From b483266c5e984862336b689ab876da12ff33de06 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 13:15:04 -0700 Subject: [PATCH 01/44] Seed the workshop crates cleanup plan Record the plan's seed and its baseline. Copy the plan into the vibe dated record, point vibe/ACTIVE at it, and record the full canonical gate results so every later step compares against a known-green baseline. - `vibe/ACTIVE` names the plan copy as the active plan for this run. - `vibe/2026-09-24-2-workshop-crates-cleanup.md` records the baseline: every canonical gate passes, with no failing or intermittent tests. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- vibe/2026-09-24-2-workshop-crates-cleanup.md | 1221 ++++++++++++++++++ vibe/ACTIVE | 1 + 2 files changed, 1222 insertions(+) create mode 100644 vibe/2026-09-24-2-workshop-crates-cleanup.md create mode 100644 vibe/ACTIVE diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md new file mode 100644 index 00000000..44833a5b --- /dev/null +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -0,0 +1,1221 @@ +--- +name: Workshop crates cleanup +overview: Cleanup of crates/workshop in the promptforge repository (branch master, after the engine consolidation). Retires "shell" for anything but terminal shells, closes a bind gap, fixes a save-timeout bug, hardens tests, standardizes registry docs and subsystem handles, moves the /prompts/contract route and shared helpers, applies the file layout convention, splits oversized code, and deletes the workshop's human docs. Runs directly on master; no rebase is needed. +todos: + - id: baseline + content: "Baseline: record test results on master before any change" + status: pending + - id: bind + content: "Bind: refuse non-loopback addresses in reuse_bind, with a test" + status: pending + - id: dead-code + content: "Dead code: delete WorkshopObserver and StatusBus helpers, drop unused server re-exports, fix gateway description and a stale comment" + status: pending + - id: shell-rename + content: "Shell rename: gateway icon copies, shell/ to desktop/, tier and constant renames, prose, UI names (desk, view, placeholder, entry bundle), vocabulary in AGENTS.md" + status: pending + - id: config-ui + content: "config-ui: views/ to pages/, page identifiers, shell to desk" + status: pending + - id: flaky-tests + content: "Flaky tests: symlink tests fail under CI, replace fixed sleeps" + status: pending + - id: save-timeout + content: "Save timeout: reproducing test, JSON 408 body on every deadline route, unknown-token state in the editor, audit of UI error consumers" + status: pending + - id: security-tests + content: "Security tests: realtime relay refusals, jail edge cases" + status: pending + - id: wire-fixture + content: "Wire fixture: shared /ws frame fixture for Rust and TypeScript, SelectModelFrame" + status: pending + - id: registry-docs + content: "Registry docs: keep subsystem-named traits, reword claims, record runtime links in the registry crate docs" + status: pending + - id: code-docs + content: "Code-level docs: fix code-comment drift; make the AGENTS.md import pointer explicit" + status: pending + - id: prompts-route + content: "Prompts route: move /prompts/contract into workshop-server, drop workspace engine dependency" + status: pending + - id: helpers + content: "Helpers: move render_message, JSON bucket validator, mock server helper, UI reconnect backoff into shared homes" + status: pending + - id: layout + content: "Layout: directories for hyphenated groups of three or more, distinct ui_state names" + status: pending + - id: renames + content: "Renames: remove server aliases, move /ws socket to a workshop_socket module, rename status relay and gateway SwitchOutcome to SwitchProfileBody, handles named structs, UI tokens into services" + status: pending + - id: split + content: "Split: supervisor.rs, socket.rs framing, compose, heartbeat and progress run loops" + status: pending + - id: docs + content: "Docs removal: delete workshop guide chapters, export, and READMEs; drop them from the guide build, doc tool, and docs-claims test" + status: pending +isProject: false +--- + +# Workshop crates cleanup + + + +## Product Requirements + +The workshop crates are well built line by line but hard for a human to explore and maintain. One word, "shell", names several unrelated things. Conventions are applied unevenly, docs contradict the code, some tests depend on timing or skip silently, and there is a bind gap and a likely save-timeout bug. This plan cleans that up inside the workshop crates and a few named exceptions, working directly on master now that the engine consolidation has landed. The workshop's human docs are deleted rather than fixed, and end-user behavior changes only where a fix requires it. + +- Problem and users: + - Users are the human maintainers and agents working on four things: the workshop desktop app, its in-process HTTP server, the subsystem crates, and the two TypeScript UIs (the workshop UI and the gateway config UI). + - "shell" currently means all of these: + - the Tauri desktop app (`crates/workshop/shell/`) + - the build check's tier for the server (`crates/build-xtask/src/tidy.rs:31`, `const SHELL: &[&str] = &["workshop-server"]`, and `crates/workshop/server/src/lib.rs:24`, "Tier: shell") + - the product-boundary rule's Tauri crate (`crates/build-xtask/src/product.rs:121`) + - a shared UI component (`createStatusBarShell` in `crates/shared-ui/status-bar.ts`) + - the workshop UI's main frame (`.ws-shell` in `crates/workshop/ui/src/parts/layout/zones.css:8`) + - a lazy panel's loading stand-in ("lazy shell", about 28 occurrences) + - the SPA entry bundle ("boot shell", `AGENTS.md:61`) + - config-ui's post-login frame (`mountLiveShell` in `crates/gateway/config-ui/ui/src/main.ts:204`) + - The workshop UI already stubs a Terminal menu (`crates/workshop/ui/src/parts/menu/stubs.contribution.ts:181-192`), where "shell" will mean a command shell. +- Goals: + - Reserve "shell" for command shells in terminals, and give every other meaning its own word. + - Close the loopback bind gap and fix the save-timeout behavior. + - Make the test suite trustworthy before restructuring: no silent skips, fewer fixed sleeps, tested security surfaces, and `/ws` frames pinned across Rust and TypeScript. + - Remove dead code and copied helpers. Make conventions uniform (subsystem handles, file layout, names). + - Split the densest files and functions along their existing seams. + - Delete the workshop's human docs (its user guide chapters, their export, and the workshop READMEs) and remove them from the guide build. Keep the guide's Gateway, Language, and Agent parts. + - Keep the remaining code-level docs accurate where this plan touches them: `//!` crate docs, `AGENTS.md` rules, comments, and Cargo descriptions. + - Keep every step fast, with just enough verification to show it works. Run the full gates only where they count. +- Non-goals: + - No edits to `crates/promptforge*` or `crates/harness/*`, and none to `crates/gateway/*` beyond the named exceptions under Constraints. + - No change to the Tauri package name `workshop` or the binary name `promptforge-workshop`. + - No change to the protocol crate's engine dependency. + - No edits to the pre-existing dated records under `vibe/`. The active plan's own repository copy and `vibe/ACTIVE` are the plan seed, and the steps edit them. + - No rewrite of workshop user documentation before beta, and no content edits to the guide's Gateway, Language, or Agent pages. +- Success criteria: + - Every work item in Execution Instructions is done. + - Every baseline command is at least as green as its recorded baseline. + - The retired-name checks in the Testing Plan exit criteria pass. +- Constraints: + - **Repository.** The repository root is `C:\Users\Vinnie\cursor\promptforge`, on branch `master` at commit 1fd82c62 ("Close plan: debt removal api firewall"). All paths in this plan are relative to that root. + - **Edit scope.** Edits are allowed in: + - `crates/workshop/**` and `crates/build-xtask` + - the workshop parts of the guide: `guide/src/workshop/`, the Workshop entries in `guide/src/SUMMARY.md`, and `guide/promptforge-workshop-guide.md` + - `crates/build-user-guide/src/main.rs`: the `SETS` list, the doc comment that counts the sets, and the two unit tests that assert the workshop set + - the link to the deleted Workshop part at `guide/src/introduction.md:27` + - the hard-coded sidecar path `crates/workshop/shell/binaries` in `tools/stage-gateway-sidecar.mjs:37`, `tools/stage-gateway-sidecar.test.mjs:116`, and `crates/build-workshop/tests/interruption.rs:51-53` + - vocabulary wording only, in `.cursor/rules/workshop-architecture.mdc` and `.cursor/rules/workshop-spa.mdc` + - these repository-root files: `Cargo.toml` members, `.gitignore`, `.github/workflows/*`, `AGENTS.md`, `README.md`, and `tools/document.md` + - the active plan's repository copy under `vibe/` and `vibe/ACTIVE`: the step marks, the baseline results, and the exit results + - **Named exceptions outside that scope.** Each is small and confined to what is named here: + - the gateway app's icon copies, and its icon and cross-reference comments (`crates/gateway/app`) + - the shared status bar rename (`crates/shared-ui` and its consumer in `crates/gateway/config-ui`) + - the config-ui page and desk renames (`crates/gateway/config-ui/ui`) + - `crates/gateway/config-ui/ui/src/services/gateway-api.ts` and `panel-bridge.ts`, but only if the timeout audit finds that they render the new 408 badly + - **History shape.** Every commit builds and passes its focused tests. + - Moved files keep their content, except for the minimal import or path fixes needed to build. + - Edits in other files that wire up a move (`mod` lines, `#[path]` attributes, imports, path strings) go in the same commit as the move. + - Identifier renames and other content edits go in separate commits. + - Git's rename detection works at this level of similarity, so `git blame --follow` still tracks the moves. + - **Step size.** Keep steps few and fast. Merge small related edits into one step whenever one focused test set covers them. Mechanical steps (moves, renames, deletions with no behavior change) need no new tests; their check is that the touched packages still compile and their existing focused tests pass. + - **File-size ceiling.** Files in crates that have the Invariants marker (every `workshop-*` crate) stay at or under 500 physical lines. `cargo test -p build-xtask` enforces this, and the desktop crate is exempt (`AGENTS.md:63`). Files already close to the limit are split before any edit that grows them. Five workshop files are within 20 lines of it: `crates/workshop/server/src/app.rs`, `crates/workshop/server/src/agents/socket.rs` (492 lines), `crates/workshop/server/tests/it/heartbeat_loop.rs`, `crates/workshop/workspace/src/workspace.rs`, and `crates/workshop/workspace/src/workspace_file.rs`. Every line count in this plan is a physical line count. + - **Verification policy.** Steps and component ends run only the targeted checks listed under Testing Plan. The full canonical gates in `AGENTS.md`, as the Project Survey records them, run only twice: at the baseline and at the final step. Nothing builds the whole desktop app (`cargo workshop`) or runs a workspace-wide suite in between. + - **Engine crate names.** New code names engine types as little as possible. Where it must, it goes through the `promptforge` facade, the only engine crate the workshop crates depend on (`crates/workshop/gateway/Cargo.toml`, `protocol/Cargo.toml`, `server/Cargo.toml`, and `workspace/Cargo.toml`). It never names a crate under `crates/promptforge-internal/`. + - **Line numbers.** The citations in `AGENTS.md`, `crates/build-xtask`, and the workshop crates' `lib.rs`, `Cargo.toml`, and `AGENTS.md` files were re-verified on master at 1fd82c62. The rest were recorded on the earlier commit 75245481. Since then, master changed the workshop crates only by renaming engine imports and dependencies to `promptforge` and sweeping docs, so those lines are at most a few off. Always locate code by its content, since lines also shift as the work lands. + - **Pre-move paths.** This plan cites paths under `crates/workshop/shell/`. They become `crates/workshop/desktop/` once the directory move lands. +- Open questions: None + +## Functional Specification + +Only four behaviors change for anyone outside the codebase: the standalone server refuses non-loopback binds, a request that hits the route deadline gets a JSON error body, the editor handles a timed-out save as an unknown state instead of a confusing failure, and `/prompts/contract` is served by the server with an unchanged wire contract. The published user guide also loses its Workshop part. Everything else is internal renaming, restructuring, testing, and doc deletion. The desktop app, installers, and release artifacts keep their names and behavior. + +- Actors and workflows: + - End users of the desktop app and of the gateway config UI see no workflow change. + - Operators of the standalone `workshop-server` binary configure it through `workshop.toml`. After this plan, a non-loopback `server.bind` fails at startup. + - Maintainers find code through the new vocabulary and a uniform layout. + - Readers of the published user guide no longer see a Workshop part; the Gateway, Language, and Agent parts are unchanged. The guide is built by `.github/workflows/guide.yml:30` (`mdbook build guide`) and published to Pages. The workshop READMEs are also gone from the repository. +- Inputs and outputs: + - **`/prompts/contract`.** Path, method, request body, response body, status codes, and wire error codes all stay the same. Only the crate that serves the route changes. + - **HTTP 408 from the route deadline.** + - Today the body is empty (`crates/workshop/support/src/deadline.rs:41`, `StatusCode::REQUEST_TIMEOUT.into_response()`). + - After this plan, the body is JSON in the shape of `ErrorEnvelope`: `{"error":{"message":"...","code":"..."}}`, with a timeout-specific code (`crates/workshop/protocol/src/error.rs:45-69`). + - **Which routes the 408 change affects.** It is a middleware change, so it covers every route wrapped by `with_deadline`, not only saves: + - the workspace routes (`crates/workshop/workspace/src/handlers.rs:33`) + - the user-state routes (`crates/workshop/user-state/src/handlers.rs:34`) + - the server routes it wraps (`crates/workshop/server/src/app.rs:481-482`) + - the gateway-config relay routes, under the 35-second relay deadline (`crates/workshop/server/src/routes/gateway_config.rs:31`) + - the `/v1/models` relay (`crates/workshop/server/src/agents/state.rs:137`) + - No existing test asserts an empty 408 body. + - **UI code that reads status or error codes.** None of it handles 408 specially today. + - Workshop UI: `crates/workshop/ui/src/services/json-request.ts:17-56`, `error-catalog.ts:17-46`, `workspace-api.ts:91-96,159-189`, `workspace-file-client.ts:104-114`, and `run-api.ts:263-267`, all under `crates/workshop/ui/src/services/`. + - config-ui: 408s from the gateway-config relay reach it through `crates/gateway/config-ui/ui/src/services/panel-bridge.ts:206-220`, and `crates/gateway/config-ui/ui/src/services/gateway-api.ts:349-363,1069-1101` maps them by status and `error.code`. +- States and validation: + - **Standalone server.** `reuse_bind` (`crates/workshop/server/src/serve.rs:327-340`) parses the configured address into a `SocketAddr` and binds it without checking what it is. After this plan, a parsed address whose IP is not loopback is refused with `std::io::ErrorKind::InvalidInput` before any socket is created. The default is `127.0.0.1:7910` (`crates/workshop/support/src/config.rs`). + - **Desktop app.** It already forces `127.0.0.1:0` (`crates/workshop/shell/src/config.rs:23,91`), so the bind check doesn't affect it. +- Errors and recovery: + - **Save timeout today:** + - A slow `PUT /workspace/file` can exceed `DEFAULT_DEADLINE`, which is 10 seconds (`crates/workshop/support/src/deadline.rs:16`). + - The client gets an empty 408, but the write runs on the blocking thread pool, can't be cancelled, and may still land on disk. + - The UI reports that the server "returned a non-JSON answer" (`crates/workshop/ui/src/services/json-request.ts:47-55`), and the next save gets a 409 conflict. + - **Save timeout after this plan:** + - **The 408 has a JSON body.** + - **The token becomes unknown.** When a save gets a 408, the editor's conflict token becomes "unknown". That state is new: today `crates/workshop/ui/src/parts/editor/editor-panel.ts:189-190` always sets `this.token = written.token`, and `crates/workshop/ui/src/services/workspace-api.ts:189` always sends `expected_token`. + - **The user is told.** The editor says the save may or may not have landed, and it never sends a stale token. + - **The next save re-reads first.** If the disk content matches what the editor tried to save, it adopts the returned token and saves. Otherwise it shows the existing conflict dialog. + - **Remaining race:** the late write can still land after that re-read. The re-read narrows the race but does not remove it. The worst case is the existing conflict dialog, never a raw error. + - **Status of the bug:** it was inferred from reading the code and has not been reproduced. The work item starts with a test that reproduces it. +- Security and privacy behavior: + - **Bind.** Loopback-only binding is enforced in code. The server's own docs already promise it (`crates/workshop/server/AGENTS.md`), and the cross-site Host check does not stop a raw LAN client that forges a loopback Host header. + - **Path jail.** Behavior is unchanged. New tests cover UNC and verbatim paths, case-only respellings, and Windows directory junctions. + - **Realtime relay.** Behavior is unchanged. New unit tests pin its origin and subprotocol refusals. +- Acceptance criteria: + - The standalone server refuses non-loopback IPv4 and IPv6 addresses, including `0.0.0.0` and `::`, and accepts `127.0.0.1` and `::1`. + - Every 408 body produced by `with_deadline`, parsed as JSON, equals `serde_json::to_value(workshop_protocol::ErrorEnvelope::new(message, code))` for the timeout message and code. + - After a save times out, the editor shows the unknown state and sends no stale token. A later save either succeeds or shows the existing conflict dialog. + - The workshop UI and config-ui show the new timeout code as a readable message, and never as a JSON parse failure. + - The `/prompts/contract` tests pass against the server with unchanged assertions. + - The desktop app builds from `crates/workshop/desktop`, and all three workflows reference the new path. + - `mdbook build guide` succeeds with no Workshop section. `cargo run -p build-user-guide` writes only the gateway, language, and agent exports and leaves them unchanged, and no workshop export remains. + + + + +## Technical Design + +The design introduces one vocabulary across the Rust crates and both UIs, moves one directory and one HTTP route, and changes a small set of public APIs in the workshop crates. It adds shared helpers to `workshop-support`, renames one shared-ui export, and gives the gateway app its own icon copies. The tier graph is unchanged apart from renaming the server's tier from "shell" to "server". Nothing outside the edit scope depends on the changed items. + +- Architecture: + - **Vocabulary.** These words apply to identifiers, file and directory names, CSS classes, and prose: + - "shell": a command shell in a terminal, and nothing else. + - "desktop app": the Tauri crate (package `workshop`). Its directory becomes `crates/workshop/desktop/`. + - "server": the build check's tier for `workshop-server`, formerly "shell", named after the only crate in it. + - "desk": a UI's main frame. The word has no existing uses in `crates/workshop/ui/src`, `crates/gateway/config-ui/ui/src`, or `crates/shared-ui`. + - In the workshop UI, `.ws-shell` becomes `.ws-desk`. It is the flex parent the dock column fills, with the status bar outside it (`crates/workshop/ui/src/parts/layout/zones.css:8`). + - In config-ui, the post-login frame (the tab bar plus its pages) becomes the desk. + - "workbench": keeps only its existing senses, and this plan adds none: + - the Model menu snapshot frame on the `/ws` socket (`WorkbenchFrame`, `{"type":"workbench"}`, in `crates/workshop/protocol/src/workbench.rs` and `crates/workshop/menu/src/menu.rs`) + - the VS Code-style UI architecture described in `crates/workshop/ui/AGENTS.md` + - "workshop socket": the `/ws` socket. Its server module becomes `workshop_socket`, pairing with the UI client `crates/workshop/ui/src/services/workshop-socket.ts`, the same way `crates/workshop/server/src/agents/socket.rs` pairs with `crates/workshop/ui/src/services/agent-socket.ts`. Today the server's crate doc (`crates/workshop/server/src/lib.rs:14`) calls it "the /ws workbench socket"; that wording becomes "the /ws workshop socket". `crates/workshop/README.md:11` says the same, but that file is deleted. + - "page": a routed screen behind a tab. This is what config-ui's `views/*-view.ts` files are today. + - "view": a DOM component. shared-ui's status bar becomes `StatusBarView`. + - "placeholder": what a lazy panel shows while its code chunk loads (`.ws-panel-lazy`). Today it is called a "lazy shell" or "empty shell", about 28 times, in `crates/workshop/ui/src/parts/layout/zones.css`, `crates/workshop/ui/src/parts/layout/panel-types.ts`, and `crates/workshop/ui/test/lazy-panel-sizing.mjs`. The local variable `shell` that holds the placeholder element in `crates/workshop/ui/test/lazy-panel-sizing.mjs:222` becomes `placeholder`. `zones.css` uses both senses: `.ws-shell` at line 8 is the desk, and the "lazy shell" prose at line 272 is the placeholder, so each occurrence in that file is classified by meaning. + - "entry bundle": what `AGENTS.md:61` calls the "boot shell", which lazy panels must never import. + - **Tier graph.** Unchanged except for the tier's name: + - The desktop app depends only on `workshop-server-api`, and `workshop-server-api` re-exports `workshop-server`. + - The server (server tier) may depend on the feature, service, and vocabulary crates. + - The feature crates (user-state, workspace) and the service crates (gateway, menu, status) depend only on vocabulary. + - Within vocabulary, `workshop-registry` depends on `workshop-protocol`. + - `cargo test -p build-xtask` enforces this graph (`crates/build-xtask/src/tidy.rs`). + - **Registry.** It keeps its subsystem-named traits: `MenuSink`, `CatalogSink`, `StatusSink`, and `WorkspaceRoots` in `crates/workshop/registry/src/traits.rs`, and `MenuPush` in `crates/workshop/registry/src/push.rs`. Its crate docs (`crates/workshop/registry/src/lib.rs`) and its Cargo description stop claiming it never names a subsystem. The crate docs record the real runtime links: + - The gateway drives the menu through `MenuPush` (`crates/workshop/registry/src/push.rs:141-175`). + - Publishing a model catalog forces a menu reconcile (`crates/workshop/registry/src/push.rs:99-106`). + - Agent sessions read the workspace's granted roots through `WorkspaceRoots`. +- Modules and interfaces: + - **Subsystem handles.** + - Every subsystem crate (gateway, menu, status, user-state, workspace) has `src/handles.rs`. Its `register` function returns a named struct of registration guards instead of a tuple, and so does `register_tasks` where one exists. + - user-state gains a `handles.rs`; its `register` lives in `crates/workshop/user-state/src/lib.rs:45` today. + - The server's `compose` (`crates/workshop/server/src/app.rs`, around lines 338-424) reads the named fields instead of unpacking tuples by position. + - **Shared helpers in `workshop-support`:** + - **Error message rendering.** `render_message` and `LEAK_DETAIL` are copied word for word in `crates/workshop/workspace/src/error.rs`, `crates/workshop/server/src/error.rs`, and `crates/workshop/user-state/src/error.rs`, and `LEAK_DETAIL` also appears in `crates/workshop/server/src/agents/relay.rs`. + - **The JSON state-bucket validator.** It checks the key against an allow list, caps the body at 1 MiB, and requires it to parse. It exists twice: in `crates/workshop/user-state/src/store.rs` and `handlers.rs`, and in `crates/workshop/workspace/src/workspace_file-ui-state.rs` and `handlers-file-state.rs`. + - **A mock HTTP server test helper** behind support's `test-fixtures` feature. It binds a loopback port and runs `axum::serve`. About 11 near-copies exist across the gateway and server tests. The server's own copy is in `crates/workshop/server/src/app-fixtures.rs:73-84`, but the gateway can't depend upward on the server, so the helper belongs in support. + - **Deadline body.** + - `with_deadline` stays in `crates/workshop/support/src/deadline.rs`, because callers exist outside the server: + - `crates/workshop/workspace/src/handlers.rs:33` + - `crates/workshop/user-state/src/handlers.rs:34` + - in the server: `crates/workshop/server/src/app.rs:481-482`, `crates/workshop/server/src/routes/gateway_config.rs:31`, and `crates/workshop/server/src/agents/state.rs:137` + - Vocabulary crates may depend only from registry to protocol, so support can't depend on protocol and builds the JSON body itself. + - To pin the shape, a server test parses the body as a JSON value and compares it with `serde_json::to_value(workshop_protocol::ErrorEnvelope::new(message, code))`. + - `ErrorEnvelope` is public, has the public constructor `new(message, code)`, and derives only `Serialize`. Its inner `EnvelopeBody` and fields are private (`crates/workshop/protocol/src/error.rs:45-69`). So the test needs no `Deserialize` and no change to the protocol API. + - The server, workspace, and user-state already build envelopes through `ErrorEnvelope::new`: `crates/workshop/server/src/error.rs:96`, `crates/workshop/workspace/src/error.rs:275`, and `crates/workshop/user-state/src/error.rs:94`. + - **`/prompts/contract`.** + - The route moves out of `crates/workshop/workspace/src/handlers-prompts.rs` and `handlers-prompts-tests.rs` into a server-owned route module under `crates/workshop/server/src/routes/`. It is mounted beside health, realtime, and gateway_config, under the default deadline. + - Its error mapping moves from the workspace error type to the server's `AppError`, keeping the same status codes and wire error codes. + - This route is workspace's only reason to depend on `promptforge` (`crates/workshop/workspace/Cargo.toml`), so workspace drops that dependency. The server already depends on `promptforge`, so nothing is added there. Both crates' Invariants blocks in `src/lib.rs` are updated to match. + - **The `/ws` workshop socket.** `crates/workshop/server/src/agents/session.rs` and `session-menu.rs` move out of `agents/` into a `workshop_socket` module in the server: `crates/workshop/server/src/workshop_socket.rs`, with its menu child in `workshop_socket-menu.rs`. `SessionsState` still mounts the `/ws` route (`crates/workshop/server/src/agents/state.rs:141`). + - **Protocol.** It gains a Rust type for the inbound `select_model` frame, next to `SwitchProfileFrame` in `crates/workshop/protocol/src/menu.rs`, plus a matching TypeScript interface in `crates/workshop/ui/src/services/protocol.ts`. + - **Workshop UI service tokens.** These four tokens and their interface types move into `crates/workshop/ui/src/services/`. The implementations stay in `parts/` and register against the tokens: + - `STATUS_BAR` (`crates/workshop/ui/src/parts/status/status-bar.ts:198`) + - `CLOSED_EDITORS` (`crates/workshop/ui/src/parts/editor/closed-editors.ts:145`) + - `EDITOR_SETTINGS_SERVICE` (`crates/workshop/ui/src/parts/editor/editor-settings-service.ts:165`) + - `QUICK_INPUT_SERVICE` (`crates/workshop/ui/src/parts/quickinput/quick-input.ts:308`) +- File and public API changes: + - **Directory move.** `crates/workshop/shell/` becomes `crates/workshop/desktop/`. Path strings change in: + - root `Cargo.toml` (the `members` entry) and `.gitignore:22,25` + - `.github/workflows/nightly.yml:204-210`, `.github/workflows/release-workshop.yml:167-191`, and `.github/workflows/workshop-installer-smoke.yml:8-9,43` + - `crates/build-xtask/src/tidy.rs:84-86`, where the fallback directory `"shell"` becomes `"desktop"`, and `crates/build-xtask/src/tidy-tests.rs:223` + - `README.md:84` and `AGENTS.md:27`. `tools/document.md:105` goes away with the workshop lens. + - the sidecar staging path `crates/workshop/shell/binaries`, in `tools/stage-gateway-sidecar.mjs:37`, `tools/stage-gateway-sidecar.test.mjs:116`, and `crates/build-workshop/tests/interruption.rs:51-53`. `cargo workshop` (`crates/build-workshop/src/main.rs`) and CI (`.github/workflows/ci.yml:175,256` and `.github/workflows/workshop-installer-smoke.yml:38`) stage the sidecar through that script. + - `git mv` leaves behind the gitignored build artifacts under the old path: the staged sidecar in `crates/workshop/shell/binaries/` and the Tauri output in `crates/workshop/shell/gen/`. Move them to the new path or delete them so no stale `crates/workshop/shell/` directory remains. `cargo workshop` re-stages the sidecar. + - two gateway comments that point to `crates/workshop/shell/src/gateway.rs`: `crates/gateway/app/src/tray/windows.rs:596` and `crates/gateway/app/src/tray/macos.rs:459` + - **Gateway icon source.** + - Today the gateway app embeds `../../workshop/shell/icons/icon.ico` (`crates/gateway/app/build.rs:23`), and its test reads the same file (`crates/gateway/app/tests/it/icon.rs:42`). + - It gets its own copies of `icon.ico`, `32x32.png`, and `64x64.png` in `crates/gateway/app/assets/`, next to the existing `tray-icon.rgba` and `tray-icon-template.rgba`. + - The build, the test, and these comments point at the copies: `build.rs:7`, `tests/it/icon.rs:2`, `Cargo.toml:19`, `src/tray/windows.rs:49-51`, `src/tray/macos.rs:63-66`, and `src/tray/linux.rs:54`. + - `crates/workshop/shell/icons/AGENTS.md` already requires config-ui's icon copies to stay in sync with the master icons. That rule is extended to cover the gateway app's copies. + - **build-xtask.** + - `crates/build-xtask/src/tidy.rs:31`: `SHELL` becomes `SERVER`, with tier name "server". The tier and fallback prose in the same file changes too (lines 27, 30, 84, and 86, including "Tier 3: the shell"). So does the "Tauri shell" wording at lines 13 and 252, which becomes "desktop app". + - `crates/build-xtask/src/product.rs:121`: `SHELL` becomes `DESKTOP`. Its value stays `"workshop"`. + - `crates/build-xtask/src/new_crate.rs:72`: the tier list in the new-crate template is updated. + - Test names that mention "shell" in `crates/build-xtask/src/tidy-tests.rs` and `crates/build-xtask/src/product-tests.rs` are renamed to match. + - **workshop-server.** + - "Tier: shell" becomes "Tier: server" (`crates/workshop/server/src/lib.rs:24`). + - The pre-decomposition module aliases are removed (`crates/workshop/server/src/lib.rs:60-67`). About 24 call sites switch to the real crate paths. + - The unused re-exports `CacheEvent`, `CacheResponse`, and `SsePayloadStream` are removed (`crates/workshop/server/src/lib.rs:93-96`), along with the `observer` alias. + - `workshop-server-api` re-exports none of these (`crates/workshop/server-api/src/lib.rs`). + - **workshop-gateway.** + - `WorkshopObserver` and its module are deleted (`crates/workshop/gateway/src/observer.rs` and `observer-tests.rs`). Nothing outside those two files uses them. + - With it go the gateway's engine dependency (`promptforge`, which is used only there) and the words "the run event log" in its Cargo description (`crates/workshop/gateway/Cargo.toml:9`). + - The public cache API (`cache_ensure`, `CacheEvent`, `CacheResponse`, `SsePayloadStream`) stays, for a planned caller. + - The gateway's `SwitchOutcome` is the switch-profile JSON body. It is renamed `SwitchProfileBody` so it stops colliding with workshop-menu's unrelated `SwitchOutcome`. + - **workshop-status.** `StatusBus::report`, `info`, `debug`, `error`, and `idle` are removed (`crates/workshop/status/src/status.rs:67-117`). Only their own tests call them; producers use `Push`. + - **shared-ui.** `createStatusBarShell` becomes `createStatusBarView`, and `StatusBarShell` becomes `StatusBarView` (`crates/shared-ui/status-bar.ts`). + - The code consumers are `crates/workshop/ui/src/parts/status/status-bar.ts`, `crates/workshop/ui/test/shared-status-bar.mjs`, and `crates/gateway/config-ui/ui/src/components/status-bar.ts`. + - Comments change in `crates/shared-ui/status-bar.css:9`, the `crates/shared-ui/package.json` description, `crates/gateway/config-ui/ui/src/components/status-bar.test.mjs:3`, and `crates/gateway/config-ui/ui/src/styles/layout.css:1423`. + - **config-ui** (paths relative to `crates/gateway/config-ui/ui/src`). Nothing in the workshop crates references these names. + - **Files.** `views/` becomes `pages/`. The six `*-view.ts` and `*-view.test.mjs` pairs (cloud-models, discover, models, profiles, secrets, settings) become `*-page.*`. `apply-revert.test.mjs`, `model-detail.test.mjs`, and `settings-sections.test.mjs` move without being renamed. The six imports at `main.ts:35-40` follow. + - **Page identifiers.** About 300 references change: + - `createXView` becomes `createXPage`, and `XViewDeps` becomes `XPageDeps`. + - `ViewId` becomes `PageId`, and `viewRoot` becomes `pageRoot`. + - `setActiveView` becomes `setActivePage`, `tabByView` becomes `tabByPage`, and `defaultView` becomes `defaultPage`. + - `PendingView` becomes `PendingPage`, and `disposeView` becomes `disposePage`. + - The `.view-empty` class becomes `.page-empty`. + - `review`, `viewport`, and `openReviewDiff` stay unchanged. + - **The desk.** About 77 references across 22 files change, test descriptions included: + - `mountLiveShell` (`main.ts:204`) becomes `mountLiveDesk`, and `showShell` (`main.ts:103`) becomes `showDesk`. + - The inert panel-mode mount, documented at `main.ts:475`, is described as the inert desk. + - `main.className = "shell"` (`main.ts:513`) and the `.shell` rules at `styles/layout.css:71,1438` become `.desk`. + - Local variables named `shell` that hold the frame are renamed as well. + - **Layout.** + - A group of three or more hyphenated sibling files moves into a directory in standard module layout, and its `#[path]` attributes are dropped. The repository already states this convention at `AGENTS.md:64`. The groups: + - `crates/workshop/workspace/src/workspace.rs` (8 path-wired children) + - `crates/workshop/workspace/src/handlers.rs` + - `crates/workshop/workspace/src/workspace_file.rs` + - the gateway's `crates/workshop/gateway/src/gateway_progress-*` group + - The four modules named `ui_state` get distinct names. They live in `workspace-ui-state.rs`, `workspace-tests-ui-state.rs`, `workspace_file-ui-state.rs`, and `workspace-file-tests-ui-state.rs`. + - **Workshop docs removal.** + - **Deleted from the guide:** `guide/src/workshop/` (the index and chapters 01 through 11), the Workshop entries in `guide/src/SUMMARY.md` (lines 4-17), and the export `guide/promptforge-workshop-guide.md`. The Gateway (`SUMMARY.md` lines 20-33), Language (35-47), and Agent (49-61) parts stay. + - **The guide build.** `crates/build-user-guide/src/main.rs:17-22` lists four export sets (workshop, gateway, language, agent), and lines 79-81 write `promptforge-{set}-guide.md` for each. `workshop` is removed from that list. The doc comment then counts three sets, and the two unit tests that assert the workshop set (`summary_has_parts_in_audience_order` and `assembly_is_deterministic`) are pointed at the remaining parts. The crate generates `guide/src/SUMMARY.md` and each part's `index.md`, so those files are regenerated with `cargo run -p build-user-guide`, never edited by hand. + - **The guide introduction.** Its link to the deleted Workshop part (`guide/src/introduction.md:27`) is removed. Nothing else on that page changes. + - **Deleted from the crates:** `crates/workshop/README.md`, `crates/workshop/server/README.md`, `crates/workshop/shell/README.md`, `crates/workshop/user-state/README.md`, and `crates/workshop/workspace/README.md`. Any Cargo `readme` key, `include_str!`, or link that names one of them goes too. + - **Kept:** the four agent-rule files (`crates/workshop/server/AGENTS.md`, `crates/workshop/shell/AGENTS.md`, `crates/workshop/shell/icons/AGENTS.md`, and `crates/workshop/ui/AGENTS.md`), the license notices in `crates/workshop/ui/THIRD_PARTY_NOTICES.md`, the `//!` crate docs with their mandatory Invariants blocks, and the Cargo descriptions. + - **The docs-claims test.** `crates/workshop/ui/test/docs-claims.mjs` (lines 35-73) checks root `AGENTS.md`, every page under `guide/src`, and the workshop export for stale phrases. Its workshop-export check is removed; the other two checks stay. + - **The doc tool.** `tools/document.md` loses its workshop lens, which writes `guide/src/workshop/` and targets the workshop crates at line 105, so the tool can't regenerate the deleted guide. + - **Rustdoc is unchanged.** CI already leaves the three top workshop crates out of `cargo doc` (`.github/workflows/ci.yml:141`). +- Data, persistence, failure, security, and privacy constraints: + - No persisted format changes: `.pfwork` workspace files, the user-state JSON file, and the `workshop.toml` schema all stay the same. + - The only wire change is the 408 body. The `/ws` and `/agents/ws` frame shapes stay the same, and the new fixture pins them. + - The icon copies stay byte-identical to the master icons until the brand changes. + + + + +## Testing Plan + +The full canonical gates run twice: once before any change, as the baseline, and once at the final step. In between, each step runs only its own focused tests, and each component end runs the suites and lints of just the packages that component touched, so no step rebuilds the world. New tests pin the bind refusal, the 408 body and the editor's recovery from it, the realtime relay's refusals, the jail's edge cases, and the shapes of the `/ws` frames. Timing-based tests move to event-driven waits. Exit also requires a clean guide build and a grep showing the retired names are gone within scope. + +- Unit: + - **Bind refusal**, in `crates/workshop/server/src/serve-tests.rs`: non-loopback IPv4 and IPv6 addresses are refused, and `127.0.0.1` and `::1` are accepted. + - **Realtime relay**, in a new `crates/workshop/server/src/routes/realtime-tests.rs`: origin refusal and subprotocol refusal. `crates/workshop/server/src/routes/realtime.rs` has no unit tests today. + - **Jail edge cases**, in `crates/workshop/workspace/src/workspace-tests.rs`: UNC and verbatim `\\?\` paths, case-only respellings of a granted root, and a Windows directory junction. + - **Socket framing helpers.** They get table-driven tests when they are split out of `crates/workshop/server/src/agents/socket.rs`, which has 2 unit tests today. + - **408 body shape.** A server test parses the body that `with_deadline` produces as a JSON value, and compares it with `serde_json::to_value(workshop_protocol::ErrorEnvelope::new(message, code))`. + - **`/prompts/contract`.** Its tests move to the server with their assertions unchanged. +- Integration and end-to-end: + - **Save timeout.** + - A server test reproduces today's behavior: a write that runs past the deadline gets an empty 408, and the write can still land on disk. It must fail against the unfixed code and pass after the fix. + - UI tests in `crates/workshop/ui/test/` cover how the editor handles a 408 on save: + - the token becomes unknown, and no stale token is sent + - a disk match adopts the new token and saves + - a mismatch shows the conflict dialog + - a late write that lands after the re-read leads to the conflict dialog, not a raw error + - **`/ws` frame fixture.** A new `crates/workshop/protocol/tests/fixtures/workshop-frames.json` covers the status, models, workbench, error, and switch_profile frames. Both `crates/workshop/protocol/tests/it/` and a new `crates/workshop/ui/test/workshop-wire-fixtures.mjs` assert it. This mirrors the existing `crates/workshop/protocol/tests/fixtures/agent-frames.json` and `crates/workshop/ui/test/agent-wire-fixtures.mjs`. + - **Gateway icon embedding.** `cargo nextest run --locked -p gateway` runs it. `gateway` is the package name of `crates/gateway/app`. + - **Both UIs.** At component ends that touch a UI, and at the final step, run `npm test`, `npm run typecheck` (`tsc --noEmit`), and `npm run build` (`node build.mjs`) in the touched UI: `crates/workshop/ui`, `crates/gateway/config-ui/ui`, or both (the scripts are at `package.json:11-14` in each). `npm test` runs `node --test` over `.mjs` files and does not typecheck the `.ts` sources, which is why typecheck is a separate step. +- Regression, security, and performance: + - **Silent skips.** Some workspace symlink tests print a message and return when they can't create a symlink. Find them by grepping for `eprintln` in `crates/workshop/workspace/src/*-tests*.rs`. They must panic instead when the `CI` environment variable is set. + - **Fixed sleeps.** Replace these with event-driven waits, or with `tokio::time::pause` where the code under test uses tokio timers: + - `crates/workshop/server/tests/it/realtime_relay/overload.rs:19` (750 ms) + - `crates/workshop/server/tests/it/chat_gate/lifecycle.rs:79` (a 150 ms quiet window) + - `crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs:148` (`TEST_INTERVAL * 4`) + - `crates/workshop/shell/src/gateway/tests/recovery.rs:274` (a 5-second hang fixture) + - Paused time already works in this codebase: `crates/workshop/support/src/deadline.rs` uses `start_paused`. + - **Structural checks.** `cargo test -p build-xtask` checks the tier graph, the Invariants marker, the file-size ceiling, and the product boundaries. It builds quickly. Run it after any change to manifests, crate names, or file layout. + - **Docs.** `docs-claims.mjs` keeps guarding root `AGENTS.md` and the remaining guide pages. `mdbook build guide` and `cargo run -p build-user-guide` confirm that the guide builds without its Workshop part. +- Exit criteria: + - **Canonical gates**, the full verification. They run at the baseline and again at the final step, and nowhere else. Each must end at least as green as its baseline. + - They are the canonical gates in `AGENTS.md`, exactly as the Project Survey records them: the full-suite test, linter, formatter-check, and docs commands, including the facade gates master added. + - They also include `cargo test -p build-xtask`, `mdbook build guide`, and the three npm scripts in `crates/workshop/ui` and in `crates/gateway/config-ui/ui`. + - At the final step, add `cargo run -p build-user-guide` (only the three remaining exports are written, unchanged) and `cargo workshop` (the full desktop build). + - **Per-step checks** (minimal): + - The step's own tests only: the survey's focused test pattern with a test-name filter, or the single `node --test` file. + - `npm run typecheck` in a UI whose `.ts` files the step changed. + - `cargo test -p build-xtask` when the step changes manifests, crate names, or file layout. + - Mechanical steps with no behavior change add no tests. Their check is that the touched packages still compile and their existing focused tests pass. + - No clippy, no full-crate suites, no `cargo workshop`, and no workspace-wide run at a step. The pre-commit hook runs `cargo fmt`. + - **Component-end checks.** These cover the packages that component touched, and nothing wider: + - the survey's component test commands + - `cargo clippy --all-targets -- -D warnings` with one `-p` flag per touched package + - `cargo fmt --all --check` + - `npm test`, `npm run typecheck`, and `npm run build` in any UI the component touched + - for a component that includes the directory move, also `cargo test -p build-xtask` and `cargo nextest run --locked -p gateway`, which covers the icon test + - **Retired names.** A grep over the edit scope, excluding `vibe/`, returns nothing for any of these: `workshop/shell`, `Tier: shell`, `StatusBarShell`, `createStatusBarShell`, `ws-shell`, `mountLiveShell`, `showShell`, `WorkshopObserver`, "workbench socket", "lazy shell", "empty shell", "boot shell". It also finds no identifier `SHELL` in `crates/build-xtask/src`. + - **Identifiers named `shell`.** No variable, parameter, or field named `shell` remains in `.rs`, `.ts`, or `.mjs` files in scope. + - **Remaining "shell" hits.** Any left in `crates/workshop`, `crates/build-xtask`, `crates/shared-ui`, `crates/gateway/config-ui/ui/src`, or the root docs must mean a terminal command shell. + + + + +## Decision Record + +- Decisions: + - **Retire "shell" for everything except terminal command shells.** + - Rationale: the word has at least eight meanings across the workshop crates and UIs, and the stubbed Terminal menu will make it mean bash or PowerShell. + - User: "maybe 'shell' should be terminology for terminals and we should use something else for Tauri". + - **Call the Tauri crate "the desktop app", in `crates/workshop/desktop/`.** + - Rationale: its Cargo description already says "desktop app", and prose already says "desktop shell". Every alternative collides with an existing name (see Rejected alternatives). + - **Keep the package name `workshop` and the binary name `promptforge-workshop`.** + - Rationale: these are user-facing release identifiers, and they were already renamed once, in commit 21493cb5 on 2026-09-02. + - The user chose "Keep both names; rename only the directory and the prose". + - **Rename the server's tier to "server".** + - Rationale: the tier holds only `workshop-server`, and "root" is already overloaded. `AGENTS.md:29` uses "root" for the `crates/` public layer, and "composition root" and "repository root" are both in use. + - The user chose "server: the tier holds only workshop-server, so name it after the crate". + - **Adopt the UI vocabulary: desk, page, view, placeholder, and entry bundle. "workbench" keeps only its existing senses.** + - Rationale for "desk": it has no existing uses in the two UI source trees or shared-ui. + - "workbench" was rejected because it already means three things: `WorkbenchFrame` and `{"type":"workbench"}` on the wire, the `/ws` "workbench socket", and the VS Code mechanics described in `crates/workshop/ui/AGENTS.md`. Using it for the main frame would add senses to a word that is already overloaded. + - Using "view" for the main frame would invert the hierarchy, since a view would then contain pages. + - User: "how about settings-page, discover-page ?" After the review showed the collision, the user chose "desk" for the main frame. + - **Name the `/ws` server module `workshop_socket`.** + - Rationale: it pairs with the UI's client for that socket, `crates/workshop/ui/src/services/workshop-socket.ts`, the same way the agent socket pairs server `agents/socket.rs` with UI `agent-socket.ts`. It also avoids "workbench". + - **Handle the save timeout in the client, as an unknown-token state, with an honest success criterion.** + - Rationale: the blocking write can't be cancelled and may land after any re-read. No client-side re-fetch can guarantee that the next save won't conflict. The criterion is therefore that the editor surfaces the unknown state, never sends a stale token, and that a later save either succeeds or shows the existing conflict dialog. + - The user chose the client-only design. + - **Rename the gateway's `SwitchOutcome` to `SwitchProfileBody`.** + - Rationale: the name describes the wire body, and it stops colliding with workshop-menu's `SwitchOutcome`. + - **Verify minimally per step, and fully only at the baseline and the final step.** + - Each step runs only its own focused tests. Component ends run the suites and lints of the touched packages. The full canonical gates (`AGENTS.md:51-57`) plus `cargo workshop` run only at the baseline and the final step. + - Rationale: the full gates rebuild the whole workspace and the desktop app. Running them per step makes each step slow and adds little over focused tests plus per-component checks. + - User: "47 steps is quite a lot. I want each step to go fast. minimal verification. just enough to make sure it works, I dont want a huge rebuilding or global test run. do a full verify where it counts". + - **Delete the workshop's human docs and remove them from the guide build.** + - Scope: the guide's Workshop chapters, their SUMMARY entries, the workshop export and its `build-user-guide` set, and the five workshop READMEs. The `AGENTS.md` agent rules, the third-party license notices, the `//!` crate docs that the build check requires, and the Cargo descriptions all stay. The docs-claims test drops only its workshop-export check. + - Rationale: before beta, prose about a fast-moving product goes stale faster than anyone can maintain it. + - User: "let's just delete all the workshop docs and remove them from the docs build. they are going to go stale very fast and keeping them up to date while the product is pre-beta is nothing but a tax on development. keep promptforge, gateway, and harness docs." + - The user chose "Human docs only" (keep the `AGENTS.md` files). + - **Remove the workshop lens from `tools/document.md`.** + - Rationale: the tool would otherwise regenerate the deleted guide. + - The user chose to remove it. + - **Approve four small scope widenings that the steps need.** Each is confined to the named lines. + - The hard-coded sidecar path in `tools/stage-gateway-sidecar.mjs`, its test, and `crates/build-workshop/tests/interruption.rs`. Without it, `cargo workshop` and CI can't find the sidecar after the directory move. + - The two unit tests and the doc comment in `crates/build-user-guide/src/main.rs` that break when the workshop set leaves `SETS`. + - The dead link to the Workshop part at `guide/src/introduction.md:27`. + - Vocabulary wording in `.cursor/rules/workshop-architecture.mdc` and `.cursor/rules/workshop-spa.mdc`, which still say "shell" and "boot shell". + - The user approved all four. + - **Allow a conditional edit to config-ui's `gateway-api.ts` and `panel-bridge.ts`.** The edit is made only if the timeout audit finds they render the new 408 badly. + - Rationale: the edit is small and made only if needed. `refusalDetail` already reads the envelope, so no edit is expected. + - The user chose to add the exception. + - **Plan the full cleanup, not just the rename.** + - The user chose "The full cleanup sequence (all phases), with the rename as a step in phase 1". + - **Keep promptforge, harness, and gateway out of scope, except for named exceptions.** + - User: "this plan should also not touch promptforge, harness, or gateway". + - Then: "break gateway's dependency on workshop/shell by just making copies of the icons and putting them in a gateway crate". + - Then: "you can reanme createStatusBarShell , the blast radius in gateway would be quite minimal and master isn't touching gateway so its very safe". + - Then: "I want config-ui's change in the plan". + - **Run the plan directly on master in the promptforge repository.** + - Rationale: master finished the engine consolidation (1fd82c62, "Close plan: debt removal api firewall"), so the plan runs on top of it. That removes the rebase and every conflict it would have caused. + - User: "change the plan to @promptforge repo, and survey based on that". + - This supersedes the earlier target, `vibe2` in the promptforge2 worktree with a rebase afterward (user: "when this plan finishes executing I plan to just rebase vibe2 on top of the completed master"). + - **Keep the registry's subsystem-named traits and fix its docs.** + - Rationale: moving the traits into the crates that own them would create service-to-service dependencies, which the tier check forbids. A new interfaces crate would add a crate for nothing more than a rename. + - The user chose "Keep the traits in workshop-registry; reword the 'never names a subsystem' claims and write down the real runtime links". + - **Standardize subsystem handles on a named struct.** + - Rationale: `handles.rs` currently takes four different shapes across five crates (user-state has none), and callers unpack unnamed tuples of guards by position (`crates/workshop/server/src/app.rs:344,381`). + - The user chose "Every subsystem gets a handles.rs whose register returns a named struct of registration guards". + - **Move `/prompts/contract` into the server.** + - Rationale: it's a pure prompt parse that has nothing to do with the filesystem jail, and it's the workspace crate's only reason to depend on the engine runtime. + - The user chose "Move it into workshop-server as a server-owned route; workspace drops the engine dependency". + - **Keep the gateway client's cache API and drop only the server's re-exports.** + - The user chose "Keep it for a planned caller; only drop the server's re-exports". + - **Move the workshop UI service tokens into `services/`.** + - Rationale: it makes the rule that parts depend on services literally true. + - The user chose "Move the tokens and their interface types into ui/src/services; implementations stay in parts". + - **Build the 408 body inside support, and pin its shape with a server test that compares JSON values against a serialized `ErrorEnvelope::new(message, code)`.** + - Rationale: the workspace and user-state crates call `with_deadline`, and support can't depend on protocol. Comparing JSON values avoids adding `Deserialize` to a public protocol type. + - The body change applies to every deadline-wrapped route, so every UI consumer of status or error codes is audited, not just the editor. + - **Name the route-deadline wire code `deadline_elapsed`.** + - The 408 body is `{"error":{"message":"...","code":"deadline_elapsed"}}` with `content-type: application/json`. The message names the elapsed deadline in seconds and says the operation may still complete, for example "the request did not finish within its 10s deadline; the operation may still complete". + - `workshop-support` exports the code as a constant and the message builder, so the server's shape test uses the same source as the middleware. + - Rationale: existing wire codes are lowercase snake_case names of the failure (`modified_conflict`, `gateway_unreachable`). This one matches `with_deadline` and its "request deadline elapsed" log line, and it says the server abandoned the response, which HTTP's "Request Timeout" (a slow client) does not. Both UIs key on the string, so it is a wire contract. + - Added during step decomposition, where the plan had named only "a timeout-specific code". The user confirmed "Keep deadline_elapsed". + - **Make the `AGENTS.md` pointer explicit.** + - Each workshop `src/lib.rs` (for example `crates/workshop/support/src/lib.rs:10`) and the new-crate template (`crates/build-xtask/src/new_crate.rs:73`) say "Read `AGENTS.md` before adding an import." without saying which file. The sentence will name the repository-root `AGENTS.md`, plus the crate's own file for crates that have one. + - Rationale: crate-level `AGENTS.md` files exist only in `crates/workshop/ui`, `crates/workshop/shell`, `crates/workshop/server`, and `crates/workshop/shell/icons`. + - **Order the work through dependencies:** + - Tests are made reliable before code is restructured. + - The settled decisions come before the moves they shape. + - The workshop docs are deleted early, before the vocabulary renames, so no step edits a file that is about to be deleted. + - The remaining code-level doc fixes come last. + - Rationale: a restructure needs a suite you can trust, and text that describes composition goes stale fastest, because the composition root changes most often: `crates/workshop/server/src/app.rs` had 29 commits and `crates/workshop/server/README.md` had 28 between 2026-09-02 and 2026-09-24, following renames. + - **Every commit builds.** Moved files keep their content, apart from the minimal import or path fixes needed to build. Edits that wire up a move go in the same commit as the move. Identifier renames and other content edits go in separate commits. + - Rationale: execution runs focused tests, a review, and periodic verification at every step, so a commit that doesn't build fails all three. Git's rename detection still works at high similarity, so `git blame --follow` keeps tracking the moves. + - This applies to `views/` to `pages/` (moves plus identifier renames) and to `shell/` to `desktop/` (a move plus identifier and prose edits). + - The user chose "Every commit builds", replacing the earlier pure-rename rule. +- Rejected alternatives: + - **Running on `vibe2` in the promptforge2 worktree and rebasing onto master afterward.** This was superseded once master's consolidation finished, because running on master removes the rebase and its conflicts. Revisit: none. + - **Renaming the Tauri package or binary.** It would churn release identifiers. Revisit if matching the other `workshop-*` package names becomes important. + - **"app", "host", "window", or "launcher" for the Tauri crate.** + - "app" collides with the server's `app.rs` and `AppState`, the gateway's `app` crate, and config-ui's `#app` document root. + - "host" collides with `HostSnapshot` and with "embedding host" in the server docs. + - "window" and "launcher" undersell the crate, which also supervises the gateway and runs the updater. + - Revisit: none. + - **"frame" for UI pieces.** It already means wire frames (`StatusFrame`, `WorkbenchFrame`) and the iframe that hosts config-ui. Revisit: none. + - **"view", "layout", "screen", or "app" for config-ui's post-login frame.** + - "view" inverts the hierarchy. + - "layout" undersells a function that also starts data flows. + - "screen" produces awkward names like `mountLiveScreen`. + - "app" is already the document root (`#app`, `app.js`, `app.css`). + - Revisit: none. + - **Deferring the directory move because the gateway embeds its icon from the workshop directory.** Giving the gateway its own icon copies replaced this. Revisit: none. + - **Deferring the shared-ui status bar rename.** Only two gateway files use the name, so the change is small. Revisit: none. + - **Moving the registry's subsystem-named traits into their owning crates, or into a new interfaces crate.** The first is forbidden by the tier check. The second adds a crate just to rename. Revisit if the registry has to become a pure type map for some other reason. + - **Only documenting the current `handles.rs` shapes.** Callers would still unpack tuples by position. Revisit: none. + - **Putting `/prompts/contract` in a new crate, or keeping it in workspace.** The server already owns several routes, and a new crate adds overhead for a single route. Revisit if more prompt-related routes appear. + - **Deleting the gateway cache API.** A caller is planned. Revisit if that caller is dropped. + - **Only updating `crates/workshop/ui/AGENTS.md` to allow tokens under `parts/`.** The layering rule would stay aspirational. Revisit: none. + - **Moving `with_deadline` into the server.** The workspace and user-state crates call it. Revisit: none. + - **"workbench" for the UI main frame.** It was chosen at first, then dropped because the word already has three senses. Revisit: none. + - **"scaffold" for the UI main frame.** It reads as code scaffolding. Revisit: none. + - **"layout", "main", "frame", "chrome", or "console" for the UI main frame.** Each already has many uses across the UI trees and shared-ui: 122, 153, 165, 46, and 35 whole-word occurrences respectively. They mean dock arrangement, the main zone and `main.ts`, wire frames and iframes, window chrome, and the browser or OS console. Revisit: none. + - **"root" or "composition" for the server's tier.** "root" is overloaded, and "composition" is less direct than naming the tier after its only crate. Revisit: none. + - **A per-path lock in workshop-workspace, so that reads wait for in-flight writes.** It would remove the save race, but it adds concurrency machinery to the jail crate. Revisit if conflict dialogs after save timeouts turn out to be common. + - **Adding `Deserialize` to `ErrorEnvelope`.** It would be a public protocol API change that the JSON-value comparison makes unnecessary. Revisit if a Rust client ever needs to parse envelopes. + - **Adding crate-level `AGENTS.md` files everywhere, or deleting the pointer sentence.** An explicit reference is cheaper and removes the ambiguity. Revisit: none. + - **Strictly pure-rename commits that don't build.** They would fail the focused tests, the review test runs, and periodic verification that execution runs at every step. Revisit: none. + - **Keeping the workshop docs and fixing their drift.** Before beta, maintaining them costs more than they return. Revisit at beta. + - **Deleting the workshop `AGENTS.md` files as well.** They hold rules that the code and this plan rely on, such as the icon sync rule and the UI layering rules. Revisit: none. + - **Keeping the workshop lens in `tools/document.md`.** It would regenerate the deleted guide. Revisit at beta, together with the docs. + - **Running the full gates at every step or component end.** They are slow and add little beyond focused tests and per-component checks. Revisit if a component end misses a regression that a full run would have caught. +- Assumptions, risks, and notes: + - **Repository state.** + - Master is at 1fd82c62, just after the engine consolidation. No plan is active (`vibe/ACTIVE` is absent). + - The workshop crates depend on the `promptforge` facade. + - The build-xtask `SHELL` constants are unchanged: `&["workshop-server"]` in `tidy.rs` and `"workshop"` in `product.rs`. + - `crates/build-user-guide`, `crates/build-workshop`, and `.cursor/rules` are unchanged since commit 75245481. + - **The bind gap** affects only the standalone `workshop-server` binary. + - **The save-timeout bug** was inferred from the code: a task on tokio's blocking pool can't be cancelled. It has not been reproduced. + - **Residual race, by design:** after a save times out, the late write can land after the editor re-reads the file. The worst case is the existing conflict dialog. + - **Test-coverage statements** in this plan are static estimates. No coverage tool was run. + - **Risk: busy files.** The restructure touches the most-changed files (`crates/workshop/server/src/app.rs` and `crates/workshop/server/src/lib.rs`). No other plan is active, so no concurrent workshop work competes for them. + - **Risk: broad renames.** The mechanical renames are large: about 300 page references and 77 desk references in config-ui (a measurement found 287 "view" occurrences), and about 130 "shell" occurrences in the workshop UI (a measurement found 108). They can catch unrelated words. The exclusions are listed (`review`, `viewport`, `openReviewDiff`), and the Testing Plan's retired-name and identifier greps catch misses. + - **Risk: duplicated icons.** The gateway's icon copies duplicate brand assets. The sync rule in `crates/workshop/shell/icons/AGENTS.md` contains this. + - **Risk: no workshop user guide.** The published guide has no Workshop part until the docs are rewritten. + - **Risk: late failures.** With full gates only at the baseline and the final step, a cross-package break can surface late. Component-end checks on the touched packages contain most of this, and the final step's full gates catch the rest. + +### Deferred and Out of Scope + +- **Deferred: the protocol crate's dependency on the engine.** It depends on the full `promptforge` facade, which was a deliberate design decision. Revisit if build times or drift in the wire format caused by engine types become a problem. +- **Deferred: cross-part imports in the workshop UI.** `STATUS_BAR` and `openInZone` act as hubs (for example `crates/workshop/ui/src/parts/run/run-panel.ts:28-31`). Revisit after the service tokens move into `services/`. +- **Deferred: workshop user documentation**, meaning the guide chapters and READMEs, and the `tools/document.md` workshop lens. Revisit at beta. +- **Out of scope:** edits to `crates/promptforge*`, to `crates/harness/*`, and to `crates/gateway/*` beyond the named exceptions. +- **Out of scope:** content edits to the guide's Gateway, Language, and Agent pages. +- **Out of scope:** renaming the Tauri package or binary. +- **Out of scope:** dated files under `vibe/`. + + + + +## Project Survey + +- Status: complete +- Build command: None for focused and component verification, because their test commands compile only the touched packages. For full verification only, `cargo workshop` (alias for `run -p build-workshop --`, accepting `--release` and `--target `) builds the gateway, stages it as the Tauri sidecar, builds the desktop app, and removes the staged copy. Plain `cargo build` builds only the default member, the gateway (`crates/gateway/app`). `cargo build -p workshop` alone needs a pre-staged sidecar; CI stages one with `node tools/stage-gateway-sidecar.mjs stage --target --source target/debug/promptforge-gateway` (`.exe` on Windows) and removes it with `node tools/stage-gateway-sidecar.mjs remove --target `. +- Focused test command pattern: `cargo nextest run --locked -p --all-features ` for any main-partition package. For `workshop`, `workshop-server`, and `workshop-server-api`, drop `--all-features` to match CI (on `workshop-server` it would turn on `headless`): `cargo nextest run --locked -p `. Workshop UI: `node --test crates/workshop/ui/test/.mjs`. Gateway config UI: `node --test crates/gateway/config-ui/ui/src/.test.mjs`. UI tests need `npm ci --prefix ` first. +- Component test command pattern: main partition, `cargo nextest run --locked -p --all-features` then `cargo test --locked -p --all-features --doc`. Workshop partition, `cargo nextest run --locked -p ` then `cargo test --doc -p `, plus `cargo nextest run --locked -p workshop-server --features headless` when touching `workshop-server`. When touching `crates/promptforge/` or anything it re-exports, also run the facade docs and surface gates listed under Docs command. UI: `npm test --prefix crates/workshop/ui` or `npm test --prefix crates/gateway/config-ui/ui`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, then `cargo nextest run --locked -p workshop-server --features headless`, then `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`, then `npm test --prefix crates/workshop/ui` and `npm test --prefix crates/gateway/config-ui/ui`. The boundary and structural harness `cargo test -p build-xtask` runs inside the workspace nextest pass and can be run alone. Its nightly-only fixtures are `#[ignore]`d and run only in CI's api-surface job: `cargo + nextest run --locked -p build-xtask --run-ignored only`. +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings` and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`, plus the headless gate `cargo check -p gateway --no-default-features`. UI typecheck: `npm run typecheck --prefix crates/workshop/ui` and `npm run typecheck --prefix crates/gateway/config-ui/ui`. Supply chain (CI, and pre-push when installed): `cargo deny check`; CI also runs `cargo audit` and a check that `ring` stays out of the gateway's normal dependency closure. Never run a standalone `cargo check --workspace` beside clippy. `clippy.toml` allows `unwrap` and `expect` in tests. +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`; the pre-commit hook runs it). No TypeScript formatter is configured. +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` with `RUSTDOCFLAGS` set to `-D warnings` (PowerShell: `$env:RUSTDOCFLAGS="-D warnings"`), then the facade docs with default features, `cargo doc -p promptforge --no-deps`, under the same flags, and `mdbook build guide` for the user guide. Facade surface gate: `cargo +nightly-2026-09-05 xtask api --check` (the nightly is pinned in `crates/build-xtask/src/api/toolchain.rs`; the committed listing is `crates/promptforge/public-api.txt`). The CI docs job does not cover the three workshop-partition packages. +- Test placement and naming conventions: + - Rust unit tests take two forms. The workshop, harness, promptforge-internal, and build-xtask crates mostly use a sibling file `-tests.rs`, wired at the bottom of the parent module with `#[cfg(test)]` and `#[path = "-tests.rs"] mod tests;` (about 150 such files). Further splits are `-tests- + + +## Execution Instructions + +Rules for every step: + +- Each step is one commit with the subject given in its Commit line, holding only that step's code, tests, and plan marks. + - Step 1 changes no code. Its baseline results and completion mark go into the active plan and fold into the plan seed commit. + - Step 34 changes code only if an exit check needs a fix. Otherwise its commit holds the exit results and its completion mark. +- **History shape.** Every commit builds and passes its focused tests. A step that moves files uses `git mv`, and in the same commit makes only the edits that wire up the move (`mod` lines, `#[path]` attributes, imports, path strings) plus the minimal import or path fixes inside the moved files. Identifier renames and other content edits get their own steps. Before committing a move, `git diff --cached -M --name-status` must list every moved file as an `R` entry, never as a delete-and-add pair. +- **Checks.** A step runs only its Tests line. The last step of a component also runs its Component end line. No clippy, no full-crate suite, no `cargo workshop`, and no workspace-wide run at a step; the pre-commit hook runs `cargo fmt`. A step is done when its Tests line passes and nothing is worse than Step 1's baseline. `cargo nextest run --no-run` is the compile check for a mechanical step. Main-partition packages take `--all-features` unless a step's command says otherwise; `workshop`, `workshop-server`, and `workshop-server-api` never take it (Project Survey). +- **Sidecar.** Building the `workshop` package needs the gateway sidecar that Step 1 stages. It stays staged, and gitignored, until Step 34. +- **Ceiling guard.** Before editing a Rust file in a `workshop-*` crate, count its physical lines, and split it first if the edit would take it past 500. The files near the limit on master: `crates/workshop/server/src/agents/socket.rs` (492, relieved by Step 21), `crates/workshop/server/src/app.rs` (489, relieved by Step 20), `crates/workshop/workspace/src/workspace-tests.rs` (475), `crates/workshop/workspace/src/workspace.rs` (493), `crates/workshop/workspace/src/workspace_file.rs` (494), and `crates/workshop/server/tests/it/heartbeat_loop.rs` (495). +- **Layout convention.** A new file that gives a module a third hyphenated sibling turns the group into a directory in standard layout (`AGENTS.md:64`). +- **Scope.** Stay inside the Constraints' edit scope and named exceptions. For any edit outside them, stop and report. +- **Line numbers.** See Constraints, "Line numbers", for which citations were re-verified on master. Locate code by its content. Paths under `crates/workshop/shell/` become `crates/workshop/desktop/` from Step 13 on. +- When a step is done, append ` [completed]` to its heading and leave its tags unchanged. + +Components, in dependency order: + +1. **Baseline** (Step 1). Every later check compares against it. +2. **Workshop docs removal** (Step 2). It depends only on the baseline. It goes first so no later step edits a file that is about to be deleted, which is why the Decision Record deletes the docs before the vocabulary renames. One piece. +3. **Trustworthy tests** (Steps 3-6): flaky-tests, security-tests, wire-fixture. Before any code change, so every later "no worse than baseline" check runs on a suite with no silent skips or timing races, and the `/ws` frames are pinned before the socket module moves. The pieces are joint: they touch different files, and none consumes another's output. Step 3 joins the workspace halves of flaky-tests and security-tests, because the junction case uses the CI skip helper and one test set covers both. +4. **Behavior fixes** (Steps 7-10): bind, save-timeout. These are the only user-visible changes. They need only the trusted suite, and landing them before the mass renames means the renames include the fixed code. The two pieces are joint (different files). Inside save-timeout the steps are sequential: the UI rendering reads the server's new body, and the editor reacts to the UI's `deadline_elapsed` error. +5. **Dead code** (Step 11). Before the vocabulary work, so nothing about to be deleted gets renamed. One step covers the gateway, status, and server deletions, because one compile-and-test check covers all three. +6. **Shell vocabulary** (Steps 12-18): shell-rename, config-ui. It settles the names and the desktop path before the restructure moves files. The pieces: + - icon copies come before the directory move, because the gateway build reads the icon from the old directory + - the directory move comes before every rename piece, so the renames edit files at their final paths + - Rust and workflow names and UI names are joint: they touch different files + - UI names come before config-ui, because config-ui's `components/status-bar.ts` uses "shell" for both the status bar and the frame + - inside config-ui, the file move comes before the identifier renames (History shape) + - docs come last, so they describe settled names +7. **Structural consolidation** (Steps 19-32): split, prompts-route, helpers, layout, renames, and workshop UI structure. It needs the trusted suite and the settled names. The pieces are sequential: + - split comes first. The Constraints require splitting `app.rs` and `socket.rs` before the edits that grow them (the prompts route, the alias removal, and the named handles), and none of the split's new files moves in a later step. Its steps are joint, except that the compose extraction follows the app directory move. + - prompts-route comes before layout, so `handlers-prompts.rs` moves once. + - helpers come before layout, so the layout moves include the final content. + - layout comes before renames, so the renames edit files at their final paths and names. + - Inside renames, the alias removal comes before the named handles (both edit `app/compose.rs`), and the socket move comes before the step that rewords the socket. + - workshop UI structure touches only UI files and depends on no Rust piece. It comes last only to keep the Rust steps contiguous. +8. **Code-level docs** (Step 33): registry-docs and the rest of docs. Last, because text that describes composition goes stale fastest (Decision Record). +9. **Exit** (Step 34). The full verification, after every change. + + + +### Step 1: Record the baseline [completed] + +- Component: Baseline +- Piece: baseline +- Confirm three things: the repository is `C:\Users\Vinnie\cursor\promptforge` on branch `master`; 1fd82c62 is HEAD or an ancestor of it (the plan seed commit may sit on top); and `git status` is clean. +- Seed the plan: copy this plan file (frontmatter included) to `vibe/2026-09-24-2-workshop-crates-cleanup.md` (the execution date's next free dated-record name), and write that path into `vibe/ACTIVE`. Both join this step's commit. +- Run `cargo workshop` first. It builds the gateway, stages its own sidecar, builds the desktop app, and removes the staged copy. +- Stage the sidecar for the plan's `workshop` package runs, the way `.github/workflows/ci.yml:171-175` does: `cargo build --locked -p gateway --no-default-features`, then `node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe`. Leave it staged until Step 34. +- Run every canonical gate from the Testing Plan exit criteria, in order, plus the survey's two workshop-partition extras: `cargo nextest run --locked -p workshop-server --features headless` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. +- Run `cargo run -p build-user-guide` and record whether it changed any tracked file under `guide/`. If it did, list the files, then restore them with `git checkout -- guide` so the tree stays unchanged. +- Rerun each failing command once. A test that fails on only one of the two runs is intermittent. +- Record the results in a `Baseline results` list inside this step of the active plan (the repository copy under `vibe/`). Use one line per command with pass or fail, plus the names of the failing and intermittent tests. +- Baseline results (all pass; no failing or intermittent tests): + - `cargo workshop`: pass (builds the gateway, stages its sidecar, builds the desktop app, removes the staged copy) + - `cargo build --locked -p gateway --no-default-features` then `node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe`: pass (sidecar left staged) + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`: pass (3899 passed, 54 skipped, 1 leaky) + - `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`: pass + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`: pass (234 passed, 4 skipped) + - `cargo nextest run --locked -p workshop-server --features headless`: pass (130 passed, 2 skipped) + - `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`: pass + - `cargo test -p build-xtask`: pass (169 passed, 18 ignored) + - `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings`: pass + - `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`: pass + - `cargo check -p gateway --no-default-features`: pass + - `cargo fmt --all --check`: pass + - `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` with `RUSTDOCFLAGS=-D warnings`: pass + - `cargo doc -p promptforge --no-deps` with `RUSTDOCFLAGS=-D warnings`: pass + - `cargo +nightly-2026-09-05 xtask api --check`: pass (0 violations; the listing matches public-api.txt) + - `mdbook build guide`: pass + - `cargo run -p build-user-guide`: pass (no tracked file under `guide/` changed) + - workshop UI `npm run build`, `npm test` (136 passed), `npm run typecheck`: pass + - gateway config UI `npm run build`, `npm test` (178 passed), `npm run typecheck`: pass + - Note: the workshop UI `npm test` boots the workbench from `dist/`, so it needs `npm run build` first; the recorded order is build then test. +- Tests: none added. The recorded list is the comparison point for every later step. +- Commit: "Seed the workshop crates cleanup plan", holding the `vibe/` plan copy, `vibe/ACTIVE`, and the recorded baseline results. + + + + + +### Step 2: Delete the workshop's human docs + +- Component: Workshop docs removal +- Piece: docs removal +- Remove with `git rm`: `guide/src/workshop/` (the index and chapters 01 through 11), the export `guide/promptforge-workshop-guide.md`, and the five READMEs `crates/workshop/README.md`, `crates/workshop/server/README.md`, `crates/workshop/shell/README.md`, `crates/workshop/user-state/README.md`, and `crates/workshop/workspace/README.md`. +- Keep `crates/workshop/server/AGENTS.md`, `crates/workshop/shell/AGENTS.md`, `crates/workshop/shell/icons/AGENTS.md`, `crates/workshop/ui/AGENTS.md`, `crates/workshop/ui/THIRD_PARTY_NOTICES.md`, every `//!` crate doc, and every Cargo description. +- Remove any Cargo `readme` key, `include_str!`, or link that names a deleted file. A planning-time grep found no `readme` key or `include_str!`. Check the remaining `AGENTS.md` files and root docs for links with `rg -n "README|guide/src/workshop|workshop-guide" crates/workshop AGENTS.md README.md tools/document.md`. +- In `crates/build-user-guide/src/main.rs`, remove `("workshop", "The Workshop")` from `SETS`, and make its doc comment say three sets instead of four. Two unit tests in the same file assert the removed set: `summary_has_parts_in_audience_order` looks for "# The Workshop", and `assembly_is_deterministic` reads `promptforge-workshop-guide.md`. Point them at the gateway part and `promptforge-gateway-guide.md`, and keep the audience-order check over the three remaining parts. These two edits are the least the `SETS` change needs to keep the crate's tests green. +- Regenerate with `cargo run -p build-user-guide`. The crate owns `guide/src/SUMMARY.md` and each part's `index.md`, so never hand-edit them. The new `SUMMARY.md` loses only the Workshop part (lines 4-17 today). If the run changes any gateway, language, or agent file, stop and report instead of committing it. +- In `crates/workshop/ui/test/docs-claims.mjs`, delete the test "the tracked guide export matches the sources on the stale claims" (the workshop export check, around lines 62-73), and reword the comment at line 48 that says one list guards both the sources and the export. The root `AGENTS.md` and `guide/src` checks stay. +- In `tools/document.md`, remove the workshop lens (the part that writes `guide/src/workshop/` and targets the workshop crates at line 105) and any list entry that names the workshop set, so the tool can't regenerate the deleted guide. +- In `guide/src/introduction.md`, remove the link to the deleted `workshop/index.md` at line 27, and the wording that introduces the Workshop part. Change nothing else on the page. This edit is approved in the Decision Record. +- Tests: `cargo nextest run --locked -p build-user-guide --all-features`, `node --test crates/workshop/ui/test/docs-claims.mjs`, and `mdbook build guide` pass. `git status` shows no change to the gateway, language, or agent exports or index files. Over the edit scope, excluding `vibe/`, `rg "promptforge-workshop-guide|guide/src/workshop"` finds nothing. +- Component end: `cargo clippy -p build-user-guide --all-targets --all-features -- -D warnings` (the crate is a binary, so it has no doc tests), `cargo fmt --all --check`, and `npm test`, `npm run typecheck`, and `npm run build` in `crates/workshop/ui`. +- Commit: "Delete the workshop's human docs and drop them from the guide build" + + + + + +### Step 3: Fail the symlink tests under CI and cover the jail's edge cases + +- Component: Trustworthy tests +- Piece: flaky-tests and security-tests, the workspace half of each +- Check recent CI logs for the skip message "skipping: symlink creation failed" (`gh run list`, then `gh run view --log`). If a CI runner already hits the skip, this step would turn that job red: stop and report. +- Add `crates/workshop/workspace/src/workspace-tests-jail.rs`, declared in `workspace-tests.rs` beside `backing`, `grants`, `pointer`, and `ui_state` as `#[path = "workspace-tests-jail.rs"] mod jail;`. `workspace-tests.rs` is at 475 lines, so the new code goes in the new file. +- In it, add `symlink_unavailable(ci: bool, reason: &str)`. It panics when `ci` is true, and otherwise prints the reason so the caller can return. The two skip sites in `workspace-tests.rs` (lines 143 and 167, `eprintln!("skipping: symlink creation failed")`) call it with `std::env::var_os("CI").is_some()`. Passing the flag keeps the tests free of `std::env::set_var`, which is `unsafe` in Rust 2024 and forbidden here. +- Also in it, pin today's behavior of the confinement code (`crates/workshop/workspace/src/workspace-confine.rs`) for: + - UNC (`\\server\share\...`) and verbatim (`\\?\C:\...`) spellings of paths inside and outside a granted root + - case-only respellings of a granted root + - a Windows directory junction inside a granted root that points outside it, created with `cmd /C mklink /J` (no new dependency) +- Gate the Windows-only cases with `#[cfg(windows)]`. A junction that can't be created goes through `symlink_unavailable`. +- If a case shows a path escaping the jail, stop and report it. The plan keeps jail behavior unchanged, so an escape is a security finding for the user, not something to pin. +- Tests: a `#[should_panic]` test for the helper with the flag set, a test that it returns normally without the flag, and the jail cases. `cargo nextest run --locked -p workshop-workspace --all-features workspace::tests` passes, and `rg eprintln crates/workshop/workspace/src -g "*-tests*.rs"` finds only the helper. +- Commit: "Fail the symlink tests under CI and cover the jail's edge cases" + + + + + +### Step 4: Replace fixed sleeps with event-driven waits + +- Component: Trustworthy tests +- Piece: flaky-tests +- Replace each fixed wait with a wait on the event it stands in for, or with `tokio::time::pause` or `#[tokio::test(start_paused = true)]` where the code under test uses tokio timers (`crates/workshop/support/src/deadline.rs` shows the pattern): + - `crates/workshop/server/tests/it/realtime_relay/overload.rs:19` (750 ms) + - `crates/workshop/server/tests/it/chat_gate/lifecycle.rs:79` (a 150 ms quiet window) + - `crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs:148` (`TEST_INTERVAL * 4`) + - `crates/workshop/shell/src/gateway/tests/recovery.rs:274` (a 5-second hang fixture). The supervisor runs on threads with std time, so use a gate the test releases instead of paused time. +- A quiet-window assertion ("nothing arrives") keeps a bound, but the bound becomes a paused-time advance or an explicit end-of-stream signal, not a wall-clock sleep. +- Tests: the four tests assert what they asserted before and pass five runs in a row: `cargo nextest run --locked -p workshop-server realtime_relay::overload chat_gate::lifecycle heartbeat_loop::startup_convergence` and `cargo nextest run --locked -p workshop gateway::tests::recovery`. +- Commit: "Replace fixed sleeps in workshop tests with event-driven waits" + + + + + +### Step 5: Pin the realtime relay refusals + +- Component: Trustworthy tests +- Piece: security-tests +- Add `crates/workshop/server/src/routes/realtime-tests.rs`, wired at the bottom of `crates/workshop/server/src/routes/realtime.rs` with `#[cfg(test)]` and `#[path = "realtime-tests.rs"] mod tests;`. +- Pin today's behavior with no production change: an upgrade whose `Origin` is outside the allowed loopback origins is refused, and an upgrade without the required subprotocol is refused. Assert the status and body that each refusal answers today. +- Tests: `cargo nextest run --locked -p workshop-server routes::realtime` passes. +- Commit: "Add unit tests for the realtime relay's refusals" + + + + + +### Step 6: Pin the /ws frames in a shared fixture + +- Component: Trustworthy tests +- Piece: wire-fixture +- Add `SelectModelFrame`, the inbound `{"type":"select_model","model":"..."}` frame, to `crates/workshop/protocol/src/menu.rs` beside `SwitchProfileFrame`, with the same derives, and re-export it from `crates/workshop/protocol/src/lib.rs` next to `SwitchProfileFrame`. +- In `crates/workshop/server/src/agents/session-menu.rs`, parse `select_model` through `SelectModelFrame`, the way `switch_profile` parses through `SwitchProfileFrame`. Keep the refusal text ("select_model needs a \"model\" string") so `crates/workshop/server/tests/it/session/menu.rs` passes unchanged. +- Add a matching `SelectModelFrame` interface to `crates/workshop/ui/src/services/protocol.ts`, and type the frame sent at `crates/workshop/ui/src/services/workshop-socket.ts:184` with it. +- Add `crates/workshop/protocol/tests/fixtures/workshop-frames.json`, shaped like `agent-frames.json`. It covers the status, models, workbench, error, and switch_profile frames, plus select_model so the new type is pinned too. +- Assert the fixture on both sides: a new `workshop_frames` module in `crates/workshop/protocol/tests/it/` (outbound frames serialize equal to the fixture, inbound frames deserialize from it), and a new `crates/workshop/ui/test/workshop-wire-fixtures.mjs` modeled on `agent-wire-fixtures.mjs`. +- Tests: `cargo nextest run --locked -p workshop-protocol --all-features workshop_frames`, `cargo nextest run --locked -p workshop-server session::menu`, `node --test crates/workshop/ui/test/workshop-wire-fixtures.mjs`, and `npm run typecheck --prefix crates/workshop/ui` pass. +- Component end, for the packages Steps 3-6 touched: + - `cargo nextest run --locked -p workshop-workspace -p workshop-protocol --all-features`, then `cargo test --locked -p workshop-workspace -p workshop-protocol --all-features --doc` + - `cargo nextest run --locked -p workshop -p workshop-server`, `cargo nextest run --locked -p workshop-server --features headless`, and `cargo test --doc -p workshop -p workshop-server` + - `cargo clippy -p workshop-workspace -p workshop-protocol --all-targets --all-features -- -D warnings` and `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings` + - `cargo fmt --all --check` + - `npm test`, `npm run typecheck`, and `npm run build` in `crates/workshop/ui` +- Commit: "Pin the /ws frames in a fixture shared by Rust and TypeScript" + + + + + +### Step 7: Refuse non-loopback binds + +- Component: Behavior fixes +- Piece: bind +- In `crates/workshop/server/src/serve.rs`, `reuse_bind` returns `std::io::Error::new(std::io::ErrorKind::InvalidInput, ...)` when the parsed `SocketAddr` has `!addr.ip().is_loopback()`, before it creates any socket. The message names the refused address and the loopback requirement. +- Tests: in `crates/workshop/server/src/serve-tests.rs`, `0.0.0.0:0`, `[::]:0`, and a LAN address such as `192.168.1.10:0` are refused with `InvalidInput`; `127.0.0.1:0` binds; `[::1]:0` is not refused with `InvalidInput` (a runner without IPv6 may fail that bind for another reason). `cargo nextest run --locked -p workshop-server serve::tests` passes. +- Commit: "Refuse non-loopback addresses in reuse_bind" + + + + + +### Step 8: Answer the route deadline with a JSON 408 + +- Component: Behavior fixes +- Piece: save-timeout +- Write the reproducing test first, in a new `save_timeout` module under `crates/workshop/server/tests/it/`: a `PUT /workspace/file` whose write outlasts the route deadline gets a 408 whose body parses as the JSON envelope, and after the stall is released the write still lands on disk. Run it against the unfixed code and confirm it fails before changing `deadline.rs`. + - Stall the write deterministically through a seam behind the workspace crate's `test-fixtures` feature, released by the test. Add the feature if it's missing, and enable it in the server's dev-dependency on `workshop-workspace`. + - Keep the test off the 10-second wall clock with a test-only deadline or paused time. Add no production knob. +- In `crates/workshop/support/src/deadline.rs`, answer an elapsed deadline with status 408, `content-type: application/json`, and the body `{"error":{"message":"...","code":"deadline_elapsed"}}`, built with `serde_json` (add it to support's dependencies if it's missing) because support can't depend on protocol. Export the code as a constant and the message builder (which takes the `Duration`) from `workshop-support`. The Decision Record fixes the code and the message. +- Add the shape test to the `save_timeout` module: parse the body as a `serde_json::Value` and compare it with `serde_json::to_value(workshop_protocol::ErrorEnvelope::new(message, code))`, using the support exports. +- Extend `a_stalled_route_answers_408_at_its_deadline` in `deadline.rs` to check the content type and the parsed body. +- Tests: `cargo nextest run --locked -p workshop-support --all-features deadline` and `cargo nextest run --locked -p workshop-server save_timeout` pass. If the workspace crate gained a seam, `cargo nextest run --locked -p workshop-workspace --all-features handlers` passes too. If a manifest changed, `cargo test -p build-xtask` passes. +- Commit: "Answer the route deadline with a JSON error envelope" + + + + + +### Step 9: Render route timeouts readably in the UIs + +- Component: Behavior fixes +- Piece: save-timeout +- In `crates/workshop/ui/src/services/json-request.ts` (lines 17-56), a 408 with the JSON envelope yields the envelope's message and the `deadline_elapsed` code, and a 408 with an empty or non-JSON body yields a readable timeout error. Neither path reports that the server "returned a non-JSON answer". +- In `crates/workshop/ui/src/services/error-catalog.ts` (lines 17-46), add `deadline_elapsed` if the catalog maps codes to messages. +- Audit the other status and code readers under `crates/workshop/ui/src/services/`: `workspace-api.ts:91-96,159-189`, `workspace-file-client.ts:104-114`, and `run-api.ts:263-267`. Fix any that would mishandle the new body. +- config-ui: `refusalDetail` in `crates/gateway/config-ui/ui/src/services/gateway-api.ts` (lines 349-363 and 1069-1101) already reads the envelope's `message` and `code`, and `panel-bridge.ts:206-220` passes relay answers through. Confirm that a 408 envelope from the gateway-config relay shows its message. These two files are a named exception only for this case: edit them only if the audit finds that they render the 408 badly, and then add a config-ui test for the fix. +- Tests: a new `crates/workshop/ui/test/json-request-timeout.mjs` covers the JSON 408 and the empty 408. It and `npm run typecheck --prefix crates/workshop/ui` pass. If config-ui changed, its new test and `npm run typecheck --prefix crates/gateway/config-ui/ui` pass. +- Commit: "Render route timeouts as readable errors in the UIs" + + + + + +### Step 10: Track an unknown save token in the editor + +- Component: Behavior fixes +- Piece: save-timeout +- In `crates/workshop/ui/src/parts/editor/editor-panel.ts`, add an "unknown" token state beside the known token that lines 189-190 set from `written.token`: + - A 408 on save (the `deadline_elapsed` error from Step 9) sets the token to unknown and tells the user the save may or may not have landed. + - While the token is unknown, the next save first re-reads the file. If the disk content matches what was last sent, it adopts the returned token and saves with it. Otherwise it shows the existing conflict dialog. + - The editor never sends a stale token: `crates/workshop/ui/src/services/workspace-api.ts:189` only ever receives a token the editor currently knows. +- Tests: a new `crates/workshop/ui/test/editor-save-timeout.mjs` covers four cases. A 408 leaves the token unknown and sends no stale token. A disk match adopts the new token and saves. A mismatch shows the conflict dialog. A late write that lands after the re-read ends in the conflict dialog, not a raw error. It and `npm run typecheck --prefix crates/workshop/ui` pass. +- Component end, for the packages Steps 7-10 touched: + - `cargo nextest run --locked -p workshop-support --all-features` and `cargo test --locked -p workshop-support --all-features --doc`, plus the same pair for `workshop-workspace` if Step 8 gave it a seam + - `cargo nextest run --locked -p workshop-server`, `cargo nextest run --locked -p workshop-server --features headless`, and `cargo test --doc -p workshop-server` + - `cargo clippy -p workshop-support --all-targets --all-features -- -D warnings` (with `-p workshop-workspace` if Step 8 touched it) and `cargo clippy -p workshop-server --all-targets -- -D warnings` + - `cargo fmt --all --check` + - `npm test`, `npm run typecheck`, and `npm run build` in `crates/workshop/ui`, and in `crates/gateway/config-ui/ui` if Step 9 changed it +- Commit: "Recover from a timed-out save through an unknown token state" + + + + + +### Step 11: Delete the dead gateway, status, and server code + +- Component: Dead code +- Piece: dead code +- Gateway: remove `crates/workshop/gateway/src/observer.rs` and `observer-tests.rs` with `git rm`, and remove `pub mod observer` and the `WorkshopObserver` re-export from `crates/workshop/gateway/src/lib.rs`. Confirm with `rg -w promptforge crates/workshop/gateway/src` that only `observer.rs` and `observer-tests.rs` name the engine crate. Then remove `promptforge` from `crates/workshop/gateway/Cargo.toml` (line 21), drop "the run event log" from its `description` (line 9), and drop the engine crate from the gateway's `## Invariants` block if it names it. The public cache API (`cache_ensure`, `CacheEvent`, `CacheResponse`, `SsePayloadStream`) stays for a planned caller. +- Server: in `crates/workshop/server/src/lib.rs`, remove `observer` from the alias list, and remove `CacheEvent`, `CacheResponse`, and `SsePayloadStream` from the `gateway` re-export (lines 93-96). Confirm that `crates/workshop/server-api/src/lib.rs` re-exports none of them. +- Status: confirm with `rg` that only their own tests call `StatusBus::report`, `info`, `debug`, `error`, and `idle` (`crates/workshop/status/src/status.rs:67-117`); producers use `Push`. Delete the five methods and those tests. +- Stale comment: in the module doc at `crates/workshop/server/src/agents/status.rs:1-4`, drop the history about the deleted sessions crate. Leave the "shell" and "relay" wording for Steps 14 and 30. +- Tests: `cargo nextest run --locked -p workshop-gateway -p workshop-status --all-features --no-run`, `cargo nextest run --locked -p workshop-status --all-features status`, `cargo nextest run --locked -p workshop-server -p workshop-server-api --no-run`, and `cargo test -p build-xtask` pass. `rg WorkshopObserver crates/workshop` finds nothing. +- Component end: + - `cargo nextest run --locked -p workshop-gateway -p workshop-status --all-features`, then `cargo test --locked -p workshop-gateway -p workshop-status --all-features --doc` + - `cargo nextest run --locked -p workshop-server -p workshop-server-api`, `cargo nextest run --locked -p workshop-server --features headless`, and `cargo test --doc -p workshop-server -p workshop-server-api` + - `cargo clippy -p workshop-gateway -p workshop-status --all-targets --all-features -- -D warnings` and `cargo clippy -p workshop-server -p workshop-server-api --all-targets -- -D warnings` + - `cargo fmt --all --check` +- Commit: "Delete WorkshopObserver, unused StatusBus helpers, and stale server re-exports" + + + + + +### Step 12: Give the gateway app its own icon copies + +- Component: Shell vocabulary +- Piece: icon copies +- Copy `icon.ico`, `32x32.png`, and `64x64.png` from `crates/workshop/shell/icons/` into `crates/gateway/app/assets/`, next to `tray-icon.rgba` and `tray-icon-template.rgba`. The copies must be byte-identical (compare with `Get-FileHash`). +- Point the build, the test, and the comments at the copies, all under `crates/gateway/app`: `build.rs:7,23`, `tests/it/icon.rs:2,42`, `Cargo.toml:19`, `src/tray/windows.rs:49-51`, `src/tray/macos.rs:63-66`, and `src/tray/linux.rs:54`. +- Extend the sync rule in `crates/workshop/shell/icons/AGENTS.md` to cover the gateway app's copies beside config-ui's. +- Tests: `cargo nextest run --locked -p gateway icon` (the icon embedding test) and `cargo check -p gateway --no-default-features` pass. `rg "workshop/shell/icons" crates/gateway/app` finds nothing. +- Commit: "Give the gateway app its own copies of the icons" + + + + + +### Step 13: Move the desktop app to crates/workshop/desktop + +- Component: Shell vocabulary +- Piece: directory move +- Approved scope widening (recorded in the Decision Record). Three files outside the Constraints' edit scope hard-code the old sidecar path: `tools/stage-gateway-sidecar.mjs:37` and `tools/stage-gateway-sidecar.test.mjs:116` build `crates/workshop/shell/binaries`, and `crates/build-workshop/tests/interruption.rs:51-53` checks it. `cargo workshop` (`crates/build-workshop/src/main.rs`) and CI (`.github/workflows/ci.yml:175,256` and `workshop-installer-smoke.yml:38`) stage through that script, so without these edits the desktop app can't find its sidecar after the move. The user approved editing the three files. +- `git mv crates/workshop/shell crates/workshop/desktop`. +- In the same commit, update the path strings that wire up the move: + - root `Cargo.toml` (the `members` entry) and `.gitignore:22,25` + - `.github/workflows/nightly.yml:204-210`, `.github/workflows/release-workshop.yml:167-191`, and `.github/workflows/workshop-installer-smoke.yml:8-9,43` + - `crates/build-xtask/src/tidy.rs:84-86`, where the fallback directory `"shell"` becomes `"desktop"`, and `crates/build-xtask/src/tidy-tests.rs:223` + - the path in `README.md:84` and in `AGENTS.md:27` (only the path; Step 18 rewrites the prose) + - the comments at `crates/gateway/app/src/tray/windows.rs:596` and `crates/gateway/app/src/tray/macos.rs:459` + - the `"shell"` path segment in the three approved files +- Catch the rest with `rg 'workshop[/\\]shell'` over the edit scope and the three approved files, excluding `vibe/`. +- `git mv` leaves the gitignored build outputs behind: the staged sidecar in `crates/workshop/shell/binaries/` and the Tauri output in `crates/workshop/shell/gen/`. Move both under `crates/workshop/desktop/`, so no `crates/workshop/shell/` directory remains and the sidecar from Step 1 keeps the `workshop` package building. +- Tests: `git diff --cached -M --name-status` lists every moved file as an `R` entry. `cargo test -p build-xtask`, `node --test tools/stage-gateway-sidecar.test.mjs`, `cargo nextest run --locked -p build-workshop --all-features --test interruption`, and `cargo nextest run --locked -p workshop gateway::` (which builds the desktop app from its new path) pass. `Test-Path crates/workshop/shell` is false, and the `rg` above finds nothing. +- Commit: "Move the desktop app to crates/workshop/desktop" + + + + + +### Step 14: Retire "shell" in the Rust crates, the build check, and the workflows + +- Component: Shell vocabulary +- Piece: Rust and workflow names +- build-xtask: + - `crates/build-xtask/src/tidy.rs:31`: `SHELL` becomes `SERVER`, with the tier name "server". Update the shell prose and the fallback literal in the same file at lines 13, 27, 30, 84, and 86, plus the "Tauri shell" wording at line 252 ("Tier 3: the shell" is line 30). + - `crates/build-xtask/src/product.rs:121`: `SHELL` becomes `DESKTOP`, and its value stays `"workshop"`. The shell-boundary prose and comments in the same file name the desktop app. + - `crates/build-xtask/src/new_crate.rs:72`: the tier list in the new-crate template becomes `vocabulary | services | features | server`. + - Rename the tests that mention "shell" in `crates/build-xtask/src/tidy-tests.rs` and `product-tests.rs`. +- `crates/workshop/server/src/lib.rs`: "Tier: shell" (line 24) becomes "Tier: server" in this same commit, so the tier name and the crate's Invariants agree, and "thin shell" (line 4) becomes "thin entry point". +- Every other non-Markdown file under `crates/workshop/**` outside `crates/workshop/ui` (Rust sources and tests, Cargo manifests with their comments and descriptions, build scripts, Tauri and installer config): classify each "shell" by meaning. + - the Tauri app becomes "desktop app" + - the server or its tier becomes "server" + - any other non-terminal sense gets its own word + - third-party names (for example Tauri's shell plugin or an NSIS keyword) keep their names + - This covers rustdoc such as "The shell maps its per-crate error types" in `crates/workshop/protocol/src/error.rs`, the "shell" wording in `crates/workshop/server/src/agents/status.rs`, and test names. Rust variables, parameters, and fields named `shell` are renamed to match. +- In `.github/workflows/*.yml`, comments that use "shell" for the desktop app or the server (for example `ci.yml:177` and the "beside the shell" comments in `release-workshop.yml`) get the same words. `shell:` step keys are terminal shells and stay. +- Touch nothing under `crates/gateway` or `crates/workshop/ui`, and no Markdown file; Steps 15-18 cover those. +- Tests: `cargo test -p build-xtask` passes. Every other touched package compiles with `cargo nextest run --locked -p --no-run` (with `--all-features` in the main partition), and its renamed tests pass by name. If a rustdoc intra-doc link names a renamed item, `cargo doc --no-deps -p ` passes with `RUSTDOCFLAGS="-D warnings"`. `rg -w SHELL crates/build-xtask/src` and `rg "Tier: shell" crates` find nothing, and `rg -i -w shell` over `crates/build-xtask/src`, `.github/workflows`, and `crates/workshop` (leaving out `crates/workshop/ui` and Markdown files) shows only third-party or terminal senses. +- Commit: "Retire shell for the desktop app and server in Rust and the workflows" + + + + + +### Step 15: Retire "shell" in the shared status bar and the workshop UI + +- Component: Shell vocabulary +- Piece: UI names +- shared-ui: in `crates/shared-ui/status-bar.ts`, `createStatusBarShell` becomes `createStatusBarView` and `StatusBarShell` becomes `StatusBarView`. Update the comment at `crates/shared-ui/status-bar.css:9` and the `crates/shared-ui/package.json` description. +- Status bar consumers: `crates/workshop/ui/src/parts/status/status-bar.ts`, `crates/workshop/ui/test/shared-status-bar.mjs`, and `crates/gateway/config-ui/ui/src/components/status-bar.ts`, plus the comments at `crates/gateway/config-ui/ui/src/components/status-bar.test.mjs:3` and `crates/gateway/config-ui/ui/src/styles/layout.css:1423`. Local `shell` variables that hold the status bar become `view`. In config-ui's `components/status-bar.ts`, "shell" means both the status bar and the frame: rename only the status bar references here, and leave the frame for Step 17. +- Workshop UI desk: `.ws-shell` becomes `.ws-desk` in `crates/workshop/ui/src/parts/layout/zones.css:8`, `crates/workshop/ui/index.html:41`, `crates/workshop/ui/test/workshop-layout.mjs:350`, and every other TypeScript and test reference. +- Workshop UI placeholder: "lazy shell" and "empty shell" become "placeholder" in `crates/workshop/ui/src/parts/layout/zones.css` (for example line 272), `crates/workshop/ui/src/parts/layout/panel-types.ts`, and `crates/workshop/ui/test/lazy-panel-sizing.mjs`. The local `shell` at `lazy-panel-sizing.mjs:222` becomes `placeholder`. Classify each `zones.css` occurrence by meaning: line 8 is the desk, and line 272 is the placeholder. +- Every other "shell" in `crates/workshop/ui` outside Markdown (TypeScript, tests, CSS, HTML, comments, and test descriptions): "boot shell" becomes "entry bundle", the Tauri app becomes "desktop app", and the server becomes "server". Terminal senses, such as the stubbed Terminal menu, stay. +- Tests: `node --test` passes for `crates/workshop/ui/test/shared-status-bar.mjs`, `crates/workshop/ui/test/lazy-panel-sizing.mjs`, `crates/gateway/config-ui/ui/src/components/status-bar.test.mjs`, and every other test file this step edits. `npm run typecheck` passes in both UIs. `rg "StatusBarShell|createStatusBarShell" crates` and `rg "ws-shell|lazy shell|empty shell|boot shell" crates/workshop/ui crates/shared-ui` find nothing. +- Commit: "Retire shell in the shared status bar and the workshop UI" + + + + + +### Step 16: Move config-ui's views to pages + +- Component: Shell vocabulary +- Piece: config-ui +- With paths relative to `crates/gateway/config-ui/ui/src`: `git mv views pages`. The six pairs for cloud-models, discover, models, profiles, secrets, and settings move from `*-view.ts` and `*-view.test.mjs` to `*-page.ts` and `*-page.test.mjs`. `apply-revert.test.mjs`, `model-detail.test.mjs`, and `settings-sections.test.mjs` move without a new name. +- In the same commit, fix only the import paths: the six imports at `main.ts:35-40`, and the relative imports inside the moved files and their tests. Identifiers keep their names until Step 17. +- Run `rg "views/" crates/gateway/config-ui` for references outside `ui/src`. If a build script, Rust asset list, or any other file outside the named exception names the old path, stop and report. +- Tests: `git diff --cached -M --name-status` lists every moved file as an `R` entry, and `views/` no longer exists. `npm run typecheck --prefix crates/gateway/config-ui/ui` and `node --test` over the moved test files pass. +- Commit: "Move config-ui's views to pages" + + + + + +### Step 17: Rename config-ui's page identifiers and its frame to desk + +- Component: Shell vocabulary +- Piece: config-ui +- With paths relative to `crates/gateway/config-ui/ui/src`, rename the page identifiers (about 300 references): `createXView` becomes `createXPage`, `XViewDeps` becomes `XPageDeps`, `ViewId` becomes `PageId`, `viewRoot` becomes `pageRoot`, `setActiveView` becomes `setActivePage`, `tabByView` becomes `tabByPage`, `defaultView` becomes `defaultPage`, `PendingView` becomes `PendingPage`, `disposeView` becomes `disposePage`, and the `.view-empty` class becomes `.page-empty`. Leave `review`, `viewport`, `openReviewDiff`, and `createStatusBarView` alone. +- Rename the frame to the desk (about 77 references across 22 files, test descriptions included): + - `mountLiveShell` (`main.ts:204`) becomes `mountLiveDesk`, and `showShell` (`main.ts:103`) becomes `showDesk`. + - The inert panel-mode mount documented at `main.ts:475` is described as the inert desk. + - `main.className = "shell"` (`main.ts:513`) and the `.shell` rules at `styles/layout.css:71,1438` become `desk`. + - Local variables named `shell` that hold the frame become `desk`, including the remaining frame references in `components/status-bar.ts`. +- Tests: `npm run typecheck --prefix crates/gateway/config-ui/ui` and `node --test` over every test file this step edits pass. In `crates/gateway/config-ui/ui/src`, `rg -w "ViewId|viewRoot|setActiveView|tabByView|defaultView|PendingView|disposeView|view-empty"` finds nothing, `rg "create\w+View\b|\w+ViewDeps"` finds only `createStatusBarView`, and `rg -w shell` shows only terminal senses. `rg "mountLiveShell|showShell" crates/gateway/config-ui` finds nothing. +- Commit: "Rename config-ui's views to pages and its shell to desk" + + + + + +### Step 18: Retire "shell" in the docs and record the vocabulary + +- Component: Shell vocabulary +- Piece: docs +- Root `AGENTS.md`: at line 27 "the shell" becomes "the desktop app"; at line 32 "the Workshop shell" becomes "the desktop app"; at lines 61-63 the tier chain becomes "server -> features -> services -> vocabulary", "boot shell" becomes "entry bundle", and "the Tauri shell" becomes "the desktop app". Fix any other non-terminal "shell" in root `AGENTS.md` and `README.md`. +- Crate rules: classify "shell" the same way in `crates/workshop/server/AGENTS.md`, `crates/workshop/desktop/AGENTS.md`, `crates/workshop/desktop/icons/AGENTS.md`, and `crates/workshop/ui/AGENTS.md`. +- Cursor rules: apply the same vocabulary, and nothing else, in `.cursor/rules/workshop-architecture.mdc` and `.cursor/rules/workshop-spa.mdc`: desktop app, server tier, entry bundle, and desk. This edit is approved in the Decision Record. +- Wrong claim: "the shell constructs the Harness" (`crates/workshop/server/AGENTS.md:13`). The server builds it: `compose` in `crates/workshop/server/src/app.rs` calls `harness_for` in `crates/workshop/server/src/agents.rs:55`. +- Add a Vocabulary section to root `AGENTS.md` with the words from Technical Design, "Architecture": shell, desktop app, server, desk, workbench, workshop socket, page, view, placeholder, and entry bundle. State each word's current meaning without quoting a retired phrase, since the retired-name grep and `docs-claims.mjs` both scan this file. Leave out the `workshop_socket` module name: Step 29 creates the module and Step 30 adds the name. +- Tests: `node --test crates/workshop/ui/test/docs-claims.mjs` passes. Over the edit scope, excluding `vibe/`, `rg "workshop/shell|Tier: shell|StatusBarShell|createStatusBarShell|ws-shell|mountLiveShell|showShell|WorkshopObserver|lazy shell|empty shell|boot shell"` finds nothing. "workbench socket" waits for Step 30. +- Component end, for the packages Steps 12-18 touched: + - `cargo test -p build-xtask` and `cargo nextest run --locked -p gateway`, which covers the icon test + - for each other main-partition package touched (at least `build-workshop` and the workshop crates Step 14 edited): `cargo nextest run --locked -p --all-features`, then `cargo test --locked -p --all-features --doc` where the package has a library + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, `cargo nextest run --locked -p workshop-server --features headless`, and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api` + - `cargo clippy --all-targets -- -D warnings` with one `-p` per touched package: the main-partition packages with `--all-features`, and `workshop`, `workshop-server`, and `workshop-server-api` in a separate invocation without it + - `cargo fmt --all --check` and `node --test tools/stage-gateway-sidecar.test.mjs` + - `npm test`, `npm run typecheck`, and `npm run build` in `crates/workshop/ui` and in `crates/gateway/config-ui/ui` +- Commit: "Retire shell in the workshop docs and record the vocabulary" + + + + + +### Step 19: Move app.rs's children into an app directory + +- Component: Structural consolidation +- Piece: split +- `git mv crates/workshop/server/src/app-fixtures.rs crates/workshop/server/src/app/fixtures.rs` and `git mv crates/workshop/server/src/app-tests.rs crates/workshop/server/src/app/tests.rs`. In the same commit, drop their `#[path]` attributes in `app.rs` (lines 13 and 16) so standard layout finds them. +- Step 20 adds `app/compose.rs` as the third child. The layout convention turns a group of three into a directory, and the History shape keeps this move apart from the compose extraction. +- Tests: `git diff --cached -M --name-status` lists both files as `R` entries. `cargo nextest run --locked -p workshop-server app::` and `cargo test -p build-xtask` pass. +- Commit: "Move app.rs's children into an app directory" + + + + + +### Step 20: Break compose into per-subsystem register helpers + +- Component: Structural consolidation +- Piece: split +- Move `compose` (about 115 lines, starting near `crates/workshop/server/src/app.rs:316`) into a new `crates/workshop/server/src/app/compose.rs`, declared from `app.rs`, and break it into one register helper per subsystem: gateway, menu, status, user-state, workspace, and the harness. The helpers still unpack today's tuples; Step 31 switches them to named fields. +- The composition order, and so the registration order, stays the same. +- Tests: `cargo nextest run --locked -p workshop-server app::` passes with the app tests unchanged, and `cargo test -p build-xtask` passes. `app.rs` ends well under 500 lines. +- Commit: "Break compose into per-subsystem register helpers" + + + + + +### Step 21: Split out the agent socket's framing helpers + +- Component: Structural consolidation +- Piece: split +- Move `input_frame`, `delta_frame`, `frame_entry`, and `drain_events` from `crates/workshop/server/src/agents/socket.rs` (492 lines) into a new sibling module `crates/workshop/server/src/agents/socket_frames.rs`, declared in `crates/workshop/server/src/agents.rs`, with table-driven tests in `agents/socket_frames-tests.rs` wired by `#[path]`. As a sibling module it needs no move of `socket-tests.rs`, and no later step moves it. +- Tests: `cargo nextest run --locked -p workshop-server agents::socket` (which matches both `socket` and `socket_frames`) passes with the existing socket tests unchanged, and `cargo test -p build-xtask` passes. +- Commit: "Split the agent socket's framing helpers into their own module" + + + + + +### Step 22: Split the desktop supervisor + +- Component: Structural consolidation +- Piece: split +- Split `crates/workshop/desktop/src/gateway/supervisor.rs` (751 lines) into new children under `crates/workshop/desktop/src/gateway/supervisor/`, one per seam: recovery-candidate ownership, stop and completion signals, thread lifecycle with the 3-second shutdown budget, and cancellable launch and wait. +- `supervisor.rs` keeps `run_supervision` and the injection APIs, so the tests in `crates/workshop/desktop/src/gateway/tests/` neither move nor change. The desktop crate is exempt from the file-size ceiling; this split is for readability. +- Tests: `cargo nextest run --locked -p workshop gateway::` passes with the gateway tests unchanged. +- Commit: "Split the desktop supervisor along its seams" + + + + + +### Step 23: Name the phases of the heartbeat and progress loops + +- Component: Structural consolidation +- Piece: split +- Extract named phase helpers from `heartbeat::run` (about 122 lines, `crates/workshop/gateway/src/heartbeat.rs:213`) and `gateway_progress::run` (about 102 lines, `crates/workshop/gateway/src/gateway_progress.rs:134`). Keep the helpers in the same files: `heartbeat.rs` already has two hyphenated siblings, and a third would trigger the directory rule. Both files stay under 500 lines (354 and 268 today). +- The `select!` semantics stay the same: the same branches, branch order, `biased` setting, and cancellation points. +- Tests: `cargo nextest run --locked -p workshop-gateway --all-features heartbeat gateway_progress` passes with the heartbeat and progress tests unchanged. +- Commit: "Name the phases of the heartbeat and progress run loops" + + + + + +### Step 24: Serve /prompts/contract from the server + +- Component: Structural consolidation +- Piece: prompts-route +- `git mv crates/workshop/workspace/src/handlers-prompts.rs crates/workshop/server/src/routes/prompts.rs` and `git mv crates/workshop/workspace/src/handlers-prompts-tests.rs crates/workshop/server/src/routes/prompts-tests.rs`. The test file keeps its `#[path = "prompts-tests.rs"]` wiring, like `routes/gateway_config-tests.rs`. +- In the same commit, the wiring: + - Remove the `prompts` module (lines 24-25) and its mount from `crates/workshop/workspace/src/handlers.rs`. + - Declare `prompts` in `crates/workshop/server/src/routes.rs` beside `assets`, `gateway_config`, `health`, and `realtime`, and mount it beside health, realtime, and gateway_config under the default deadline. + - Map its errors to `AppError` in `crates/workshop/server/src/error.rs`, with the same status codes and wire error codes the workspace error type used, and remove the variants only this route used from `crates/workshop/workspace/src/error.rs`. + - Drop `promptforge` from `crates/workshop/workspace/Cargo.toml` (line 23); the server already depends on it, so nothing is added there. Update the `## Invariants` blocks in both crates' `src/lib.rs`. +- Inside the moved files, change only what the server needs to build them: import paths, the error type, and the test setup that reaches the server's router. The test assertions stay the same. Put the error mapping in `error.rs` rather than in the moved file, so git still detects both files as renames. +- Tests: `git diff --cached -M --name-status` lists both files as `R` entries. `cargo nextest run --locked -p workshop-server routes::prompts`, `cargo nextest run --locked -p workshop-workspace --all-features handlers`, and `cargo test -p build-xtask` pass. `rg -w promptforge crates/workshop/workspace/src` finds nothing. +- Commit: "Serve the prompts contract route from the server" + + + + + +### Step 25: Share the error rendering and the state-bucket validator + +- Component: Structural consolidation +- Piece: helpers +- Error rendering: move `render_message` and `LEAK_DETAIL` into a new `crates/workshop/support/src/error_message.rs`, exported from `crates/workshop/support/src/lib.rs`, with unit tests in `error_message-tests.rs`. Delete the copies in `crates/workshop/workspace/src/error.rs`, `crates/workshop/server/src/error.rs`, and `crates/workshop/user-state/src/error.rs`, and point `crates/workshop/server/src/agents/relay.rs` at the shared `LEAK_DETAIL`. Rendered messages stay byte-for-byte the same. +- State-bucket validator: add it in a new `crates/workshop/support/src/state_bucket.rs`, with tests in `state_bucket-tests.rs`. It checks the key against an allow list the caller passes, caps the body at 1 MiB, and requires the body to parse as JSON, returning a support-level error with one variant per refusal. Switch both copies to it: `crates/workshop/user-state/src/store.rs` and `handlers.rs`, and `crates/workshop/workspace/src/workspace_file-ui-state.rs` and `handlers-file-state.rs`. Each crate maps the support error onto its existing variants, so the wire codes (`user_state_key`, `user_state_too_large`, `user_state_not_json`, `ui_state_key`, `ui_state_too_large`, `ui_state_not_json`) and the messages don't change. +- Tests: the new support tests cover each refusal, an accepted body, and the rendered messages. The existing tests pass unchanged: `cargo nextest run --locked -p workshop-support --all-features error_message state_bucket`, `cargo nextest run --locked -p workshop-user-state --all-features store handlers error`, `cargo nextest run --locked -p workshop-workspace --all-features error ui_state file_state`, and `cargo nextest run --locked -p workshop-server error relay`. +- Commit: "Share error rendering and the state-bucket validator through workshop-support" + + + + + +### Step 26: Share the mock HTTP server test helper + +- Component: Structural consolidation +- Piece: helpers +- Add a helper behind support's `test-fixtures` feature (add the feature if it's missing), in a new `crates/workshop/support/src/fixtures.rs`. It binds a loopback port, runs `axum::serve` on the caller's router in a task, and returns the bound address, plus the task handle if callers stop it. +- Switch the near-copies to it. Find them with `rg "axum::serve" crates/workshop/gateway crates/workshop/server`, skipping the production server in `serve.rs`. They include `crates/workshop/server/src/app/fixtures.rs`, `crates/workshop/server/src/agents/relay-tests.rs`, `crates/workshop/gateway/src/gateway/tests.rs`, `crates/workshop/gateway/src/gateway_progress-tests.rs`, and the server integration tests `session.rs`, `session/menu/restart.rs`, `heartbeat_loop.rs`, `chat_gate.rs`, and `agents.rs` under `tests/it/`. +- Enable support's `test-fixtures` feature in the gateway's and the server's dev-dependencies where it isn't enabled already. +- Tests: `cargo nextest run --locked -p workshop-support --all-features fixtures`, `cargo nextest run --locked -p workshop-gateway --all-features gateway::tests gateway_progress`, `cargo nextest run --locked -p workshop-server app:: relay session heartbeat_loop chat_gate agents`, and `cargo test -p build-xtask` pass. +- Commit: "Share the mock HTTP server test helper through workshop-support" + + + + + +### Step 27: Move the hyphenated groups into directories + +- Component: Structural consolidation +- Piece: layout +- In one commit, `git mv` each group of three or more hyphenated siblings into standard module layout, drop the moved files' `#[path]` attributes, declare the modules in standard layout, and fix the imports. Each file lands where Rust's standard layout looks for it from the module that declares it today. The parent files (`workspace.rs`, `handlers.rs`, `workspace_file.rs`, and `gateway_progress.rs`) stay where they are. +- The children of `crates/workshop/workspace/src/workspace.rs`: + - `workspace-backing.rs`, `workspace-confine.rs`, `workspace-pointer.rs`, and `workspace-token.rs` become `workspace/backing.rs`, `workspace/confine.rs`, `workspace/pointer.rs`, and `workspace/token.rs`. + - `workspace-tests.rs` becomes `workspace/tests.rs`. `workspace-tests-close.rs`, `workspace-tests-reopen.rs`, and `workspace-tests-switch.rs`, which `workspace.rs` declares, become `workspace/tests_close.rs`, `workspace/tests_reopen.rs`, and `workspace/tests_switch.rs`. + - The test children `workspace-tests-backing.rs`, `workspace-tests-grants.rs`, `workspace-tests-pointer.rs`, and Step 3's `workspace-tests-jail.rs` become `workspace/tests/backing.rs`, `workspace/tests/grants.rs`, `workspace/tests/pointer.rs`, and `workspace/tests/jail.rs`. +- The children of `crates/workshop/workspace/src/handlers.rs`: `handlers-file.rs` becomes `handlers/file.rs` and its `handlers-file-tests.rs` becomes `handlers/file/tests.rs`; `handlers-file-state.rs` becomes `handlers/file_state.rs` and its `handlers-file-state-tests.rs` becomes `handlers/file_state/tests.rs`; `handlers-tests.rs` becomes `handlers/tests.rs`. +- The children of `crates/workshop/workspace/src/workspace_file.rs`: `workspace_file-actor.rs` and `workspace_file-siblings.rs` become `workspace_file/actor.rs` and `workspace_file/siblings.rs`. `workspace-file-tests.rs` becomes `workspace_file/tests.rs`, and its `workspace-file-tests-mutations.rs` becomes `workspace_file/tests/mutations.rs`. +- The four `ui_state` modules take distinct names in the same move, because standard layout ties a module's name to its file name: + - `workspace-ui-state.rs` (the in-memory map on the backing) becomes `workspace/backing/ui_state_memory.rs`, module `ui_state_memory` + - `workspace-tests-ui-state.rs` becomes `workspace/tests/ui_state_memory_tests.rs`, module `ui_state_memory_tests` + - `workspace_file-ui-state.rs` (the values in the file's `kv` table) becomes `workspace_file/ui_state_kv.rs`, module `ui_state_kv` + - `workspace-file-tests-ui-state.rs` becomes `workspace_file/tests/ui_state_kv_tests.rs`, module `ui_state_kv_tests` +- The children of `crates/workshop/gateway/src/gateway_progress.rs`: `gateway_progress-presenter.rs` becomes `gateway_progress/presenter.rs`, `gateway_progress-tests.rs` becomes `gateway_progress/tests.rs`, and its `gateway_progress-tests-presenter.rs` and `gateway_progress-tests-recovery.rs` become `gateway_progress/tests/presenter.rs` and `gateway_progress/tests/recovery.rs`. +- Tests: `git diff --cached -M --name-status` lists every moved file as an `R` entry. `cargo nextest run --locked -p workshop-workspace --all-features workspace handlers`, `cargo nextest run --locked -p workshop-gateway --all-features gateway_progress`, and `cargo test -p build-xtask` pass. `rg -n "#\[path" crates/workshop/workspace/src crates/workshop/gateway/src` shows only groups under three files, such as `error-tests.rs`, `resolve-tests.rs`, the two `heartbeat-*.rs` files, and `test_gateway-process.rs`. +- Commit: "Move the workspace and gateway progress groups into directories" + + + + + +### Step 28: Remove the server's module aliases + +- Component: Structural consolidation +- Piece: renames +- Remove the pre-decomposition aliases in `crates/workshop/server/src/lib.rs` (lines 60-67: `gateway`, `gateway_binding`, `gateway_progress`, `heartbeat`, `resolve`, `catalog`, `menu`, and `status`), and point their call sites at the real crates (`workshop_gateway::gateway::...` and so on). The named public re-exports below them (`GatewayClient`, `GatewayUpdater`, `ResolvedGateway`, and the rest) name the real paths. +- The call sites, about 24: `serve.rs`, `fixtures.rs`, `error.rs`, `app.rs`, `app/compose.rs`, `app/tests.rs`, `app/fixtures.rs`, and `routes/realtime.rs` in the server; the server's integration tests; and `boot.rs`, `identity.rs`, `recovery.rs`, and `shutdown.rs` in `crates/workshop/desktop/src/gateway/tests/`. +- The desktop app may depend only on `workshop-server-api`, so its call sites switch to named re-exports. If an item it needs has none, add a named `pub use` to the server's `lib.rs`, not an alias module. +- Tests: `cargo nextest run --locked -p workshop-server -p workshop-server-api --no-run`, `cargo nextest run --locked -p workshop-server app::`, `cargo nextest run --locked -p workshop gateway::`, and `cargo test -p build-xtask` pass. No path in the server, its tests, or the desktop app goes through a removed alias. +- Commit: "Remove the server's pre-decomposition module aliases" + + + + + +### Step 29: Move the /ws socket into a workshop_socket module + +- Component: Structural consolidation +- Piece: renames +- `git mv crates/workshop/server/src/agents/session.rs crates/workshop/server/src/workshop_socket.rs` and `git mv crates/workshop/server/src/agents/session-menu.rs crates/workshop/server/src/workshop_socket-menu.rs`. +- In the same commit: declare `mod workshop_socket;` in `crates/workshop/server/src/lib.rs`, remove `session` from `crates/workshop/server/src/agents.rs`, point the menu child's attribute at `#[path = "workshop_socket-menu.rs"]`, and fix the paths that were relative to `agents`. `SessionsState` still mounts `/ws` (`crates/workshop/server/src/agents/state.rs:141`), now from `crate::workshop_socket`. +- Tests: `git diff --cached -M --name-status` lists both files as `R` entries. `cargo nextest run --locked -p workshop-server session workshop_socket` passes, including the `/ws` tests in `tests/it/session/`. +- Commit: "Move the /ws socket into a workshop_socket module" + + + + + +### Step 30: Rename the status relay, the gateway's SwitchOutcome, and the socket wording + +- Component: Structural consolidation +- Piece: renames +- In `crates/workshop/server/src/agents/status.rs`, `spawn_relay` becomes `spawn_reporter`, `relay` becomes `report`, and the module doc calls the task the status reporter, so "relay" only means the model-catalog passthrough in `agents/relay.rs`. Update the caller in `crates/workshop/server/src/agents.rs` and the names in `status-tests.rs`. +- The gateway's `SwitchOutcome` becomes `SwitchProfileBody` in `crates/workshop/gateway/src/gateway/events.rs`, `gateway.rs`, `lib.rs`, and `gateway/tests/switch.rs`, and in the server's re-export in `crates/workshop/server/src/lib.rs`. workshop-menu's `SwitchOutcome` keeps its name. +- "the /ws workbench socket" becomes "the /ws workshop socket" in the crate doc of `crates/workshop/server/src/lib.rs` (line 14), in `crates/workshop/server/src/agents.rs:1`, and in `crates/workshop/server/src/agents/socket.rs:46`. Add the `workshop_socket` module name to the workshop socket entry of the root `AGENTS.md` Vocabulary section. +- Tests: `cargo nextest run --locked -p workshop-server agents::status` and `cargo nextest run --locked -p workshop-gateway --all-features switch` pass. `rg -w relay crates/workshop/server/src/agents/status.rs crates/workshop/server/src/agents/status-tests.rs`, `rg -w SwitchOutcome crates/workshop/gateway`, and `rg "workbench socket"` over the edit scope, excluding `vibe/`, find nothing. +- Commit: "Rename the status relay, the gateway's SwitchOutcome, and the /ws socket wording" + + + + + +### Step 31: Return named registration structs from every subsystem + +- Component: Structural consolidation +- Piece: renames +- Add `crates/workshop/user-state/src/handles.rs`, and move `register` into it from `crates/workshop/user-state/src/lib.rs:45`, keeping its public path through a re-export. +- In the `handles.rs` of gateway, menu, status, user-state, and workspace, `register` returns a named struct of registration guards instead of a tuple (for example `WorkspaceRegistrations`, with one field per guard), and so does `register_tasks` where one exists. +- The register helpers in `crates/workshop/server/src/app/compose.rs` read the named fields instead of unpacking by position. +- Tests: `cargo nextest run --locked -p workshop-gateway -p workshop-menu -p workshop-status -p workshop-user-state -p workshop-workspace --all-features --no-run` passes, and so do the subsystem tests that call `register` or `register_tasks`, run by name, and `cargo nextest run --locked -p workshop-server app::`. +- Commit: "Return named registration structs from every subsystem" + + + + + +### Step 32: Move the workshop UI's shared backoff and service tokens into services + +- Component: Structural consolidation +- Piece: workshop UI structure +- Reconnect backoff: merge the two implementations (`crates/workshop/ui/src/services/workshop-socket.ts:21` and `crates/workshop/ui/src/services/agent-socket.ts:48`) into a new `crates/workshop/ui/src/services/reconnect-backoff.ts`. If their delays or caps differ, the module takes them as options, and each socket keeps its current values. +- Service tokens: move these tokens and their interface types into new modules under `crates/workshop/ui/src/services/`, one per service. The implementations stay in `parts/` and register against the tokens: + - `STATUS_BAR` (`crates/workshop/ui/src/parts/status/status-bar.ts:198`) + - `CLOSED_EDITORS` (`crates/workshop/ui/src/parts/editor/closed-editors.ts:145`) + - `EDITOR_SETTINGS_SERVICE` (`crates/workshop/ui/src/parts/editor/editor-settings-service.ts:165`) + - `QUICK_INPUT_SERVICE` (`crates/workshop/ui/src/parts/quickinput/quick-input.ts:308`) +- Switch every consumer to import the tokens from `services/`, and update the imports-flow layering rule at `crates/workshop/ui/AGENTS.md:5` (and the service-token list it describes). +- Tests: a new `crates/workshop/ui/test/reconnect-backoff.mjs` covers growth, the cap, and reset. It, the socket test files, `crates/workshop/ui/test/lazy-panel-sizing.mjs`, and every other test file this step edits pass under `node --test`, and `npm run typecheck --prefix crates/workshop/ui` passes. No file imports one of the four tokens from `parts/`. +- Component end, for the packages Steps 19-32 touched: + - `cargo nextest run --locked -p workshop-support -p workshop-workspace -p workshop-user-state -p workshop-gateway -p workshop-menu -p workshop-status --all-features`, then `cargo test --locked` over the same packages with `--all-features --doc` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, `cargo nextest run --locked -p workshop-server --features headless`, and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api` + - `cargo clippy --all-targets --all-features -- -D warnings` with one `-p` for each of the six main-partition packages above, and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings` + - `cargo fmt --all --check` and `cargo test -p build-xtask` + - `npm test`, `npm run typecheck`, and `npm run build` in `crates/workshop/ui` +- Commit: "Move the workshop UI's shared backoff and service tokens into services" + + + + + +### Step 33: Correct the code-level docs + +- Component: Code-level docs +- Piece: docs +- Registry: reword the claims that the registry never names a subsystem, in `crates/workshop/registry/src/lib.rs:19-21` and the `description` in `crates/workshop/registry/Cargo.toml:9`. The traits keep their subsystem names (`MenuSink`, `CatalogSink`, `StatusSink`, `WorkspaceRoots`, and `MenuPush`). Record the three runtime links in the registry's crate docs: the gateway drives the menu through `MenuPush` (`crates/workshop/registry/src/push.rs:141-175`), publishing a model catalog forces a menu reconcile (`push.rs:99-106`), and agent sessions read the workspace's granted roots through `WorkspaceRoots`. +- Code-comment drift (the README items went with Step 2): + - `crates/workshop/desktop/Cargo.toml:50-51`: `src/linux_media.rs` handles the Linux microphone permission, not `src/bridge.rs`. + - `crates/workshop/desktop/Cargo.toml:69-73`: clippy `pedantic` is lowered too, not only `unsafe_code` (compare the workspace lints in root `Cargo.toml`, around line 274). + - `crates/workshop/protocol/src/lib.rs:83-85`: confirm the sentence about the session loops reads in the present tense; master's earlier doc sweep already made it so, so expect no edit here. + - `crates/workshop/support/src/atomic.rs:1-2`: `write_atomic` is also used by `crates/workshop/user-state/src/store.rs:95` and by the workspace pointer module (`crates/workshop/workspace/src/workspace/pointer.rs` since Step 27). + - `crates/workshop/ui/src/services/protocol.ts:66-67`: point the citation at `crates/workshop/server/src/agents/socket.rs`. +- Import pointer: in every workshop crate's `src/lib.rs` (for example `crates/workshop/support/src/lib.rs:10`) and in the new-crate template at `crates/build-xtask/src/new_crate.rs:73`, "Read `AGENTS.md` before adding an import." names the repository-root `AGENTS.md`. The server's sentence also names `crates/workshop/server/AGENTS.md`, and the desktop app's names `crates/workshop/desktop/AGENTS.md` if its `lib.rs` has the sentence. Update any build-xtask test that pins the template text. +- Sweep: re-read these against the code and fix what drifted: the workshop rules and the Vocabulary section in root `AGENTS.md`; `crates/workshop/server/AGENTS.md`, `crates/workshop/desktop/AGENTS.md`, `crates/workshop/desktop/icons/AGENTS.md`, and `crates/workshop/ui/AGENTS.md`; each workshop crate's `//!` crate doc and `## Invariants` block; and each workshop crate's Cargo `description`. +- Tests: `cargo test -p build-xtask` passes. `cargo doc --no-deps` with `RUSTDOCFLAGS="-D warnings"` passes for each touched main-partition library crate; CI leaves the three workshop-partition crates out of `cargo doc`, so they get `cargo nextest run --locked -p --no-run` instead. `npm run typecheck --prefix crates/workshop/ui` and `node --test crates/workshop/ui/test/docs-claims.mjs` pass. `rg "Read .AGENTS\.md. before" crates/workshop crates/build-xtask/src` finds nothing. +- Component end: Step 34 runs next, and its full gates cover every package this step touched, so this component adds no separate checks. +- Commit: "Correct the workshop's code-level docs" + + + + + +### Step 34: Run the exit gates + +- Component: Exit +- Piece: exit +- With the sidecar from Step 1 still staged, run every canonical gate from the Testing Plan exit criteria, plus `cargo nextest run --locked -p workshop-server --features headless` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. Each must end at least as green as its line in Step 1's `Baseline results`. +- Remove the staged sidecar with `node tools/stage-gateway-sidecar.mjs remove --target x86_64-pc-windows-msvc`, then run `cargo workshop`. It must build the desktop app from `crates/workshop/desktop`. +- Run `cargo run -p build-user-guide`. It writes only the gateway, language, and agent exports, `git status` shows them unchanged, and no workshop export exists. +- Retired names: over the edit scope, excluding `vibe/`, a grep for `workshop/shell`, `Tier: shell`, `StatusBarShell`, `createStatusBarShell`, `ws-shell`, `mountLiveShell`, `showShell`, `WorkshopObserver`, "workbench socket", "lazy shell", "empty shell", and "boot shell" finds nothing, and `rg -w SHELL crates/build-xtask/src` finds nothing. +- No variable, parameter, or field named `shell` remains in `.rs`, `.ts`, or `.mjs` files in scope. +- Every remaining "shell" in `crates/workshop`, `crates/build-xtask`, `crates/shared-ui`, `crates/gateway/config-ui/ui/src`, and the root docs means a terminal command shell. +- Record the exit results beside the baseline in Step 1. +- Tests: every exit check passes. If one fails, fix it within this step's commit and rerun that check. +- Commit: "Record the exit gate results", holding the exit results, the completion mark, and any fix an exit check needed. + + + + diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..f6f4cdc6 --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-24-2-workshop-crates-cleanup.md \ No newline at end of file From 13b3b2aa0ca17c5a80ae24fe79120b3438c0fe1f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 13:47:10 -0700 Subject: [PATCH 02/44] Delete the workshop's human docs and drop them from the guide build Remove the workshop user guide chapters, their SUMMARY.md entries, the single-file export, and the five workshop READMEs. The guide generator loses the workshop set, the doc tool loses its workshop lens, and the guide introduction stops routing to the deleted part. - `guide/src/workshop/` and `guide/promptforge-workshop-guide.md` are removed; the Gateway, Language, and Agent parts stay byte-for-byte. - `crates/build-user-guide/src/main.rs` drops the workshop set and repoints two tests at the gateway part. - `crates/workshop/ui/test/docs-claims.mjs` drops its workshop-export check; the guide-sources check stays. - `tools/document.md` loses the workshop lens so it cannot regenerate the deleted guide. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- crates/build-user-guide/src/main.rs | 15 +- crates/workshop/README.md | 47 -- crates/workshop/server/README.md | 145 ---- crates/workshop/server/build.rs | 2 +- crates/workshop/shell/README.md | 47 -- crates/workshop/ui/test/docs-claims.mjs | 16 +- crates/workshop/user-state/README.md | 49 -- crates/workshop/workspace/README.md | 92 --- guide/promptforge-workshop-guide.md | 827 ------------------- guide/src/SUMMARY.md | 15 - guide/src/introduction.md | 2 - guide/src/workshop/01-application.md | 101 --- guide/src/workshop/02-workbench.md | 71 -- guide/src/workshop/03-menus.md | 69 -- guide/src/workshop/04-status-bar.md | 58 -- guide/src/workshop/05-models.md | 63 -- guide/src/workshop/06-chat.md | 96 --- guide/src/workshop/07-voice.md | 62 -- guide/src/workshop/08-workspace.md | 58 -- guide/src/workshop/09-workspace-files.md | 104 --- guide/src/workshop/10-editor.md | 60 -- guide/src/workshop/11-updates.md | 61 -- guide/src/workshop/index.md | 13 - tools/document.md | 13 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 25 files changed, 11 insertions(+), 2077 deletions(-) delete mode 100644 crates/workshop/README.md delete mode 100644 crates/workshop/server/README.md delete mode 100644 crates/workshop/shell/README.md delete mode 100644 crates/workshop/user-state/README.md delete mode 100644 crates/workshop/workspace/README.md delete mode 100644 guide/promptforge-workshop-guide.md delete mode 100644 guide/src/workshop/01-application.md delete mode 100644 guide/src/workshop/02-workbench.md delete mode 100644 guide/src/workshop/03-menus.md delete mode 100644 guide/src/workshop/04-status-bar.md delete mode 100644 guide/src/workshop/05-models.md delete mode 100644 guide/src/workshop/06-chat.md delete mode 100644 guide/src/workshop/07-voice.md delete mode 100644 guide/src/workshop/08-workspace.md delete mode 100644 guide/src/workshop/09-workspace-files.md delete mode 100644 guide/src/workshop/10-editor.md delete mode 100644 guide/src/workshop/11-updates.md delete mode 100644 guide/src/workshop/index.md diff --git a/crates/build-user-guide/src/main.rs b/crates/build-user-guide/src/main.rs index e9020911..4f67b109 100644 --- a/crates/build-user-guide/src/main.rs +++ b/crates/build-user-guide/src/main.rs @@ -13,9 +13,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process; -/// The four documentation sets, in audience order, with their part titles. +/// The three documentation sets, in audience order, with their part titles. const SETS: &[(&str, &str)] = &[ - ("workshop", "The Workshop"), ("gateway", "The Gateway"), ("language", "The Prompt Language"), ("agent", "Agent Programs"), @@ -307,15 +306,14 @@ mod tests { }) .collect(); let summary = render_summary(&parts); - let workshop = summary.find("# The Workshop").expect("workshop part"); let gateway = summary.find("# The Gateway").expect("gateway part"); let language = summary .find("# The Prompt Language") .expect("language part"); let agent = summary.find("# Agent Programs").expect("agent part"); - assert!(workshop < gateway && gateway < language && language < agent); + assert!(gateway < language && language < agent); assert!(summary.contains("- [Introduction](introduction.md)")); - assert!(summary.contains("- [The Window](workshop/01-the-window.md)")); + assert!(summary.contains("- [Start](gateway/01-start.md)")); } #[test] @@ -354,9 +352,8 @@ mod tests { fs::read_to_string(dir.path().join("src").join("SUMMARY.md")).expect("summary"); assert_eq!(first, second); let export = - fs::read_to_string(dir.path().join("promptforge-workshop-guide.md")).expect("export"); - assert!(export.contains("# The Workshop")); - assert!(export.contains("# The Window")); - assert!(export.contains("# The Editor")); + fs::read_to_string(dir.path().join("promptforge-gateway-guide.md")).expect("export"); + assert!(export.contains("# The Gateway")); + assert!(export.contains("# Start")); } } diff --git a/crates/workshop/README.md b/crates/workshop/README.md deleted file mode 100644 index 682a6dd5..00000000 --- a/crates/workshop/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# crates/workshop/ - -`crates/workshop/` is the workshop family's private container - nothing outside may depend in, and inside it dependencies flow one way: shell -> features -> services -> vocabulary. - -## workshop - -The desktop app (at `shell/`): hosts the workshop server in-process and opens the workshop window. It is the shipped artifact, and it reaches the server only through workshop-server-api. Depends on workshop-server-api and gateway-api-discovery; Tauri is the load-bearing third-party stack. - -## workshop-server - -The workshop HTTP server: serves the workshop API to the desktop shell, loopback-only, with the embedded SPA. The shell hosts it in-process, and it composes every subsystem through the registry. It also holds the sessions subsystem itself: the `/ws` workbench socket, the `/agents/ws` agent-session socket, and the `/v1/models` catalog relay, with agent sessions run in the harness through `harness-api` (the shell constructs the `Harness` at boot, registers it, and pushes the gateway binding, chat catalog, and host snapshot into it as data). Depends on all eight sibling subsystems plus harness-api, promptforge, shared-loopback, and gateway-api-discovery; build-ui is its build dependency. - -## workshop-server-api - -The shell's view of the server: re-exports only, so server internals never resolve in the shell. The shell depends on it and never on workshop-server. Depends on workshop-server. - -## workshop-gateway - -The gateway client: bearer-auth HTTP, endpoint binding and discovery, heartbeat, the progress subscriber (which decodes the gateway's `Progress` snapshots and drives the status bar's busy frames), and the run event log. The server's subsystems reach the gateway through it. Depends on workshop-protocol, workshop-registry, workshop-support, promptforge, gateway-api-types, and gateway-api-discovery. - -## workshop-menu - -The server-owned Model menu workbench: the snapshot, broadcast bus, chat model catalog, and per-profile model memory. The server mounts it as the menu subsystem. Depends on workshop-protocol, workshop-registry, and workshop-support. - -## workshop-protocol - -The wire protocol: every JSON frame over the workshop sockets, typed in one place, zero I/O. Every subsystem and the SPA share it as the frame contract. Depends on promptforge. - -## workshop-registry - -The sealed proxy slots subsystems self-register into, so the composition root never names them. The server builds its subsystem set through it. Depends on workshop-protocol. - -## workshop-status - -The status-bar broadcast bus. The server mounts it as the status subsystem. Depends on workshop-protocol, workshop-registry, and workshop-support. - -## workshop-support - -The support vocabulary: atomic writes, reconnect backoff, route deadlines, `workshop.toml`, and the retained broadcast bus. Every subsystem builds on it. No workspace dependencies. - -## workshop-user-state - -The account-scoped UI state bucket persisted as one JSON file in the state directory. The server mounts it, and the SPA's persisted account state lands here. Depends on workshop-protocol, workshop-registry, and workshop-support. - -## workshop-workspace - -The jailed filesystem behind `/workspace/*`: trees, reads, and writes confined to granted roots. The server mounts it as the workspace subsystem. Depends on workshop-protocol, workshop-registry, and workshop-support. diff --git a/crates/workshop/server/README.md b/crates/workshop/server/README.md deleted file mode 100644 index 52ac02cf..00000000 --- a/crates/workshop/server/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# workshop-server - -[![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) - -The PromptForge Workshop HTTP server. It serves a local UI and API on loopback: agent sessions (Markdown agent prompts on the unified PromptForge engine, through the `promptforge` crate), an OpenAI-shaped model catalog passthrough in front of a PromptForge gateway, workspace APIs, and a same-origin payload-opaque relay to Gateway Realtime transcription. The desktop shell (`workshop`) embeds it in-process; run standalone it is the browser-tab frame. - -## Quick start - -Create a `workshop.toml` in the current directory. Every field is optional and the defaults are built in: - -```toml -[gateway] -base_url = "http://127.0.0.1:8081" -# Optional for a loopback gateway: with the gateway's default -# trust_loopback = true, a same-machine caller that presents no key is -# admitted. Required when the gateway is on another host or its operator -# set trust_loopback = false. -api_key = "${PROMPTFORGE_GATEWAY_API_KEY}" -``` - -A gateway that trusts loopback callers trusts every OS account on that machine, including reading upstream API keys from the gateway's admin config surface; on a shared machine the gateway operator sets `trust_loopback = false`, and then this key is required. - -Then run: - -```bash -cargo run -p workshop-server -``` - -The server binds `127.0.0.1:7910` by default and serves the chat UI at `http://127.0.0.1:7910/`. Set `server.open_browser = true` to have it open your system browser once it is serving. - -The desktop shell (`workshop`) is the zero-config path: it embeds this server in-process on an OS-assigned loopback port, discovering `workshop.toml` beside its executable, then the current directory, then `~/.promptforge/` - the file supplies only the `[gateway]` connection and the path settings, since the shell owns the listener. The server binary does not generate one - it reads `workshop.toml` from the current directory, or `workbench.toml` there if the canonical name is missing. - -String values support `${VAR}` environment interpolation; `$$` is a literal `$`, and an unset variable interpolates to the empty string. - -## Configuration - -Every field of `workshop.toml`: - -| Field | Default | Description | -| --- | --- | --- | -| `gateway.base_url` | (empty) | Base URL of the PromptForge gateway. A live `gateway.json` gateway discovery file in `~/.promptforge/run` (written by a running gateway) wins over this setting; the explicit value is the fallback for a gateway discovery cannot see, such as a LAN gateway. With no live file and no explicit value, startup fails plainly: no gateway configured or running | -| `gateway.api_key` | (empty) | Bearer key for the gateway API; supports `${VAR}` interpolation; empty sends no `Authorization` header, which a loopback gateway with the default `trust_loopback = true` accepts (a LAN gateway, or one with `trust_loopback = false`, answers 401) | -| `server.bind` | `127.0.0.1:7910` | Address the workshop server binds to | -| `server.open_browser` | `false` | When true, the server binary opens the system browser at its address once serving; the desktop shell ignores it | -| `server.state_dir` | the config file's directory | Directory holding the server's persistent state: the harness run log every agent session is recorded in lives under `state_dir/harness/`, and the per-profile model memory is written here | -| `agents.path` | `agents/` beside the config file | Directory whose `.md` files are launchable agent prompts alongside the embedded built-in `chat` agent; a directory `chat.md` shadows the embedded source, and a missing directory offers exactly the built-in | - -## Routes - -| Route | Description | -| --- | --- | -| `GET /health` | Health probe; answers `{"status":"serving"}` | -| `GET /` | The chat UI (also `/app.js`, `/app.css`, `/style.css`, `/pcm-worklet.js`, bundled by the crate's build script: read from disk in debug builds, embedded in the binary in release builds) | -| `GET /v1/models` | Proxies the gateway's model catalog verbatim; while the gateway is known down, answers 502 `gateway_unreachable` without attempting it | -| `GET /v1/realtime` | Same-origin WebSocket relay to the gateway's fixed `/v1/realtime?intent=transcription` target; validates browser Origin, attaches gateway authentication upstream, rejects subprotocols, preserves text, binary, and close frames, and never parses speech payloads | -| `GET /ws` | WebSocket upgrade, one persistent socket for the workshop's downstream JSON: unsolicited `{"type":"status","label","description","severity","activity","progress"}` observer updates, `{"type":"models","models":[...]}` catalog pushes, and `{"type":"workbench",...}` Model-menu snapshots out; `{"type":"select_model","model"}` and `{"type":"switch_profile","name"}` menu events in (`name` is a profile name or `null` for no profile; the selection persists on the gateway, and a supervised sidecar is restarted to load it), refusals answered with `{"type":"error","message"}` frames | -| `GET /gateway/origin` | The gateway's base URL, so the UI can point the embedded config panel's iframe at `/config/?mode=panel` | -| `ANY /gateway/api/{*path}` | The config panel's proxy to the gateway with the bearer key attached server-side. The rule: every method on a path under `/admin/` forwards (including `switch-profile`, `config-apply`, and `profiles`), except `GET /admin/progress`, which the workshop's own status bar owns; outside `/admin/`, only `GET /v1/cache` and `DELETE /v1/cache/<64-hex digest>` forward. Dot and backslash segments and everything else are refused; an `/admin/` path the gateway does not serve is the gateway's 404 or 405 to answer | -| `GET /gateway/config/` | The gateway's config SPA assets proxied same-origin, so the panel iframe loads from the workshop's own origin | -| `GET /agents/ws` | WebSocket upgrade for one agent session: the discovered agent list on connect, `{"type":"launch","agent"}` / `{"type":"attach","session"}` in (acknowledged with `{"type":"agent_session","session","agent"}`), then durable `{"type":"agent_event","index","event",...}` log entries, ephemeral `{"type":"agent_delta","kind","content","reply"}` streaming chunks, and the `input_required` / `input_cancelled` wait frames answered by `{"type":"input_response","token","text"}`; `{"type":"cancel"}` fires turn-cancel | - -## Gateway discovery and resilience - -At startup the server resolves the gateway endpoint: a live `gateway.json` gateway discovery file in the run directory (`~/.promptforge/run`) wins - the gateway writes it after a successful bind, and it is validated by pid, process image, health answer, and bearer key - then explicit `[gateway]` config. A stale file is removed and its reason (dead pid, foreign image, failed health, rejected key) is reported on the status bus and in the log before the config fallback is used. With no live file and no explicit `gateway.base_url`, startup fails plainly: no gateway configured or running. - -A background heartbeat polls the gateway's `GET /health` every five seconds and reports transitions on the status bus: "Gateway unreachable" when the gateway stops answering, "Connected to gateway" when it comes back. While the gateway is known down, `GET /v1/models` answers 502 `gateway_unreachable` instead of waiting on a dead connection, and the Model menu's `chat_ready` reads false. A reconnect re-fetches the model catalog and pushes it to every `/ws` session as a `{"type":"models",...}` frame, so a UI that booted during the outage refreshes its model picker by itself. Once an endpoint has resolved, the server boots and serves the UI whether or not the gateway has ever answered. - -An embedding host can publish a local Gateway replacement only by presenting `gateway_api_discovery::ValidatedConnection`; raw gateway discovery files are not accepted. The cancellable publication entry point also stops lock contention without changing the current generation when its host is shutting down. The server publishes the HTTP client, model client, endpoint, bearer, generation, and validated process identity together as one immutable snapshot, so long-lived consumers never observe mixed replacement state. Explicitly configured LAN gateways have no local process identity and are never supervised or stopped by the desktop shell. - -## UI development - -The chat UI is TypeScript in the sibling package `../ui/` (`crates/workshop/ui/`, sources under `../ui/src/`), bundled by esbuild. Building the crate requires Node.js 22: run `npm ci` in `../ui/` once per checkout. Every `cargo build` runs the UI build through the crate's `build.rs` (via the shared `build-ui` helper's `build_sibling("../ui", ...)`), writing the bundle to `$OUT_DIR/ui-dist/` - never into the repository. Debug builds read the bundle from disk on every request; release builds minify and embed it into the binary. `../ui/node_modules/` and `../ui/dist/` are gitignored. - -The workflow: edit the TypeScript, then `cargo build` (or `cargo run -p workshop-server`). The build script re-bundles whenever `../ui/src/` or the static UI files change - a build-script-only rerun, no Rust recompile - and debug builds read the bundle from disk on every request. `npm run build` and `npm run watch` in `../ui/` still write `../ui/dist/` in place, which nothing serves: that tree exists for the jsdom tests, which import the built bundle. - -`npm run typecheck` runs `tsc --noEmit`; esbuild strips types without checking them, so the typecheck is advisory. `npm test` runs `node --test`, which discovers every test under `../ui/test/` plus any colocated `src/**/*.test.mjs` files; the suite includes a jsdom smoke test that imports the built `dist/app.js` and asserts the workbench mounts (run `npm run build` first). - -The chat surface is the agent-session panel (`../ui/src/parts/agent/agent-session-view.ts`), rendered from the durable event stream over `GET /agents/ws`. Its input includes the push-to-talk mic (`../ui/src/parts/stt/stt.ts`): `SpeechCaptureService` produces little-endian mono PCM16 at 24 kHz, `RealtimeTranscriptionService` speaks the transcription subset through the same-origin `/v1/realtime` relay, and the view replaces one reversible editor range with live hypothesis snapshots until completion. One recording remains one item and take for arbitrary duration while Gateway final throughput keeps pace with capture. If Gateway's 30-second retained PCM ownership is exhausted, the decoded `too_much_unfinalized_audio` event stops capture and commits the still-valid input without clearing accepted visible text; other server errors retain rollback behavior. The mic is gated by the pending input wait, and connection or capture failures are local recoverable status messages. The Workshop never reads speech payloads or owns model lifecycle; the gateway key stays in the server process. `../ui/style.css` defines the workshop shell (tree, panels, dictation UI, status bar) and overrides. - -The status bar at the bottom of the window renders the observer's `{"type":"status",...}` frames (`../ui/src/parts/status/status-bar.ts`): the label as the bar text, the description as the tooltip, error frames in a distinct color. Debug-severity frames are internal instrumentation and never touch the text. The right slot holds a `` bar while a frame reports progress, and an activity LED otherwise: a small circle that pulses green on gateway traffic and amber on dictation activity (green wins when both coincide), lit for one pulse window per frame and faded by a CSS transition. The bar's colors, glow radii, and pulse window are CSS custom properties (`--led-green`, `--led-amber`, `--led-off`, `--led-glow-radius`, `--led-pulse-ms`, `--progress-fill`, `--progress-glow`, ...) at the top of `../ui/style.css`. - -## Skinning - -The whole UI skins from the `:root` block at the top of `../ui/style.css` - every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a CSS custom property there. - -Two ways to reskin: - -1. **Edit the block.** Change values in the `:root` block of `../ui/style.css` and rebuild (`cargo build`; debug builds serve the bundle from disk). This is the path for changes you keep. -2. **Override from an additional stylesheet.** Add a `` after `/style.css` in `../ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade. - -The variables: - -| Variable | Default | What it paints | -| --- | --- | --- | -| `--bg` | `#0d0e12` | Window and chat background | -| `--bg-raised` | `#14161c` | Raised surfaces (cards, code blocks) | -| `--bg-hover` | `#1a1d25` | Hover washes, user message bubble | -| `--bg-sidebar` | `--bg-raised` | Sidebar background | -| `--text` | `#d6d9e0` | Body text (13:1 on `--bg`) | -| `--text-muted` | `#8b90a0` | Dimmed text (6:1 on `--bg`; do not go dimmer, 4.5:1 is the floor) | -| `--border` | `#262a33` | Hairline borders | -| `--accent` | `#7c7fd4` | Primary action (send button) | -| `--accent-dim` | `#5658a0` | Focus border | -| `--danger` | `#b0606a` | Recording background, danger accents (non-text) | -| `--danger-text` | `#cf7f88` | Danger as text on dark surfaces | -| `--on-danger` | `#ffffff` | Icon or text on a `--danger` fill | -| `--font-prose` | system stack | UI font | -| `--code-font` | ui-monospace stack | Code blocks, code chrome | -| `--space-xs`..`--space-xl` | `4/6/8/12/16px` | Shell spacing scale | -| `--radius` | `6px` | Control corner radius | -| `--sidebar-width` | `220px` | Sidebar width | -| `--status-bar-height` | `24px` | Status bar height | -| `--status-bar-bg` | `--bg-raised` | Status bar background | -| `--status-bar-text` | `--text-muted` | Status bar text | -| `--status-bar-text-error` | `--danger-text` | Status bar error text | -| `--status-bar-padding-inline` | `--space-lg` | Status bar horizontal padding | -| `--status-bar-gap` | `--space-lg` | Status bar item gap | -| `--progress-width` | `96px` | Progress bar width (also the slot's minimum) | -| `--progress-height` | `6px` | Progress bar height (drives its rounding) | -| `--progress-fill` | `#4caf7d` | Progress fill | -| `--progress-track` | `rgba(255,255,255,0.08)` | Progress track | -| `--progress-glow` | `4px` | Blur radius of the fill's glow | -| `--led-size` | `10px` | Activity LED diameter | -| `--led-green` / `--led-amber` | `#4caf7d` / `#d9a03f` | Gateway / dictation activity colors | -| `--led-off` | `rgba(255,255,255,0.08)` | The unlit LED lens | -| `--led-core` | `#ffffff` | Hot center of the lit gradient | -| `--led-glow-radius` | `6px` | Base blur of the layered bloom | -| `--led-pulse-ms` | `250ms` | Pulse hold window and fade-out (also read by the status bar's JS) | -| `--led-fade-in-ms` | `60ms` | Fade-in when a pulse lights the LED | -| `--led-lens-highlight` / `--led-lens-shadow` | white/black alphas | Idle lens inset shading | -| `--scrollbar-width` | `8px` | Scrollbar thickness (drives thumb rounding) | -| `--scrollbar-thumb` | `rgba(255,255,255,0.16)` | Scrollbar thumb | -| `--scrollbar-thumb-hover` | `rgba(255,255,255,0.28)` | Scrollbar thumb on hover | - -## Agent sessions - -Agent sessions run in the PromptForge harness, reached through `harness-api`. The composition root constructs the `Harness` (agents directory and `state_dir/harness/`, where its run log lives) and registers it into the registry like every other subsystem; `AgentSessions` (reached through `AppState::agents`) opens sessions through it behind `GET /agents/ws`. The harness discovers `.md` agent prompts from `agents.path` and always offers the embedded built-in `chat` agent (a directory `chat.md` shadows it). Everything the harness knows about the shell is pushed through its public API as data: the gateway endpoint and bearer (at boot and on every replacement), the chat-capable catalog (an empty list means no model to launch under), and the host snapshot serving the `ui()` global's selected model and first granted workspace root, read from the menu and the registry's `WorkspaceRoots` slot. A session's transcript is the harness run log: sockets attach and detach, a reconnect replays the transcript (every durable frame includes its wire index) and re-announces unresolved waits, and the harness's wait registry turns every dying wait into a cancelled frame the socket renders as `input_cancelled`. Live deltas are sent on a dedicated ephemeral channel, each stamped with the reply id of the durable event that will supersede it. Turn-cancel relaunches the program over the retained transcript - a stop reason, never an error - while `AgentSessions::close` ends a session for good. Status-bar reporting stays on the shell side: a per-session relay derives the Generating and Thinking pulses, the idle on a completed reply (which also resets the reconnect backoff), and the failure status for a failed model turn from the session's events and deltas. - -## Minimum Rust Version - -Rust 1.89 or later. - -## License - -Licensed under the [Boost Software License 1.0](../../../LICENSE). diff --git a/crates/workshop/server/build.rs b/crates/workshop/server/build.rs index 625b95da..26bed415 100644 --- a/crates/workshop/server/build.rs +++ b/crates/workshop/server/build.rs @@ -3,7 +3,7 @@ //! copies of the static assets, all written to `$OUT_DIR/ui-dist/` (never //! into the repository). The crate version is baked into the bundle as //! `__APP_VERSION__`. Requires Node.js 22 and one `npm ci` in `../ui/` -//! per checkout; see the crate README. Under the +//! per checkout; see the crate docs. Under the //! `headless` feature the UI build is skipped and the asset directory is //! left empty: the asset routes serve through the no-op implementation, //! so server-only integration tests need neither Node.js nor the bundle. diff --git a/crates/workshop/shell/README.md b/crates/workshop/shell/README.md deleted file mode 100644 index 96b830a8..00000000 --- a/crates/workshop/shell/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# workshop - -[![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) - -The PromptForge Workshop desktop window. It hosts the workshop server in-process on a loopback listener with an OS-assigned port, waits for its health endpoint to answer, and opens a Tauri window (WebView2 on Windows) pointed at it. Boot connects the gateway first: attach to a running gateway through its gateway discovery file, or launch the sibling `promptforge-gateway` as a separate detached process when none is running. The server then resolves the same endpoint itself: the gateway discovery file first, explicit `workshop.toml` config second. While the window runs, the shell supervises only a local sidecar: it validates a replacement's process image, boot identity, health, and bearer before publishing the whole endpoint generation together, and relaunches the sibling with bounded backoff when no replacement exists. Closing the window cancels and joins supervision before stopping the in-process server; the gateway is a separate process and keeps running. The window menu's quit item (Quit PromptForge and Gateway) also stops the currently published local gateway. Explicitly configured LAN gateways are never supervised or stopped. - -## Quick start - -```bash -cargo run -p workshop -``` - -## Configuration - -The shell reads no `gateway.toml`; the gateway owns its own configuration. What the shell discovers is `workshop.toml`, searching three places, first found wins: - -1. Beside the executable -2. The current directory -3. `%USERPROFILE%\.promptforge\workshop.toml` - -The file supplies the `[gateway]` connection (`base_url`, `api_key`) for attaching to a gateway discovery cannot see - a LAN gateway - plus the state and agent-program paths. The listener settings are the shell's own and cannot be configured away: the in-process server always binds `127.0.0.1:0` and never opens a browser. With no `workshop.toml`, state anchors in `%USERPROFILE%\.promptforge\` and the gateway endpoint resolves through the gateway discovery file a running gateway writes - or the file the shell's own launch produces: with no gateway running, the shell launches the sibling `promptforge-gateway` beside its executable before the server starts. A Workshop-only install has no sibling executable, so resolution falls through to explicit config; with no gateway running, no sibling executable, and no explicit config, boot fails with the plain no-gateway error naming both remedies. - -Development against the standalone `workshop-server` binary flow is unchanged. - -## Browser opening - -The shell drives its own window and never opens a browser tab. The `open_browser` flag belongs to the standalone `workshop-server` binary. - -## Window state - -The shell no longer uses `tauri-plugin-window-state`. Window geometry (logical size, position, and the maximized flag) lives in the open workspace file, the same `.pfwork` database that holds the granted folders, and the shell reaches it only over HTTP through the in-process server: `GET /workspace/file/current` before the window shows, to restore; `PUT /workspace/file/window-state` to save, debounced while the user drags and once more on close with a short timeout; and a refetch and reapply when the SPA emits `promptforge:workspace-opened` after opening, saving as, or duplicating a workspace. An ephemeral workspace has nowhere to keep geometry: the server answers `saved: false`, the window opens at the default size, and nothing persists until the first Save Workspace As. Every failure on this path logs and continues; geometry never blocks or fails boot. - -## Native runtimes - -The desktop build has no native-backend feature flags. At run time the artifact store downloads the pinned whisper.cpp bundle for the host - CUDA on Windows, Metal on Apple Silicon, and CPU on the other supported targets - alongside the managed `llama-server`. - -## Updates - -The installed desktop app checks the latest GitHub Release after startup. Signed updater bundles are verified with the public key embedded in `tauri.conf.json`; the matching private key exists only in the release workflow secrets. Help > About PromptForge also provides a manual check. - -## Minimum Rust Version - -Rust 1.89 or later. - -## License - -Licensed under the [Boost Software License 1.0](../../../LICENSE). diff --git a/crates/workshop/ui/test/docs-claims.mjs b/crates/workshop/ui/test/docs-claims.mjs index 61e6becb..fbc7e6ed 100644 --- a/crates/workshop/ui/test/docs-claims.mjs +++ b/crates/workshop/ui/test/docs-claims.mjs @@ -45,8 +45,7 @@ test("AGENTS.md names two UI-state homes, not a TOML config or three buckets", a // such as in private mode" no longer describes a real failure. // TWF-003: an Open Recent workspace row now opens the workspace, so // the list is no longer "a record only". -// One list guards both the sources and the export so a regeneration -// that landed between the two source fixes cannot slip one phrase through. +// One list guards the guide sources. const STALE_GUIDE_PHRASES = ["storage is blocked", "a record only"]; test("the stale zoom and Open Recent claims are gone from the guide sources", async () => { @@ -58,16 +57,3 @@ test("the stale zoom and Open Recent claims are gone from the guide sources", as assert.deepEqual(offenders, [], `guide/src still says "${phrase}"`); } }); - -test("the tracked guide export matches the sources on the stale claims", async () => { - // The single-file export is regenerated by `cargo run -p build-user-guide`; - // a source fix that skipped the regeneration leaves the export stale. - const exportFile = path.join("guide", "promptforge-workshop-guide.md"); - for (const phrase of STALE_GUIDE_PHRASES) { - assert.deepEqual( - await offendingLines(exportFile, phrase), - [], - `guide/promptforge-workshop-guide.md still says "${phrase}"; rerun build-user-guide`, - ); - } -}); diff --git a/crates/workshop/user-state/README.md b/crates/workshop/user-state/README.md deleted file mode 100644 index fb43a0cd..00000000 --- a/crates/workshop/user-state/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# workshop-user-state - -The PromptForge Workshop's user-state subsystem: the account-scoped UI state bucket. It holds the values the SPA keeps per user rather than per workspace - editor toggles, zoom, the recent-files list, the command palette's history - persists them as one JSON file in the server's state directory, and serves them over `/user/state`. - -## Tier - -A feature crate. It may depend on `workshop-protocol`, `workshop-registry`, and `workshop-support`, and never on `workshop-workspace`, `workshop-server`, or any `gateway-*` or `promptforge-*` crate. The workspace-scoped bucket (dock layout, expanded tree folders, closed editors) is the `workshop-workspace` crate's business and travels with the `.pfwork` file; this crate knows nothing about workspaces. - -## The state file - -`state_dir/ui-state.json` is one JSON object, keyed by the allow-listed names, each value stored exactly as the SPA serialized it: - -```json -{ - "editor_settings": { "wordWrap": true, "renderWhitespace": false, "renderControlCharacters": false, "columnSelection": false }, - "zoom": 1.1, - "recent_files": ["", "..."], - "commands_history": ["", "..."] -} -``` - -The server never interprets a value beyond checking that its key is allow-listed, that its JSON text is at most 1 MiB, and that it parses as JSON. The SPA owns every value's schema. A key this build does not know is kept and rewritten with the rest, so a newer build's value survives a round trip through an older one, but only the allow-listed keys are served. - -The file is read once when the store is constructed and nothing is created until the first put. A missing file is the ordinary first launch. An unreadable file, one that does not parse, or one whose top level is not an object is corrupt state: logged once at warn and read as empty, to be replaced whole by the next put. - -Every put updates the in-memory map under one mutex and rewrites the whole file through `workshop_support::write_atomic` on a blocking task, the same pattern `workshop-menu` uses for `workshop-state.json`. The lock is held across the write so two puts cannot land their rewrites out of order, and a crash leaves the old document or the new, never a truncation. - -## Endpoints - -Registered through `workshop_registry::Registry` and merged into the shell's API router under the default deadline tier. - -| Route | Body | Effect | -|---|---|---| -| `GET /user/state` | none | Answers every allow-listed key with its stored value, `null` where nothing has been put. | -| `PUT /user/state/{key}` | any JSON value | Stores the body verbatim under `key` and answers `{ "saved": true }`. | - -A put is judged key first, then body size, then shape, so the client is told about the cheapest mistake. Failures reach the wire through the crate's own `UserStateError` envelope: an unknown key or a body that is not JSON is `400` (`user_state_key`, `user_state_not_json`), a body over the cap is `413` (`user_state_too_large`), and a write that fails is `500` (`user_state_io`). The raw body still passes axum's default 2 MiB body limit before the handler sees it; that hard stop answers axum's own `413`. - -## Failure posture - -Zone two throughout. A refused put returns its error and writes nothing. A put whose write fails keeps the new value in memory - the map is the source of truth and the file is its mirror - logs at warn, and answers the server-error envelope; the SPA warns once and keeps working with its in-memory value. Boot never blocks on the state file and never fails for it. - -## Minimum Rust Version - -Rust 1.89 or later. - -## License - -Licensed under the [Boost Software License 1.0](../../../LICENSE). diff --git a/crates/workshop/workspace/README.md b/crates/workshop/workspace/README.md deleted file mode 100644 index a682caa5..00000000 --- a/crates/workshop/workspace/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# workshop-workspace - -The PromptForge Workshop's workspace subsystem: confined filesystem access behind `/workspace/*` - directory trees, file reads, and file writes jailed to roots the user explicitly granted - plus the workspace file that keeps those grants, the window geometry, and the workspace-scoped UI state (dock layout, expanded folders, closed editors) between sessions. - -## Confinement - -A dropped folder becomes a granted root; a dropped file grants its parent directory. Every request path is checked lexically (no `..`, and on Windows no NTFS alternate data stream names), then canonicalized and prefix-matched against the canonical grants before any filesystem operation, so traversal, symlink escapes, and UNC aliases cannot reach outside a grant. The in-memory grant set is the confinement source of truth. The workspace file below mirrors it and is never consulted on a request path. - -## The workspace file - -A workspace is one user-visible file, `Name.pfwork`: an embedded Turso database the user opens, saves as, and duplicates from the SPA File menu. Until the first Save As the workspace is ephemeral - grants live in memory only, nothing persists, and the display name is `Untitled`. Once a file backs the workspace, every grant and revoke lands in it as it happens, so there is no dirty state to save or lose: the file is a live mirror, not a snapshot. - -While a file backs the workspace, turso runs it in write-ahead-log mode, so a `Name.pfwork-wal` sidecar sits beside the file and holds recent writes. `Workspace::close_backing` stops the actor and closes the connection, which checkpoints the log into the main file and removes the sidecar; graceful shutdown runs it through the subsystem's registered task, so a normal quit leaves exactly one file. A crash skips the close and leaves the sidecar; turso replays it into the main file on the next open, so nothing is lost. Because turso keeps a process-wide registry keyed by path, the same file is never opened twice in one process: an open of the already-open path reloads the existing backing instead (see `Workspace::open_file`). - -Save As and Duplicate both create exactly one file at the chosen path. No directory is created around it; follow-on projects grow plain-named sibling directories (`agents/`, `runs/`) beside the file lazily, only when there is something to put in them. Two `.pfwork` files in one folder would share those siblings, so the convention is one workspace per folder. It is a convention, not an enforced rule. - -Save As and Duplicate differ by what travels. Save As writes the current grants and the saved window state into a new file and switches to it; siblings stay beside the original. Duplicate drains pending writes, checkpoints the write-ahead log into the main file so the copy is complete without a `-wal` sidecar, copies the file plus any existing sibling directories except the derived `index.db`, and switches to the copy. In v1 no siblings exist, so both are the same file operation. - -### Schema v1 - -The `user_version` pragma is the migration counter and holds `1`. - -```sql -CREATE TABLE meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE grants ( - path TEXT PRIMARY KEY, -- canonical, verbatim-prefix-free - position INTEGER NOT NULL, -- insertion order - added_at TEXT NOT NULL -- RFC 3339 -); - -CREATE TABLE kv ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -- JSON text -); -``` - -`meta` holds `format` (always `promptforge-workspace`), `version` (`1`), `name` (the display name; absent means the file stem), and `created_at`. `grants.position` and `added_at` record insertion order from both producers: a `grant()` on the live workspace assigns one past the current maximum and the current time, and Save As writes the in-memory grants with the `position` and `added_at` they were loaded or granted with, so the new file records the true history rather than a renumbering. Removal never renumbers. The tree still lists grants in canonical path order, so the columns record history and do not change display. The table names `agent_windows`, `run_presets`, `runs`, `run_events`, `agents`, and `documents` are reserved for follow-on projects and unused. - -#### The `kv` table - -`kv` holds one JSON text per key. `window` is typed and written by the shell's own route; the other three are the opaque workspace-scoped UI state bucket - stored verbatim, checked only for an allow-listed key, a 1 MiB cap on the JSON text, and that it parses - whose schemas the SPA owns. Keys are additive, so `user_version` stays `1` and an older file reads every missing key as `null`. - -| Key | Shape | Holds | -|---|---|---| -| `window` | `{ width, height, x, y, maximized }` in logical pixels | The shell's geometry. | -| `layout` | `{ version, zones, overrides, layout }` | The dock layout envelope exactly as the SPA's layout persistence builds it (schema version 3 today). | -| `tree` | `{ "expanded": ["", ...] }` | The folders expanded in the Workshop tree. Paths are absolute, matching the grants table. | -| `closed_editors` | `{ "paths": ["", ...] }` | The closed-editor stack, most recent first, capped at 50. | -| `scroll` | reserved | Unused. | -| `agent_sessions` | reserved | Unused. | - -Opening validates `user_version`, `meta.format`, and `meta.version` before reading anything else and writes nothing. A file that is not a database, or a database without the stamp, is refused as "not a promptforge workspace file" and left byte-identical; a stamp at another version is refused with the found-versus-supported versions. A refusal never wipes or partially loads a workspace. - -All database I/O runs on one actor task that owns the one connection; handles are clone-cheap senders into a bounded channel, so channel order is disk order and the synchronous confinement code keeps its shape. Dropping every handle drains the queue and closes the connection. - -### Endpoints - -Registered through `workshop_registry::Registry` beside the tree and file routes. Every successful switch answers with the workspace as it now stands, `{ path, name, grants, window_state }`, with `path` and `window_state` `null` while ephemeral. - -| Route | Body | Effect | -|---|---|---| -| `GET /workspace/file/current` | none | Reports the open file, name, grants (each with `exists`), and saved geometry. | -| `POST /workspace/file/open` | `{ path }` | Opens the file and replaces every grant with its contents. | -| `POST /workspace/file/save_as` | `{ path }` | Creates a new file from the current grants and window state and switches to it. | -| `POST /workspace/file/duplicate` | `{ path }` | Copies the current file and its siblings to `path` and switches to the copy. While ephemeral there is no file to copy, so it behaves as `save_as`: a new file from the current grants. | -| `PUT /workspace/file/window-state` | `{ width, height, x, y, maximized }` | Saves geometry; answers `{ saved: false }` and writes nothing while ephemeral. | -| `GET /workspace/file/state` | none | Answers every allow-listed `kv` state key (`layout`, `tree`, `closed_editors`) with its value, `null` where nothing has been put or while ephemeral. | -| `PUT /workspace/file/state/{key}` | any JSON value | Stores the body verbatim under `key`; answers `{ saved: false }` and writes nothing while ephemeral. | - -Every write to the file, the state keys included, funnels through the one actor task, so there is a single writer per `.pfwork`. Save As copies grants and window state into the new file but not the state keys; the SPA writes them after the switch so that fact has one writer too. - -Failures reach the wire through the crate's `WorkspaceError` envelope: a refused file is a client error reporting the required-versus-actual text with grants unchanged, a missing path is the ordinary not-found, a path already taken is a conflict. A state put with an unknown key or a body that is not JSON is `400`, a body over the cap is `413`; either changes nothing, ephemeral or not. - -### The last-workspace pointer - -`state_dir/last-workspace` is a plain-text file holding the path of the workspace that was open when the server last ran. It is written atomically after every successful open, save-as, or duplicate, and read once at boot: the server reopens the file before the listener serves, so readiness means the grants are already in place. A missing pointer is the ordinary first launch. An unreadable, empty, or non-UTF-8 pointer, a target that has vanished, or a file that is refused all log a warning and start the server ephemeral. Boot never blocks on it and never fails for it. - -### Zone-two behavior - -Persistence never decides whether an operation succeeds. A grant or revoke updates memory first and then persists through the backing when one is open; a persist that fails logs at warn and the operation still returns success. A pointer that cannot be written costs the next launch its reopen and nothing else. Restored grants log at info, and a grant whose path has vanished from disk still loads and lists as `exists: false` so the user can see it and revoke it. Opening a workspace file grants its stored directories to the confined file API, the same trust gesture as dropping a folder: deliberate user action, restored grants visible in the tree. - -## Minimum Rust Version - -Rust 1.89 or later. - -## License - -Licensed under the [Boost Software License 1.0](../../../LICENSE). diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md deleted file mode 100644 index 176908ed..00000000 --- a/guide/promptforge-workshop-guide.md +++ /dev/null @@ -1,827 +0,0 @@ -# The Workshop - ---- - -# The Application - -This chapter teaches you what the Workshop desktop application is, how to install and start it, and what you see the first time its window opens. Everything else in this guide happens inside this one window, so it is worth a few minutes to understand what the application is made of and how it boots before you touch any feature. - -## What the Workshop is - -PromptForge Workshop is a desktop application for Windows, macOS, and Linux. You launch one program named Workshop. That program boots a small server inside itself and then opens a single window titled "PromptForge". The window shows the Workshop interface, which the built-in server serves on your own machine. There is no separate web server to install and no files to download before the interface can appear; the interface ships bundled inside the application. - -The Workshop talks to a PromptForge gateway. The gateway is the part of the system that supplies the model catalog, the profiles, and the model rounds that power chat. The gateway runs as its own program, separate from the Workshop window: the application's built-in server attaches to a running gateway over HTTP, so closing the window never unloads the gateway or its loaded models. The window opens at 1024 by 768 pixels the first time. Once you have saved a workspace file, it remembers its size, position, and maximized state there across launches; the Workspace Files chapter explains how. - -The application shows the PromptForge program icon in its custom title bar. - -## Installing and starting the Workshop - -You receive the application as a Windows installer, a macOS disk image, a Debian package, or a Linux AppImage, depending on your platform. On Windows the installer silently includes the webview runtime the application needs, so there is no separate setup step. - -To start the application, launch it the way you launch any installed program on your platform. If you work from a source checkout instead, one command builds and starts it: - -```` -cargo run -p workshop -```` - -To check which version you have without starting anything, run: - -```` -promptforge-workshop --version -```` - -This prints the version and exits. It does not start the server and it does not open a window. - -The installed application can also check for updates and update itself. After startup it automatically checks the latest GitHub Release, and it installs only cryptographically verified updates. - -You can also run the Workshop's server on its own and use the interface in an ordinary browser. In that mode you open the chat UI at `http://127.0.0.1:7910/`. The browser session works like the desktop window for almost everything; the few differences, such as native window controls and Explorer drag-and-drop, are called out in the chapters that cover them. - -## The first launch - -The first time you start the Workshop, the application prepares everything it needs before you see a window. Follow what happens: - -1. The application looks for its boot configuration. -2. It attaches to a running local gateway through its validated gateway discovery file. If none is running, it launches the sibling `promptforge-gateway`; a Workshop-only install instead uses the explicit gateway in `workshop.toml`. -3. It starts its server inside its own process and waits until the server accepts connections. -4. It waits for the interface to answer a health check, up to 15 seconds. -5. Only then does the window open. - -You never see a window before the interface is ready, and the interface never opens against a dead server. If the server does not answer in time, the error message names the health endpoint and how long the application waited. If startup fails for any reason, the application prints the full error chain and exits with a failure code instead of opening a broken window. - -Only one instance of the Workshop runs at a time. If you launch it again while it is already running, the existing window comes into focus instead of a second copy opening. When you close the window, the application shuts its built-in server down cleanly and exits; the gateway is a separate program and keeps running. To stop the gateway together with the window, use the quit command instead: Quit PromptForge and Gateway on the application menu, or Ctrl+Q (Cmd+Q on macOS). When the Workshop is attached to a gateway on another machine, the command reads Quit PromptForge and stops only the window - a client never stops a shared gateway. In-flight connections get a 5-second grace window, so a held chat session or a stuck request cannot hang the shutdown. The interface listens on an OS-assigned loopback port, so another program holding a port can never block startup. - -The Workshop also keeps working when parts of its environment fail. After boot, if a local gateway exits, the application keeps the interface open while it looks for a validated replacement or relaunches the installed sibling with bounded backoff. A replacement is published only after its process identity, health response, and bearer key all validate, and the server switches its clients and credentials together. The same relaunch loop is how the Workshop restarts its supervised gateway on purpose: picking a profile from the Model menu persists the selection and then asks the gateway to shut down, and the relaunched sibling boots into the new profile. Explicitly configured gateways on another machine are never launched, supervised, or stopped by the Workshop. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. - -## The gateway configuration - -The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and a sibling `gateway.state.toml` selecting the generated `default` profile. The generated catalog, profiles, and global settings all live in that one editable config file; the state file holds only the profile selection, which the gateway reads once at boot. - -The generated config is a single editable TOML file with a header that invites edits. Two properties of the generated file are worth knowing: - -- The gateway is secured with a freshly generated random bearer key, so no two installs share a key. -- The gateway listens on the loopback address only, on an OS-assigned port. It is not reachable from other machines, and the Workshop learns the port from the gateway discovery file the gateway writes. - -A `gateway.toml` left over from an older version may declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. - -Voice uses the same separation. The gateway owns speech models, worker lifecycle, batch transcription, and the generic Realtime endpoint. The Workshop server contributes only an authenticated same-origin relay, while the browser UI owns microphone capture and transcript presentation. - -At run time the gateway also downloads the pinned voice runtime matched to your machine (CUDA on Windows, Metal on Apple Silicon, CPU on the other supported targets), plus the managed `llama-server`. You make no build-time choices for this. - -## The Workshop configuration - -You configure the Workshop through a TOML file named `workshop.toml`. The application searches three places in order: beside the executable, the current directory, and `~/.promptforge/workshop.toml`. The first file found wins. Every field is optional and the defaults are built in. With no file anywhere, the application keeps its state in `~/.promptforge/` and attaches to the gateway through its gateway discovery file. The application never writes the file, and the standalone server's `workbench.toml` fallback does not apply to it. - -The keys you are most likely to set: - -- `gateway.base_url` points the Workshop at a PromptForge gateway the gateway discovery file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its gateway discovery file or launches the sibling `promptforge-gateway`. A Workshop-only install has no sibling, so with neither a running gateway nor an explicit value, startup fails with an error that names both remedies. -- `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. -- `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. -- `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. -- `agents.path` chooses which directory of `.md` agent prompts is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. - -String values support `${VAR}` environment interpolation, so you can keep secrets out of the file. A literal dollar sign is written `$$`. An unset variable interpolates to the empty string instead of failing startup. - -The configuration is strict about mistakes, so you find out about problems immediately. A config without a `[gateway]` section fails to load. Unknown keys or sections are a startup error, such as a leftover `[voice]` section from an older version. Error messages name the offending file, and a malformed `${...}` interpolation gives a clear error. A browser launch failure, by contrast, is only logged as a warning; it never stops the server. - -## Working with your operating system - -The Workshop is a desktop citizen, not just a web page in a frame. - -You can drag files from your operating system and drop them into the application to attach them. You can open native file and folder picker dialogs from the Workshop. When you click a link to an external website, it opens in your system browser while the Workshop window stays on its own page. Links between pages served by the Workshop itself load inside the application window. - -One protection is worth understanding early: a link to any other local server, even one on the same port spelled `localhost` or `[::1]`, opens in the system browser. No other program on your machine gets the application's desktop features. - -## Safety and limits - -The Workshop is built so that only you, on your own machine, can reach it. - -The window loads its interface only from the local machine, never from a remote address. The Workshop refuses any request a browser marks as coming from another website, and it only answers requests addressed to a loopback host. Requests that change things must declare a JSON body. The live socket for chat only upgrades for the Workshop's own loopback origin or a native client. - -Nothing hangs forever. A stalled request is answered with a timeout error instead of freezing: ordinary routes give up after 10 seconds, and routes that relay a call to the gateway allow up to 35 seconds so a stalled gateway surfaces as a meaningful failure. Live socket sessions are never cut off by a request deadline. A gateway that is down or wedged fails fast in the interface: connections give up after 5 seconds and ordinary requests after 30 seconds. - -Startup also cleans up after previous runs. Leftover temporary files in the state directory are swept away on boot, so a crash during a previous save never leaves residue that affects the next launch. - -You now know what the application is, how it starts, and what it connects to. The next chapter opens the window and walks through its regions. - ---- - -# The Workbench - -You know how the Workshop starts and what its window is. This chapter teaches you how that window is organized: the regions it is divided into, the panels that live in those regions, and how to arrange them to fit the way you work. Everything you do in the Workshop happens inside a panel, so learning the layout once pays off in every later chapter. - -## The three zones - -The Workshop window is a dock area divided into three named zones, rendered in the Cursor Dark visual theme: - -- The left zone holds the workspace tree. -- The main zone holds document editors. -- The right zone holds the agent session. - -Each kind of panel has a default zone it opens in until you move it. The Workshop tree opens on the left, editors open in the main zone, and the agent session opens on the right. On a fresh start you see two panels: the Workshop tree docked on the left, titled "Workshop", and the Agent Session panel docked on the right. The main zone stays empty until you open a document. - -Below the dock area, a permanent full-width status bar runs along the bottom of the window. It is not part of the dock and is never saved as part of the layout. - -## The title bar - -Across the top of the window sits a custom title bar. It shows the PromptForge program icon, holds the five application menus (File, Edit, Model, Window, Help), and leaves an empty center region you can grab. On Windows this bar replaces the native window frame; macOS and Linux keep their decorated windows. The bar is always shown, even when you run the Workshop in a plain browser, because the application menus live there. - -To operate the window from the title bar: - -- Drag the empty center region with the primary mouse button to move the window. -- Double-click the same region to toggle between maximized and restored. -- Click the Minimize, Maximize, or Close control at the right end to operate the window. - -The controls appear in the Windows-standard order: Minimize, Maximize, Close. The maximize control swaps its glyph and label between "Maximize" and "Restore" to match the window's current state, including changes made by Windows Snap or by drag-resizing. The window reopens at its previous size and position on the next launch. The native window controls appear only in the desktop application. In a plain browser the control cluster is hidden, because there is no native window for the commands to act on; the menus still work. - -## Zooming the interface - -You can scale the whole interface to a comfortable size. Zoom applies uniformly to the whole window, so the chat, the editor, and every other surface scale together. - -- Press Ctrl+= to zoom in one step. Ctrl+Shift+= also zooms in. -- Press Ctrl+- to zoom out one step. -- Press Ctrl+0 to reset to 100%. - -Zoom changes in fixed steps of 10 percent, clamped between 50% and 200%. Your chosen level persists across sessions and is re-applied on every boot. A missing, corrupt, or out-of-range saved value leaves the default 100% in place. Zoom keeps working even when the saved value cannot be read or written; only the persistence is skipped. In a plain browser, zoom uses CSS zoom instead of native window zoom. - -## Panels - -A panel is one unit of content in the dock: the Workshop tree, an editor, an agent session, or the Gateway Config panel. Every panel renders a normal chip tab, so tabs are always visible even when a panel is alone in its group. - -A few rules govern how panels open: - -- Reopening a panel that is already open brings it to focus instead of opening a duplicate. -- Each open document gets its own editor tab keyed by its file path. The same file never opens twice. -- Each agent session gets its own panel keyed by its instance id, so multiple agent sessions can be open side by side. -- Panel kinds other than editors and agent sessions are singletons. Only one of each can be open at a time. - -Editor tabs are titled with the file's base name rather than its full path. Panel tabs update their displayed title when the panel's title changes. If an unknown panel is ever requested, you see a labelled placeholder instead of a broken dock. - -You can close an Agent Session tab with the close button on the tab. Right-clicking an Agent Session tab opens a context menu with "Close" and "Close Others" actions. - -- Press Ctrl+B to close the Workshop tree panel. Press Ctrl+B again to reopen it. - -## Rearranging the layout - -The workbench is never locked. You can drag panels to rearrange the layout at any time. - -When you move a panel to another zone, the application remembers that choice and reopens the panel in your chosen zone next time. Moving a panel back to its default zone clears the remembered override, so the panel follows its type's normal placement again. - -Closing or dragging away a zone's last panel leaves the zone in place, empty, at its current size. The next panel opened into that zone fills it, so the layout keeps its familiar shape. - -## Layout persistence - -The panel layout persists across sessions. Layout changes save automatically shortly after you move, resize, open, or close panels. There is no manual save step. - -If the saved layout is missing, corrupt, or from an older version of the application, the Workshop discards it and boots the known-good default layout: the Workshop tree anchored left and the agent session open right. You can never lose the Workshop tree or the agent session. Both panels are restored on every boot even if a stale saved layout dropped them, and the Workshop tree's tab has no close button. - -A few small behaviors keep the workbench predictable. Drag-and-drop of panels inside the application always works, because the application avoids registering an OS-level drop target that would break in-page dragging. The browser's native right-click context menu is suppressed inside the application, so right-clicks always produce Workshop menus. - ---- - -# Menus and Commands - -You know the window's regions and panels. This chapter teaches you the command surface that sits on top of them: the five menus in the title bar, the keyboard shortcuts, and how menus behave. Once you know where the commands live, every later chapter can simply name a command and you will know where to find it. - -## The five menus - -The title bar has five menus: File, Edit, Model, Window, and Help. Click a menu button to open its popover. Here is what each menu holds. - -The File menu: - -- New Agent starts a fresh agent session; it opens or focuses the agent-session panel. New Agent is the only new-conversation command. There is no New Chat. -- Open Workspace from File..., Save Workspace As..., and Duplicate Workspace... manage the `.pfwork` workspace file; Add Folder to Workspace... grants a folder. The Workspace Files chapter covers them. -- Close Window closes the window, also with Alt+F4. - -The Edit menu runs Undo, Redo, Cut, Copy, Paste, and Select All with the standard shortcuts Ctrl+Z, Ctrl+Y, Ctrl+X, Ctrl+C, Ctrl+V, and Ctrl+A. After an Edit command runs, focus returns to the field that had it. - -The Window menu: - -- Workshop Panel toggles the Workshop panel tree, also with Ctrl+B. -- Gateway Config opens or focuses the gateway configuration panel. It sits directly after Workshop Panel. -- New Agent opens or focuses the agent-session panel. It sits directly after Gateway Config. -- Zoom In, Zoom Out, and Reset Zoom zoom the interface, with shortcuts Ctrl+=, Ctrl+-, and Ctrl+0. Ctrl+Shift+= also zooms in. -- Minimize and Maximize/Restore operate the window. These menu commands do exactly what the visible title bar buttons do. - -The Model menu lists every catalog model as a checkable radio row with the selected one checked. Each model's description appears as a tooltip on its row. When the catalog is empty, the Model menu shows a disabled "No models available" row. A Profiles section at the bottom of the Model menu selects the gateway profile; it appears whenever the gateway defines at least one profile, lists "No profile" first and then every profile, and checks the active one. The Models and Profiles chapter covers this menu in depth. - -Help > About PromptForge opens the About dialog, which also shows the desktop update state. The Updates and Configuration chapter covers it. - -## Keyboard shortcuts - -Beyond the menu shortcuts, the application binds a small fixed set of keys: - -- Ctrl+S saves the active editor. The shortcut does nothing when no editor is active. -- Ctrl+W closes the active editor and prompts when there are unsaved changes. -- Ctrl+B toggles the Workshop tree panel open and closed. -- Ctrl+Tab cycles through the open editors and Ctrl+Shift+Tab cycles in reverse, wrapping around at the ends. -- Ctrl+Shift+F opens or activates the Workshop tree and moves keyboard focus into it. - -The bindings are fixed. You cannot customize them, and there are no multi-key chords. Only plain Ctrl combinations are bound; combinations with Alt or Meta are left untouched. Unbound key combinations fall through to the browser and the editor, so typing, selection, clipboard, undo/redo, and in-file find keep their normal behavior. Inside the desktop application the browser's built-in shortcuts are disabled, so the application's own key handling never races them. - -## How menus behave - -Menus in the Workshop follow the desktop conventions you already know, with a few details worth learning once. - -Edit menu commands are enabled only when an editable element (a text input, textarea, or contenteditable element) holds focus. They act on the element that was focused before the menu opened. A disabled command cannot run and does not close the menu. - -You can navigate open menus with the keyboard. ArrowDown and ArrowUp move between rows with wraparound. ArrowRight and ArrowLeft switch menus. Enter runs the focused row. Escape closes the menu and returns focus to its button. While any menu is open, hovering another menu button switches to it. Hovering alone opens nothing when no menu is open. An open menu closes when you click anywhere outside it or when the window loses focus. - -Menu rows show the label on the left and the shortcut hint on the right in muted, smaller text. Disabled rows are muted and do not react to hover. Thin separator lines group related rows. Checkable rows keep a fixed-width check column so labels stay aligned. - -The Model menu is live. It rebuilds its rows from the catalog every time it opens, and again whenever a workbench snapshot arrives while it stays open, so check marks move without reopening the menu. Clicking a model row sends the selection, and the check mark moves only when the server confirms the new selection. Keyboard focus survives a live rebuild of the open menu: focus stays on the equivalent row and falls back to the first row if the focused row disappears. While a profile selection is in progress, every Model menu row disables, and the target profile shows a pending "..." mark in place of its check until the server confirms. The still-active profile keeps its checkmark. - -The same menus work in a plain browser. Only the native window commands (Minimize, Maximize/Restore, Close Window) do nothing there, because only the desktop bridge can run them. - -## Context menus - -Some panels, such as the Workshop tree, open a context menu of action items from a trigger element. Context menus share one set of behaviors: - -- Activating the same trigger a second time closes the menu. At most one menu is open at a time. -- Items can show an icon next to the label, a check mark for the selected choice, and a danger style for destructive actions. -- A right-click invocation opens the menu at the pointer position. The menu flips above the trigger or right-aligns when it would overflow the window. -- Escape dismisses the menu and returns focus to the trigger. ArrowUp, ArrowDown, Home, and End move through the items. Tab closes the menu. -- Activating an item runs its action and closes the menu immediately. -- The trigger announces its expanded state to assistive technology. - -Panels and chat use one consistent set of small inline outline icons. The trash icon deletes an item, the folder-plus icon creates a folder, the microphone icon starts voice input, and the send icon sends the message. The icons are sized 15 or 16 pixels and drawn in the surrounding text color, so they stay legible across themes. - -You can now reach every command the application offers. The next chapter teaches the status bar, which is how the application reports what it is doing while you work. - ---- - -# The Status Bar - -You know the window, its panels, and its menus. This chapter teaches you the status bar, the permanent full-width footer at the bottom of the window. The status bar is how the Workshop tells you what it is doing whenever something takes noticeable time: startup phases, gateway round trips, dictation and transcription, and model downloads. Learning to read it means you always know whether the application is idle, working, or stuck, and why. - -## Reading the bar - -The status bar shows a short label as its text. When startup finishes and nothing is happening, the resting state reads "Ready". Hover over the bar to see a longer description of the current status as a tooltip. Failures appear as errors, visually distinct from ordinary status updates: the text switches to red. Long status text truncates with an ellipsis instead of overflowing the bar, and numbers use fixed-width digits so values do not jitter as they change. The bar announces its updates to assistive technology. - -During startup you see a "Connecting to gateway" update that names the gateway base URL being contacted. When startup finishes and nothing is happening, the bar returns to "Ready". - -## The right slot: progress bar and lights - -The right end of the bar holds one of two things, never both at once. While an operation reports progress, a progress bar fills the slot. Otherwise the slot holds the indicator lights. The slot swaps as a unit. - -When an activity can report how far along it is, you see determinate progress: units completed so far against units expected in total. A model download, for example, shows its label, the file name as the description, and a current-of-total count. Gateway-side work such as model downloads and profile switches renders on the Workshop status bar through the same progress display as local operations. - -When no progress is showing, two small lights sit in the slot: - -- The activity LED pulses green while output tokens arrive and amber while a model turn is thinking. It also tells gateway traffic (green) from dictation activity (amber). Green wins when both coincide. The thinking LED stays lit for the whole thinking period, not just a brief flash. Pulses fade in fast and decay slowly, so a stream of activity reads as one continuous glow. -- The recording LED lights up red while the microphone is recording. - -Both LEDs sit dark when the application is idle. The recording LED sits one LED-width to the left of the activity LED. When a chat is aborted, the activity LED goes dark immediately, even though no final server status arrives for that chat. When an error status arrives, the activity LED goes dark at once and does not light again on its own. - -## Gateway connectivity - -The status bar is where you watch the gateway connection. The Workshop probes the gateway's health endpoint and treats a transport failure, a slow answer, or a non-success status as unreachable. Each probe is bounded at 2 seconds. The Workshop opens and works normally whether or not the gateway has ever answered; only gateway calls wait. - -- When the gateway stops answering, the bar announces "Gateway unreachable" with the explanation "the gateway does not answer its health probe". Calls to the gateway are not attempted while it is down. -- When the gateway returns, the bar announces "Connected to gateway". The model catalog refreshes by itself, because a gateway that was down may serve a different catalog. - -You are notified only when reachability changes. A steady state never re-announces itself. While the gateway is reachable, the Workshop checks its health every 5 seconds, so a recovery is detected within about 5 seconds. While the gateway is down, retries use a jittered, escalating delay: starting at about 5 seconds, doubling per attempt, and never exceeding one minute. A gateway that accepts connections but never answers keeps the escalated schedule, because only useful work resets it. After roughly a full day of continuous outage, the Workshop stops probing and shows "Gateway reconnect stopped" with the advice "the reconnect budget is exhausted; restart the workshop to retry". - -When a gateway call fails in transport, you see the gateway's own summary line as the error message. Every failure you hit surfaces as a short plain-language message near the status text. Production builds show no internal detail; debug builds append the underlying cause chain after the message. - -Gateway progress appears on the status bar only while the gateway is reachable. When the gateway becomes unreachable the progress entry disappears instead of going stale. After a reconnect the progress resumes with a single fresh entry. - -## Live delivery and reconnection - -The application holds one persistent live connection to the server. Status updates, the model catalog, and menu state arrive in the interface as they happen, with no manual refresh. The interface boots with its status bar, catalog, and menu state already populated; there are no loading round trips. Snapshots are pushed on every connect and resent on reconnect, and the newest status update is retained and replayed to late-connecting sessions, so if you reconnect you immediately see the current status. A late-joining session gets a status line recomputed from the current probe, not a stale retained announcement; if real work is in progress, such as a model download or a chat, that work's status frame replays as-is. - -When the connection to the server drops, the status bar returns to a neutral "Reconnecting..." state. The application reconnects automatically: retries start at a one-second wait and double on each failure, capped at 30 seconds. The application connects over a secure socket automatically when the page is served over HTTPS, and a plain socket otherwise. - -Locally-originated messages such as dictation errors appear in the status bar too, and are replaced by the next server status update. - -## Why the bar stays calm - -The status bar is engineered not to flicker, so what you see is always meaningful: - -- An operation that finishes in under one second never disturbs the status bar. -- Once the progress indicator appears, it stays visible for at least half a second. -- The bar never steps backward, even when a new operation starts while the previous bar is still on screen. Back-to-back operations share one continuous bar. -- When an operation has several sub-tasks, the bar shows a single weighted aggregate and the label names the sub-task that is still unfinished. -- Internal instrumentation never reaches the screen. Debug-level updates never change the status bar text or tooltip, though they still pulse the activity LED; only info and error severities are displayed. -- If updates arrive faster than the interface can draw them, the display skips ahead to the newest snapshot instead of lagging behind. -- Updates that arrive while the application is still starting are held and replayed in arrival order once the interface is ready. The holding queue is bounded at 32 pushes with the oldest dropped when full, and if the connection drops before the interface is ready, the queued messages are cleared. - -You can now read everything the application tells you about its state. The next chapter teaches you to choose what the application runs: models and profiles. - ---- - -# Models and Profiles - -You can read the status bar, so you can tell when the application is ready. This chapter teaches you to choose what the application runs: the model that answers your chats, and the profile that decides which models exist. By the end you will be able to pick a model, understand when chat is ready, and switch profiles with confidence. - -## The catalog - -The Workshop does not invent its model list. The catalog comes from the configured gateway, which serves it at `GET /v1/models`. The Workshop relays the catalog verbatim, including upstream error bodies, so what you see matches the gateway's answer. Each model lists its id and owner, with an optional description. Each push replaces the previous list in full. - -Every connected session receives each catalog update, so all open sessions show the same current list. A session that connects later receives the current catalog immediately. The catalog also refreshes automatically every time the gateway comes back after an outage, because a gateway that was down may serve a different catalog. A boot-time catalog failure heals itself this way. A failed, declined, or malformed catalog answer is logged and skipped rather than pushed, so your pickers never lose a usable list. - -While the Workshop fetches the catalog, the status bar shows "Loading models...". When the gateway is known to be down, the request is refused immediately with the message "Gateway unreachable". A non-success answer shows "Gateway error: ". A failed connection shows "Connection lost" with the underlying detail. A successful fetch returns the status area to idle. - -## Picking a model - -You pick a model from the Model menu in the title bar. The menu lists every catalog model as a checkable radio row with the selected one checked, and each model's description appears as a tooltip on its row. When the catalog is empty, the menu shows a disabled "No models available" row. - -The agent toolbar offers a second way to pick: a pill-shaped button that displays the id of the currently selected model. To use it: - -1. Click the pill button. A dropdown menu opens listing every model in the catalog. -2. Click a model. It becomes the current model. - -When no model is selected, the pill shows the label "Select model". When the catalog is empty, the dropdown shows a single inert "No models available" row. Hovering the button shows the current model's description as a tooltip. - -One current model selection is shared by every Agent tab and the title-bar Model menu, so the chosen model stays consistent across the whole application. Your pick is sent to the server as a command, and the on-screen selection changes only when the server confirms it. The button label updates only after that confirmation, never optimistically on click. A catalog refresh never silently changes which model is selected, and selection indicators update only on a real change, so the Model menu and Agent tabs do not flicker when the server re-confirms the same model. Picking an unknown model id is refused with an error message, and the previous selection stays in place. - -If a refreshed catalog no longer contains the selected model, the Model menu clears the selection and chat becomes unavailable until you pick again. - -## When chat is ready - -Chat input is enabled only when all of these hold: the catalog has models, a model is selected, no profile switch is in flight, and the gateway is reachable. The server computes this readiness; the interface never derives it. - -On startup and after every reconnect, the application restores the remembered model for the active profile, falling back to the first catalog model when the remembered one is gone. A fresh boot against a live gateway lands ready to chat with no manual pick. While the gateway is unreachable, chat input stays disabled even with a model selected. Your chosen model survives the outage; only chat readiness flips, and the selection is still in place when the gateway returns. - -If a model selection cannot be sent because the connection is down, the status bar shows an error naming the model and the cause: "Could not select : the workshop socket is down". - -## Profiles - -A profile is a named checklist on the gateway that decides which local and speech models it loads at boot. Remote models are always available; the profile governs what runs on the gateway's own machine. The Workshop shows you the list of profiles the gateway offers and which profile is currently active, read from the gateway. You can see the Model menu's full state at a glance: every profile, the active profile, any profile selection in progress, and the selected model. A gateway without profile support shows an empty profile list instead of an error or stale names. - -The gateway loads its local models once, when it starts, so changing the profile means restarting the gateway. When the gateway is a sidecar the Workshop launched and supervises, the Workshop performs that restart for you. To select a profile: - -1. Open the Model menu. -2. Find the Profiles section at the bottom. It appears whenever the gateway defines at least one profile. "No profile" is the first entry, and the active profile is checked. -3. Click the profile you want, or "No profile" to run remote models only. - -The selection runs a sequence of up to three labeled stages shown in order with determinate counts: "Selecting profile..." (1 of 3), "Restarting gateway..." (2 of 3), "Loading models..." (3 of 3). The status bar names the profile being selected while progress is shown. The first stage persists the selection on the gateway. When the gateway is already running the chosen profile, the sequence stops there and the menu settles at once. Otherwise, for a supervised sidecar, the Workshop asks the gateway to shut down and waits up to 90 seconds for its relaunched replacement to come up serving the chosen profile; the replacement's boot then loads the profile's models, which can take minutes while weights load into VRAM. - -When the gateway is one you configured on another machine, the Workshop never stops it. The selection persists on that gateway and the status bar reads "Profile selected" with a notice that you must restart the gateway by hand to load it; the running profile stays active until you do. - -While a selection runs, the menu shows a pending state and chat input is disabled. Only one selection runs at a time; starting a second while one is in flight is refused with an error. - -When a selection completes, the application selects the model last used on that profile, or the first catalog model when none is remembered. Chat becomes ready again and the status bar returns to idle. When a selection fails, you see a "Profile switch failed" notification with the gateway's own error message; if the gateway still serves, the selected model and chat readiness are restored. A sidecar that was shut down and did not return in time reports "gateway did not return after restart", and the Workshop's supervisor keeps looking for it and repopulates the menu when it appears. After any selection that leaves a gateway serving, the profile list and model catalog are refreshed, so the menu reflects the gateway's real state. If the connection is down when you try to select, a local error appears on the status bar: "Could not switch to : the workshop socket is down". - -The application remembers the selected model per profile and restores it across restarts. The memory lives in a `workshop-state.json` file in the server's state directory. A missing, unreadable, or corrupt memory file never blocks startup; the application starts with no memory and selects the first catalog model. - -## The model cache - -You can trigger a download of a model blob into the gateway's cache and watch cumulative progress until the blob is ready or the download fails. When the requested blob is already cached, you get an immediate ready answer instead of a download. The cache feature is meaningful only in the standard local deployment, where the Workshop and the gateway run on the same machine and share the filesystem. - -Before the application receives its first state from the server, you see an empty workbench: no profiles, no active profile, no selected model, and chat gated off. Every server push refreshes the Model menu and chat gating, even when nothing changed, so the display never goes stale. - -You now have a model selected and chat ready. The next chapter teaches the chat surface itself. - ---- - -# The Chat Surface - -You have a model selected and chat is ready. This chapter teaches you the chat surface itself: how to send a prompt, how to read the transcript, and how to steer a session once it is running. Chat is the heart of the Workshop, and everything here builds directly on the Models and Profiles chapter. - -## Your first message - -The Agent Session panel on the right side of the window is where you talk to the selected model. Chat always runs as a live agent session, not a one-shot buffered request. Every reply streams through the open session, which opens instantly and stays open for the whole session. - -The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.md` prompt files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.md` file in the agents directory shadows the built-in one, so you can replace the default chat with your own prompt. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. - -To send your first message: - -1. Click into the input box at the bottom of the Agent Session panel. The placeholder reads "Plan, Build, / for skills, @ for context". -2. Type your message. -3. Press Enter. - -Enter sends the prompt. Shift+Enter inserts a newline without sending. If you use a CJK input method, an Enter that commits an IME composition never sends, so you can confirm candidates safely. - -Sending delivers exactly the text you typed, never trimmed. An empty box sends nothing. A failed send keeps the text for retry. A successful send clears the box. The box grows and shrinks with what you type, within a minimum and maximum height (about 36px to 200px), and scrolls past the maximum. - -The prompt box and send button enable only while the agent is asking for input. Otherwise the box is read-only and send is disabled. - -A push-to-talk microphone button sits beside the send button. It stays visible in every state, and when dictation cannot start, a click names the blocker on the status bar. The Voice Input chapter covers dictation. - -## Reading the transcript - -The session reads as a scrolling feed of rows, one row per transcript entry, with each kind of entry styled distinctly. The feed scrolls itself to the newest entry whenever it repaints. New rows are announced to assistive technology as they arrive; settled history is never rebuilt or re-announced during streaming. - -Your own messages appear under a muted "You" label as plain text, right-aligned as bubbles. Text you send is never interpreted as markup, so pasted or typed HTML cannot inject formatting or scripts. - -Agent replies render as formatted Markdown with a muted line above naming the model that produced the reply. Replies and reasoning that are still streaming are drawn with a visible pending style and a blinking caret at the live tail. While a reply streams, you see the answer text arrive chunk by chunk. The status bar shows "Running agent turn" while the agent thinks, "Streaming response..." while the reply streams, and "Ready" when the turn completes. The model's reasoning streams live on its own side channel, separate from the answer text, and appears in a collapsible block titled "Reasoning" or "Reasoning (model)". It stays open while it streams and collapses once it settles. - -Tool calls appear as collapsible cards with a clickable header. The header shows the tool's name (or a generic "Tool call" / "Tool calls" label), a count badge for multi-call batches, and a status dot. A card opens on its own while the call runs and closes when the result arrives. A card you opened by hand stays open. Each call's arguments render as syntax-highlighted JSON. The result appears as a preformatted block labeled with the id of the call it answers. A batch that cannot be parsed still renders as raw text instead of vanishing. - -Errors appear inline in the transcript with a visible "Error: " label, never by color alone. A message that could not be sent because the connection is down appears as a local notice: "The message was not sent: the agent socket is down." - -You can observe per-reply model metrics such as token usage and generation speed attached to the assistant's replies. The log records which model produced each entry, per-reply token usage (prompt, completion, cached, and reasoning tokens), and per-reply timings (time to first token, generation speed in tokens per second, and end-to-end latency). - -## Mentions and the composer extras - -You can mention files with @ and pick them from a typeahead popup that opens next to the cursor. The list filters its entries by case-insensitive substring match against the text typed after the @. While the popup is open, ArrowUp and ArrowDown move the highlight through the suggestion list with wraparound, and Enter inserts the highlighted item instead of sending the message. Clicking a row inserts that file without moving focus out of the editor. Escape dismisses the popup. A query with no matches hides the popup. - -Each referenced file appears as an inline pill inside the prompt editor, with a file icon and the file's label. The pill behaves as a single unit, not editable text. Clicking the X button on the pill removes the whole mention. The suggestion list currently offers three canned file entries (README.md, src/main.ts, Cargo.toml) as a stand-in until the workspace file index exists. - -## The agent toolbar - -A toolbar above the input bar groups the mode chip, the model picker, and a context-usage ring in one row. - -The mode chip lets you choose among five agent interaction modes: Agent, Plan, Debug, Multitask, and Ask. The chip starts in Agent mode. Click it and pick a mode; the chip's icon and label update immediately and the change is announced to the rest of the application. Re-picking the current mode produces no change and no event. - -The context ring is a small 16px gauge showing how much of the model's context window the current session has used. The arc fills in proportion to the percentage used. The ring reads 0 percent until real usage data exists, and readings are clamped between 0 and 100. Assistive technology hears it announced as "Context usage" with the current percentage. - -The model picker in the toolbar is the pill button from the Models and Profiles chapter; it shares the same selection as the title-bar Model menu. - -## Sessions that survive - -A session is more durable than its connection. Agent sessions survive a dropped connection. The socket reconnects on its own and reattaches to the same session. The server replays the session's event log from the beginning, and a per-client cursor drops duplicates, so you see each event exactly once and in order. Every unanswered prompt is re-announced in the order it was asked. - -You can also attach to an already running session by its session id, resuming where that session stands. Sessions outlive sockets. - -Your run history is recorded as an event log the Workshop keeps in memory for the life of the session: every reconnect replays it from the beginning in its original ordering, and new events append to the same record. The log does not survive an application restart; a durable, resumable run history arrives with the harness's run log. - -The chat shows both sides of the conversation back to the model each turn, rebuilding the message list from the recorded user and agent messages. The conversation accumulates turn over turn, and what you typed reaches the model byte-exact, with newlines, quotes, and unicode preserved. Selecting another model takes effect on the next turn, and each reply is attributed to the model that produced it. A relaunch over retained history resumes the conversation exactly where it stood. - -## Cancelling and failing gracefully - -You can cancel a running turn. Cancellation is a stop reason, never an error. Pending prompts close as cancelled, and the relaunched agent returns to waiting over its retained history. The chat is immediately usable again. - -The chat survives a transport failure: the session surfaces the failure and returns to waiting for the next message. When a single model round fails, you see an error message naming the agent; the agent survives the failure and returns to waiting for input. When a run fails outright, you see an "Agent failed" notification with the error text. If stream chunks are dropped on a slow connection, the completed transcript event repairs the text. Late chunks that arrive after a cancel are discarded, so you never see duplicate or orphaned streaming text. - -Closing a session ends the agent run for good with no relaunch. The saved transcript stays on disk. - -## When the agent asks you a question - -Some agent programs pause and ask for input. When an agent program needs input, the Workshop presents a prompt in the session's input box and waits for you to type an answer. The input box stays pinned to that request until it is answered. Each prompt accepts exactly one answer, and your typed answer reaches the agent byte-exact as typed, preserving newlines, quotes, braces, backslashes, and non-ASCII characters. - -Cancelling a turn while a prompt is pending dismisses that prompt, so the input box is never left stuck on a dead question. A prompt that dies unresolved is explicitly cancelled on screen, never silently abandoned. A pending prompt survives a lost connection: on reconnect, every unanswered prompt is shown again in the order it was asked, and a stale prompt vanishes. You can answer a prompt that was asked while the socket was down; the answer is delivered normally once the session is back. - -## The agent panel - -You work with one agent session per panel. Opening a new panel starts a fresh session. Closing the panel ends the session and releases its connection. The panel automatically launches the "chat" agent when the server reports available agents, falling back to the first available agent when "chat" is not present. You can open additional agent sessions from the Agents menu (New Agent) or the Workshop menu (Open Agent Session). Each new session gets its own panel in the right zone. Agent windows are modal: one window serves one session at a time, and trying to open a second session in the same window is refused with an explanation. - -While the panel has no active session, you see a launchable-agent menu labeled "Agents" for assistive technology, with the lead line "Launch an agent to start a session." There is one button per discovered agent, labeled with the agent's name; clicking it launches a session. When no agents are discovered, you see the message "No agents discovered." After you launch an agent, every launch button disables until the server answers, preventing a double launch. A refused launch shows the server's error message and re-enables the buttons for another try. When the agent socket is down, you see "The agent socket is down; it reconnects by itself. Try again shortly." and no launch is sent. The whole menu disappears once the session acknowledgment arrives, replaced by the session surface. Starting or reattaching to a session clears any pending input prompt; a same-session reattach keeps the transcript, and a new session starts the transcript fresh. - -## What chat content can contain - -Model-authored chat content renders as Markdown: headings, bold, italic, inline code, lists, blockquotes, tables, links, and images. Fenced code blocks are syntax-highlighted in the application's dark theme in twelve languages: bash, css, html, javascript, json, lua, markdown, python, rust, toml, typescript, and yaml. A code block in an unrecognized language renders as a plain code block, and if highlighting fails to initialize, code blocks still render as plain preformatted text. - -You can size an image embedded in chat content by appending a ` =WxH` or ` =Wx` dimension suffix to the image source. Links show a tooltip on hover that defaults to the link URL. - -Model-authored markup is sanitized before display. Scripts, inline event handlers, and dangerous URLs such as javascript: links are stripped. Tool results render as plain text, so markup inside a result can never execute. - -Launching an agent is refused when the gateway settings cannot produce a usable model client. The error tells you to check `gateway.base_url` and `gateway.api_key` in `workshop.toml`. The rest of the Workshop keeps serving. - -You can now hold a full conversation, steer it, and recover from anything that interrupts it. The next chapter teaches you to speak your prompts instead of typing them. - ---- - -# Voice Input - -You can type prompts into the chat surface. This chapter teaches you to speak them instead. Dictation uses a push-to-talk microphone button beside the send button, and the transcript lands in the prompt exactly as if you had typed it. If voice is not available on your machine, this chapter also teaches you how to tell and why. - -The desktop application keeps the microphone connection same-origin: its Workshop server relays `/v1/realtime` to the gateway's fixed `/v1/realtime?intent=transcription` target. The relay authenticates upstream but never parses speech payloads or owns speech state. The gateway credential stays in the server process and is never exposed to the webview. - -## Dictating a prompt - -To dictate into the chat input: - -1. Click the microphone button beside the send button. Its tooltip reads "Push to talk". -2. Speak your message. -3. Click the microphone button again to stop. The tooltip now reads "Stop recording". - -While you speak, you see one evolving transcript. Each revision replaces the previous hypothesis in the same editor range, so revised phrases do not accumulate. One continuous recording remains one item and one take for arbitrary duration, with one commit when you stop and one authoritative completion. The gateway compacts finalized audio while retaining at most 30 seconds of resident, queued, and actively decoding PCM, so recording duration is not capped at 30 seconds. When you stop, the authoritative completion replaces the hypothesis and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; other committed takes may finish independently. - -Dictation splices the transcript into the current selection, behaving like typing at the cursor. Newlines in the transcript become line breaks. Dictating over a selection replaces the selection outright. Consecutive takes compose, because each take captures the cursor position fresh at record start. You never see stale transcription text from a previous take: takes are numbered per connection, and frames from a superseded take are discarded. - -While a take records, the input locks against typing and shows a recording ring, so the insertion geometry cannot be disturbed. You can still press Enter to send what the box shows. Sending during a take sends the visible text, interim transcript included, and discards the take. Discarding a live take, for example by closing the tab or starting a new session, restores the pre-take text and unlocks the input. An empty take tells you no speech was detected, with the number of captured audio frames. - -The status bar shows a red recording LED while the microphone is capturing, and the mic button shows a solid danger-colored fill with a matching ring while recording. - -## When the mic does nothing - -The mic stays visible and clickable in every state. Dictation is gated by the agent's pending input wait. Clicking it at another time names the blocker on the status bar: "The agent isn't asking for input; the mic opens when it does." The first eligible click may connect the Realtime session and ask you to try again in a moment; the session then reconnects with bounded backoff after a dropped connection. - -Failures during dictation are named too. Microphone permission denial or capture failure is named on the status bar. A dropped dictation connection is reported on the status bar, including drops before the final transcript lands. Arbitrary-duration capture requires the accurate transcription worker to keep pace on average. If it falls behind until all 30 retained seconds are owned, Workshop stops capture, preserves the already accepted visible transcript, flushes the microphone, and commits the still-valid input without clearing or rolling it back. Other server errors retain the ordinary failure behavior and restore the pre-take text. A browser without microphone, audio, or WebSocket support is told "Dictation is not available in this browser." - -Under the hood, the Workshop serves a payload-opaque Realtime socket at `/v1/realtime`. Browser capture applies echo cancellation and noise suppression, resamples to 24 kHz, converts samples to signed little-endian PCM16, and sends canonical Base64 audio appends. Stop flushes the capture worklet before committing the input buffer, so the final short block is included. - -## Microphone permission on each platform - -Each platform handles the microphone grant differently: - -- On Windows, the application grants the microphone permission automatically. You are never interrupted by a microphone permission prompt. Every other permission kind keeps the normal browser behavior. -- On Linux, the application turns on media capture in its webview and grants microphone and camera capture requests automatically. Other permission requests, such as notifications and geolocation, remain denied by default. -- On macOS, the application holds the audio-input entitlement that permits microphone capture for local dictation. The system permission prompt explains: "PromptForge uses the microphone you select for local voice dictation." - -If microphone setup fails at startup, you can keep working in the application and only voice input stays unavailable. - -## Voice configuration - -Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[stt]` section of the gateway boot config: - -```` -[stt] -window_seconds = 15 -interval_ms = 500 -```` - -You can add a `vocabulary` list of domain terms to bias recognition: - -```` -vocabulary = ["MCP", "GGUF", "Lua"] -```` - -Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and the gateway saves only `[stt]`. - -First run provisions two recommended speech-to-text models: `whisper-base-en` for interim results and `whisper-small-en` for final results. They download from Hugging Face with pinned sha256 checksums and stated VRAM requirements of 1.0 GB and 2.0 GB. The generated configuration boots the gateway into a profile named `default` that activates both provisioned whisper models. - -You can now speak or type your prompts. The next chapter teaches you to give the agent files to work on by granting folders to the workspace. - ---- - -# The Workspace - -You can converse with an agent. This chapter teaches you to give the agent files to work on. The Workshop never roams your disk on its own: you grant it access to specific folders, and the Workshop tree panel on the left shows you exactly what you have granted. By the end you will know how to grant folders, browse them, and take access away. - -## Granting a folder - -The fastest way to grant a folder is drag and drop. In the desktop application, drop a folder onto the window and it becomes a workspace root. Dropping a single file grants the application access to the file's parent folder instead of just the file. On Windows you can drop files or folders straight from Explorer, and the application receives the real OS paths of the dropped items. Each successfully dropped path is confirmed on the status bar with a message naming the path. When one dropped path cannot be opened, the status bar shows an error for that path and the remaining dropped paths are still added. - -- Dropping a file onto the window never by itself gives the application access to the file's bytes. The page grants each dropped path through the workspace API first. -- Dropping files onto the window never navigates the page away from your session. In-page drags such as panel tab drags keep their normal behavior; only drags of OS files are intercepted. - -You can also add a folder without dragging. Click the header "+" button labeled "Add Folder to Workspace...", or right-click empty space in the panel and choose the same item. In the desktop application you pick a folder through the native folder picker. In a plain browser you type the path into an "Add Folder to Workspace" dialog. The drop-to-grant feature is desktop only; in a plain browser, dropping files keeps the normal HTML drag/drop behavior of reading file contents and never grants workspace access. - -The outcome of adding or removing a folder is always announced on the status bar, as a success or an error. Grants registered through any session are visible to every open session immediately, and open panels such as the Workshop tree refresh automatically to show new grants. - -Folder grants are held in memory. Until you save a workspace they last only for the current session; once a workspace file is open, every grant and removal is written into it as it happens. The next chapter covers workspace files. - -## Browsing the tree - -The Workshop tree lists the granted workspace roots and browses one directory at a time. When no folder is selected, the panel shows the granted folders as the top level of the tree. When no folders are granted, you see the hint "Drop a folder onto the window to browse it here." - -Each granted folder row shows the folder's own name rather than the full path, with the full path available as the row tooltip. A drive root shows its path. Directory listings show folders before files, each group sorted alphabetically by name. Each entry includes its name, full path, kind (directory or file), byte size, and modification time. Browsing is paths only: the tree lists names and never reads file contents. - -To browse: - -1. Click a directory's chevron to expand it. Click again to collapse it. -2. Click a file to open it in the editor zone. The Editor chapter covers what happens next. - -Your expansion state and fetched listings persist for the session. Closing and reopening the Workshop panel restores the tree as it was left. A directory load failure appears as an error row inside the affected list, exposed to assistive technology as an alert. Pressing Ctrl+Shift+F activates the file tree and moves keyboard focus into it, even while the tree is empty. - -A granted folder that has been deleted from disk still appears in the panel, flagged as missing so you can clean it up: a struck-through name in the danger color plus a "missing" text label. - -## Confined access - -The grant boundary is enforced, not cosmetic. You cannot open, list, or save any path outside the granted folders; the application refuses with a "path is outside every granted root" error. - -The refusal messages are precise about what went wrong: - -- Paths containing `..` are refused before any disk access, however they were encoded, with "path contains a forbidden component". On Windows, file names containing a colon are refused. -- A path that is not a regular file fails with "path is not a file". -- A tree listing for something that is not a directory fails with "path is not a directory". -- A missing path reports "path does not exist". - -Nested grants are independent. Revoking a parent folder's grant leaves a separately granted child intact, and files under the child stay reachable. - -Dropped paths keep their native spelling, including backslashes, spaces, and Unicode characters. Any Windows verbatim prefix is removed. On older WebView2 runtimes, Explorer drops degrade gracefully instead of failing the application. - -## Revoking a grant - -To take access away: - -1. Right-click the root row of the granted folder. -2. Choose "Remove from Workspace". - -Files under the removed folder lose access on their next operation. Removing an unknown root reports "path is not a granted root". A root deleted from disk stays removable, so you can always clean up a missing entry. - -You can now grant folders and browse them. The next chapter teaches workspace files, which remember those grants between launches. - ---- - -# Workspace Files - -You can grant folders and browse them. This chapter teaches you to keep that arrangement: a workspace file remembers your granted folders and your window layout, so they come back the next time you launch. By the end you will know how to save a workspace, open one, duplicate one, and what a workspace file does and does not hold. - -## What a workspace file is - -A workspace is a single file with the extension `.pfwork`. It is an ordinary file you can see in your file manager, copy, move, back up, and delete. Inside, it is a small embedded database; you never need to look inside it, but if you are curious, any Turso or SQLite inspector opens it. - -A workspace file holds your arrangement of that workspace: - -- The granted folders, in the order you granted them. The folders themselves are not copied; the file remembers their paths. -- The window's size, position, and maximized state. -- The panel layout, which folders are expanded in the tree, and the list of editors you have closed (for Reopen Closed Editor). - -That is all. Your files stay where they are on disk, and your agent sessions are unaffected. The workspace is a bag of preferences, not a project archive. The "What persists" section below spells out what lives in the workspace and what follows you between workspaces. - -The workspace commands use native file dialogs, so they are desktop only. In a plain browser the three File menu rows are disabled. - -## Ephemeral until saved - -When you launch the Workshop for the first time, or open no workspace, you are working in an ephemeral workspace. Everything works exactly as in the previous chapter, and nothing is remembered: folder grants and the window layout last only for the current session. This is the state the previous chapter described when it said grants are held in memory. - -To start remembering, save the workspace once. From then on there is nothing more to save. - -## Saving a workspace - -1. Open the File menu. -2. Choose "Save Workspace As...". -3. In the save dialog, pick a folder and a name. The dialog suggests `Untitled.pfwork` for an ephemeral workspace and the current workspace's name otherwise. The `.pfwork` extension is added for you if you leave it off. - -The Workshop creates exactly one file at the path you chose. It does not create a folder around it. The current grants and window layout are written into it, the Workshop switches to it, and the file appears under File > Open Recent. - -While the Workshop has a workspace open, a second file named `Name.pfwork-wal` may sit beside it. It is the database's write-ahead log, holding the most recent changes until they are folded into the workspace file, which happens when you quit. It is not a stray: leave it alone while the Workshop is running. If you want to copy or back up a workspace, quit first so the workspace is one complete file. - -From now on every change is saved as it happens. Grant a folder and it lands in the file; remove one and it leaves the file; move or resize the window and the new geometry is saved a moment after you stop dragging, and once more when you close the window. There is no unsaved state, no dirty marker, and no Save command, because the file is a live mirror of what you see. - -If you save while a workspace is already open, you get a second file with the same grants and layout and the Workshop switches to the new one. The original stays where it is, unchanged from that point on. - -## Reopening at launch - -The Workshop remembers which workspace was open when you last quit. When you launch it again, that workspace is reopened before the window appears: your granted folders are back in the tree and the window opens at its saved size and position. - -If the file has been moved, deleted, or damaged since, the Workshop starts with an ephemeral workspace instead and notes the reason in its log. Launch never fails because of a workspace file. - -## Opening a workspace - -1. Open the File menu. -2. Choose "Open Workspace from File...". -3. Pick a `.pfwork` file in the file dialog. - -The file's grants replace your current grants entirely, the tree refreshes, and the window moves to the file's saved geometry. Opening a workspace is the same trust gesture as dropping a folder onto the window: you are deliberately granting the Workshop access to the folders the file names, and every restored folder is visible in the tree. A granted folder that no longer exists on disk still appears, flagged as missing, so you can remove it. - -A file that is not a PromptForge workspace is refused with a message naming the file, and a workspace saved by a newer version of the Workshop is refused with the version it needs. In both cases nothing changes: your current grants stay, and the refused file is not touched. - -Recently opened and saved workspaces are listed under File > Open Recent in their own group above recently opened files. Choosing a workspace there opens it directly, with no file dialog, exactly as if you had picked it under "Open Workspace from File...". The same refusals apply: a damaged or newer-version file is declined with a message and your current workspace stays. - -## Duplicating a workspace - -1. Open the File menu. -2. Choose "Duplicate Workspace...". -3. Pick a folder and a name for the copy. - -The Workshop makes a complete, independent copy of the current workspace and switches to it. Changes you make afterwards go to the copy; the original is untouched, and vice versa. If no workspace file is open, there is nothing to copy, so Duplicate behaves exactly like Save Workspace As: a new file is created from the current grants and layout. - -Save Workspace As and Duplicate Workspace look alike today because a workspace is one file. They differ in what travels. Save As means "my preferences under a new name": only the workspace file is written. Duplicate means "the whole world comes along": in future versions, when a workspace has grown companion folders beside it (see below), Duplicate copies them too and Save As leaves them with the original. - -## Companion folders - -A workspace file may in future gain sibling folders beside it, created only when there is something to put in them: `agents/` for agent databases, `runs/` for saved runs, and so on. They are plain folders with plain names, so their relationship to the workspace file is self-evident in your file manager. Nothing in the current version creates them. - -Because siblings are named for their role rather than for the workspace, two `.pfwork` files in the same folder would share them. Keep one workspace per folder. The Workshop does not stop you from doing otherwise, but you will find the arrangement confusing later. - -## What persists - -The Workshop remembers your interface state in two buckets, split by whether the state belongs to a workspace or to you. - -The workspace bucket lives in the `.pfwork` file and comes back whenever that workspace is open: - -- The granted folders and the window geometry, as described above. -- The panel layout: which panels are open, where they sit, and their sizes. -- Which folders are expanded in the Workshop tree. Restored folders load their listings on demand, so an expanded folder shows its children. -- The closed-editor list, so Reopen Closed Editor works across launches. - -The user bucket lives in the Workshop's own state directory and follows you from workspace to workspace: - -- Editor toggles: word wrap, rendered whitespace, control characters, column selection. -- The zoom level. -- Recent files and recent workspaces under File > Open Recent. -- The command palette's history. - -Both buckets save as you go. There is no Save command for either. While a workspace is ephemeral, the workspace bucket has nowhere to go and lasts only for the session; the user bucket saves regardless. - -Opening a workspace applies its bucket in place of what you see. The live layout is replaced by the file's layout, and every open editor is disposed, including editors with unsaved text, so save your work before you open another workspace. The tree collapses to the file's expanded folders. A restored agent panel is a panel, not a conversation: it starts a fresh session, and your earlier sessions stay in the state directory as before. Saving a workspace under a new name copies the live layout, tree, and closed-editor list into the new file so it opens as you left it. - -If either bucket cannot be read or written, the Workshop starts from defaults for that bucket, notes the reason in its log, and keeps working; nothing you do in the interface is blocked by a persistence failure. - -## What is not in the workspace - -- Your files. The workspace remembers paths, not contents. -- Agent sessions and their transcripts. Those live in the Workshop's own state directory, as before. -- Anything from before this version. Existing state is not imported; save a workspace to start one. -- Editor toggles, zoom, recent files, and command history. Those are yours, not the workspace's, and stay the same as you move between workspaces. - -You can now save, open, and duplicate workspaces, and you know which of your settings travel with a workspace and which follow you. The next chapter teaches the editor, where you open and change the files those folders contain. - ---- - -# The Editor - -You have granted folders and you can browse them in the Workshop tree. This chapter teaches you to open the files those folders contain, edit them, and save them safely. The editor is where reading the agent's work and making your own changes happen, and it is built so you never lose text or silently overwrite someone else's. - -## Opening a file - -To open a file, click it in the Workshop tree. The file opens in its own tabbed editor panel in the main zone, with one panel per file. The tab title shows the file's base name rather than its full path. - -You can open a text file from a granted folder and see its full contents, up to a 1 MiB size limit. The editor targets source text, not media. A larger read fails with an error that states the byte limit. Binary files cannot be edited; the attempt is rejected with "file is binary, not text". Files that are not valid UTF-8 are rejected with "file is not utf-8 text". - -The editing surface is a CodeMirror-based text editor. Syntax highlighting is chosen automatically from the file extension: JavaScript, TypeScript, JSX, TSX, Python, Rust, JSON, Markdown, YAML, and TOML. Files with unknown or missing extensions open as plain text with no highlighting mode. You can search within the open document using the editor's built-in search panel, styled to match the application's dark theme. - -## Editing and saving - -Edit the text as you would in any code editor. A dot marker appears in the tab title when the document has unsaved changes, and clears when the document is clean again. - -To save the active editor, press Ctrl+S. The shortcut does nothing when no editor is active. To close the active editor, press Ctrl+W; a clean panel closes immediately. To move between open editors, press Ctrl+Tab to cycle forward and Ctrl+Shift+Tab to cycle in reverse, wrapping around at the ends. - -You can create a new file inside a granted folder by saving to a path that does not exist yet. - -Saves are atomic. You never see a half-written file or a leftover temporary file after a save. A crash or power loss during a save leaves either the old contents or the new, never a truncation. You also never lose unsaved typing to a slow save: edits made while a save write is still in flight remain marked as unsaved after the save completes. Triggering a second save while one is in flight does nothing, so you cannot stack overlapping writes. - -Load and save failures appear as an alert bar above the editor. The newest error replaces the previous one. The editor also warns when a panel opens with no file path. - -## Conflicts - -When you save a file that changed on disk since it was read, the save is refused with a conflict instead of silently overwriting. Each save sends the version token from the previous successful write, so the editor never silently overwrites a file that changed elsewhere. You get a "File changed on disk" dialog with two choices: - -- Reload discards the editor's text and loads the on-disk text. -- Overwrite writes your changes over the file on disk, re-reading the fresh token first so the write succeeds. - -## Closing with unsaved changes - -Closing a panel with unsaved changes opens an "Unsaved changes" dialog with three choices: - -- Save writes the file and closes the panel. -- Discard abandons your changes and closes the panel. -- Cancel returns you to the editor. - -A failed or conflicted save leaves the panel open. The panel closes only after a successful write. - -## Dialogs and read-only mode - -Modal prompts, such as the editor's conflict and close prompts and the tree's Add Folder prompt, appear as a themed dialog box overlaid on the panel you are working in, dimming the rest of that panel. Dialog behavior is consistent across panels: - -- You read a title and a message line at the top of each prompt. -- Prompts can show a labeled single-line text field. -- When a dialog opens, focus moves into it, landing in the text field or on the first button. -- Destructive actions are styled as danger buttons. -- Value-dependent buttons stay disabled until you type something. -- Enter inside the text field submits the dialog through its primary button. -- Escape dismisses the dialog without taking any action. -- Tab and Shift+Tab cycle focus within the dialog's controls and cannot escape to the panel behind it. -- When the dialog closes, focus returns to the element that had focus before the dialog opened. -- Re-invoking an already-open dialog does nothing. - -You can toggle the editor between editable and read-only without losing the document, the undo history, or the view state. When the workspace reloads a file from the server, the reload lands in place as one marked transaction instead of an editor rebuild: you keep undo history, selection, and scroll position, and you can undo back across the reload. A reloaded file arrives clean and is not flagged as an unsaved change. - -You can now open, edit, and save workspace files with confidence. The final chapter teaches you to keep the application current and tuned: updates, the About dialog, and the Gateway Config panel. - ---- - -# Updates and Configuration - -You can operate the whole application: the window, the panels, the menus, the status bar, models, chat, voice, the workspace, and the editor. This final chapter teaches you to keep the Workshop current and tuned: the update flow, the About dialog, and the embedded Gateway Config panel. - -## Keeping the Workshop up to date - -The installed application automatically checks the latest GitHub Release shortly after startup and installs only cryptographically verified updates. Downloaded updates are verified against a pinned public key before installation, so tampered updates are rejected. The automatic check runs on the desktop application only, and update checks give up after 30 seconds rather than hanging. On Windows, updates install passively, applying with minimal interruption to your session. - -Platform notes: - -- On Linux the update flow is available only when running as an AppImage. Package-managed installations show the update flow as unsupported and never contact the update endpoint. -- In a plain browser session the update flow stays inert. -- Nightly builds do not produce updater artifacts, so a nightly install does not receive automatic in-app updates. - -When an update is available, you see a banner floating at the bottom-right corner of the window, above the status bar. The banner shows the new version number and a one-line summary of the release notes. You have two choices: - -- Click "Remind me later" to dismiss the banner and bring the prompt back later. -- Click "Update now" to start the update immediately. - -While an update downloads, installs, or restarts, a full-screen modal overlay takes over the window. You watch download progress as a percentage and a progress bar, with bytes received against the total size. After the download finishes, the application installs the update and restarts itself. - -When an update download or install fails, you see the failure reason and can dismiss the overlay with a Close button to return to the application. You can expand an "Update log" section in the overlay to read the raw log lines produced during the update. When the application is already up to date, the update state reports that no update is available. When an update check fails, you see an error message. - -## The About dialog - -Open Help > About PromptForge to see the About dialog. It names the product, the application version, and the license, shown as "License: BSL-1.0". A development build shows the version "dev" instead of a release number. - -The About dialog is also where you trigger an update check manually. The update button reflects the state: - -- "Desktop updates unavailable" in a browser. -- "Updates are managed by your package manager" on package-managed installs. -- "Checking for updates..." while a check runs. -- "Show update " when an update is ready. -- "Retry update check" after a failed check. - -The About dialog traps keyboard focus: Tab and Shift+Tab cycle between its buttons and never leave the modal. You can dismiss it with the Escape key or the Close button, and focus returns to the element that opened it. Only one About dialog can be open at a time. - -## The Gateway Config panel - -You can view and change gateway configuration without leaving the Workshop, in the Gateway Config panel. The panel opens in the main zone through the application's Gateway Config command, titled "Gateway Config". Opening it a second time focuses the existing panel instead of opening a duplicate, and you can close it from its tab's close action. - -The panel embeds the gateway's configuration web interface, served same-origin through the Workshop at the `/gateway/config/` route in panel mode. It opens in the dark theme on the local gateway view. From the panel you can: - -- View the gateway's current configuration. -- Edit and save gateway configuration and environment values. -- Apply or revert pending configuration changes, and see whether the configuration has unsaved edits or changes waiting to be applied. -- Search and browse Hugging Face models. -- View gateway status, system information, model information, chat templates, environment, and orphaned files. -- View the downloaded model cache and delete a cached model to free disk space. -- Trigger the gateway's reveal action. - -Panel actions are announced on the Workshop status bar: "Gateway configuration applied", "Gateway configuration changes reverted", and "Gateway download started". Long-running panel operations such as cache downloads can stream for minutes without being cut off by a timeout. When the gateway is unreachable, the panel reports the failure instead of hanging. - -You never handle the gateway access key. The Workshop server attaches the bearer key on the server side of every forwarded panel request. Neither the Workshop page nor the embedded config panel ever sees it, and the key is never written to logs. The panel's API requests go through an allowlisted proxy; anything outside the configuration surface is refused, including chat completions, progress subscriptions, health checks, and direct cache uploads. Deleting a cached model is allowed only by its 64-character lowercase hex digest. Requests with malformed or absolute targets are refused locally with a forbidden status before anything leaves the application. The panel is reachable only from your own machine, never from the local network, and the embedded configuration interface runs in a restricted sandbox limited to running scripts within the same origin. - -## Reskinning the interface - -If you build the Workshop from source, you can reskin the entire interface by editing CSS custom properties in the `:root` block of `ui/style.css`. Every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a custom property there. To reskin without editing the shipped stylesheet, add a `` after `/style.css` in `ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade. Focus on menus and controls is shown through state backgrounds, opacity, or underlines, never through outline rings or focus boxes. - -You have completed the tour. You can install and start the Workshop, read its window and status bar, pick models and switch profiles, converse with an agent by keyboard or voice, grant folders, edit files, and keep the application current and configured. diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index 9cbc7402..189e37d2 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -2,21 +2,6 @@ - [Introduction](introduction.md) -# The Workshop - -- [Overview](workshop/index.md) -- [The Application](workshop/01-application.md) -- [The Workbench](workshop/02-workbench.md) -- [Menus and Commands](workshop/03-menus.md) -- [The Status Bar](workshop/04-status-bar.md) -- [Models and Profiles](workshop/05-models.md) -- [The Chat Surface](workshop/06-chat.md) -- [Voice Input](workshop/07-voice.md) -- [The Workspace](workshop/08-workspace.md) -- [Workspace Files](workshop/09-workspace-files.md) -- [The Editor](workshop/10-editor.md) -- [Updates and Configuration](workshop/11-updates.md) - # The Gateway - [Overview](gateway/index.md) diff --git a/guide/src/introduction.md b/guide/src/introduction.md index 51b5b2ad..d17eba27 100644 --- a/guide/src/introduction.md +++ b/guide/src/introduction.md @@ -24,8 +24,6 @@ The parts connect in one direction. The Workshop and the library sit on the engi Each audience has one documentation set. -If you use the Workshop desktop application, read [the Workshop set](workshop/index.md). It teaches the workbench, the chat surface, the editor, voice input, models and profiles, and updates. - If you operate the gateway, read [the Gateway set](gateway/index.md). It teaches installation, the configuration file, remote and local models, speech-to-text, profiles, and the operational surface. If you write prompts, read [the Prompt Language set](language/index.md). It teaches the .md prompt syntax: frontmatter, sections and blocks, Lua globals, prose substitution, models, tools, control flow, and fanout. diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md deleted file mode 100644 index c37cbcec..00000000 --- a/guide/src/workshop/01-application.md +++ /dev/null @@ -1,101 +0,0 @@ -# The Application - -This chapter teaches you what the Workshop desktop application is, how to install and start it, and what you see the first time its window opens. Everything else in this guide happens inside this one window, so it is worth a few minutes to understand what the application is made of and how it boots before you touch any feature. - -## What the Workshop is - -PromptForge Workshop is a desktop application for Windows, macOS, and Linux. You launch one program named Workshop. That program boots a small server inside itself and then opens a single window titled "PromptForge". The window shows the Workshop interface, which the built-in server serves on your own machine. There is no separate web server to install and no files to download before the interface can appear; the interface ships bundled inside the application. - -The Workshop talks to a PromptForge gateway. The gateway is the part of the system that supplies the model catalog, the profiles, and the model rounds that power chat. The gateway runs as its own program, separate from the Workshop window: the application's built-in server attaches to a running gateway over HTTP, so closing the window never unloads the gateway or its loaded models. The window opens at 1024 by 768 pixels the first time. Once you have saved a workspace file, it remembers its size, position, and maximized state there across launches; the Workspace Files chapter explains how. - -The application shows the PromptForge program icon in its custom title bar. - -## Installing and starting the Workshop - -You receive the application as a Windows installer, a macOS disk image, a Debian package, or a Linux AppImage, depending on your platform. On Windows the installer silently includes the webview runtime the application needs, so there is no separate setup step. - -To start the application, launch it the way you launch any installed program on your platform. If you work from a source checkout instead, one command builds and starts it: - -```` -cargo run -p workshop -```` - -To check which version you have without starting anything, run: - -```` -promptforge-workshop --version -```` - -This prints the version and exits. It does not start the server and it does not open a window. - -The installed application can also check for updates and update itself. After startup it automatically checks the latest GitHub Release, and it installs only cryptographically verified updates. - -You can also run the Workshop's server on its own and use the interface in an ordinary browser. In that mode you open the chat UI at `http://127.0.0.1:7910/`. The browser session works like the desktop window for almost everything; the few differences, such as native window controls and Explorer drag-and-drop, are called out in the chapters that cover them. - -## The first launch - -The first time you start the Workshop, the application prepares everything it needs before you see a window. Follow what happens: - -1. The application looks for its boot configuration. -2. It attaches to a running local gateway through its validated gateway discovery file. If none is running, it launches the sibling `promptforge-gateway`; a Workshop-only install instead uses the explicit gateway in `workshop.toml`. -3. It starts its server inside its own process and waits until the server accepts connections. -4. It waits for the interface to answer a health check, up to 15 seconds. -5. Only then does the window open. - -You never see a window before the interface is ready, and the interface never opens against a dead server. If the server does not answer in time, the error message names the health endpoint and how long the application waited. If startup fails for any reason, the application prints the full error chain and exits with a failure code instead of opening a broken window. - -Only one instance of the Workshop runs at a time. If you launch it again while it is already running, the existing window comes into focus instead of a second copy opening. When you close the window, the application shuts its built-in server down cleanly and exits; the gateway is a separate program and keeps running. To stop the gateway together with the window, use the quit command instead: Quit PromptForge and Gateway on the application menu, or Ctrl+Q (Cmd+Q on macOS). When the Workshop is attached to a gateway on another machine, the command reads Quit PromptForge and stops only the window - a client never stops a shared gateway. In-flight connections get a 5-second grace window, so a held chat session or a stuck request cannot hang the shutdown. The interface listens on an OS-assigned loopback port, so another program holding a port can never block startup. - -The Workshop also keeps working when parts of its environment fail. After boot, if a local gateway exits, the application keeps the interface open while it looks for a validated replacement or relaunches the installed sibling with bounded backoff. A replacement is published only after its process identity, health response, and bearer key all validate, and the server switches its clients and credentials together. The same relaunch loop is how the Workshop restarts its supervised gateway on purpose: picking a profile from the Model menu persists the selection and then asks the gateway to shut down, and the relaunched sibling boots into the new profile. Explicitly configured gateways on another machine are never launched, supervised, or stopped by the Workshop. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. - -## The gateway configuration - -The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and a sibling `gateway.state.toml` selecting the generated `default` profile. The generated catalog, profiles, and global settings all live in that one editable config file; the state file holds only the profile selection, which the gateway reads once at boot. - -The generated config is a single editable TOML file with a header that invites edits. Two properties of the generated file are worth knowing: - -- The gateway is secured with a freshly generated random bearer key, so no two installs share a key. -- The gateway listens on the loopback address only, on an OS-assigned port. It is not reachable from other machines, and the Workshop learns the port from the gateway discovery file the gateway writes. - -A `gateway.toml` left over from an older version may declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. - -Voice uses the same separation. The gateway owns speech models, worker lifecycle, batch transcription, and the generic Realtime endpoint. The Workshop server contributes only an authenticated same-origin relay, while the browser UI owns microphone capture and transcript presentation. - -At run time the gateway also downloads the pinned voice runtime matched to your machine (CUDA on Windows, Metal on Apple Silicon, CPU on the other supported targets), plus the managed `llama-server`. You make no build-time choices for this. - -## The Workshop configuration - -You configure the Workshop through a TOML file named `workshop.toml`. The application searches three places in order: beside the executable, the current directory, and `~/.promptforge/workshop.toml`. The first file found wins. Every field is optional and the defaults are built in. With no file anywhere, the application keeps its state in `~/.promptforge/` and attaches to the gateway through its gateway discovery file. The application never writes the file, and the standalone server's `workbench.toml` fallback does not apply to it. - -The keys you are most likely to set: - -- `gateway.base_url` points the Workshop at a PromptForge gateway the gateway discovery file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its gateway discovery file or launches the sibling `promptforge-gateway`. A Workshop-only install has no sibling, so with neither a running gateway nor an explicit value, startup fails with an error that names both remedies. -- `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. -- `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. -- `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. -- `agents.path` chooses which directory of `.md` agent prompts is launchable. The default is `agents/` beside the config file. A missing directory offers no agents; that is a state, not an error. - -String values support `${VAR}` environment interpolation, so you can keep secrets out of the file. A literal dollar sign is written `$$`. An unset variable interpolates to the empty string instead of failing startup. - -The configuration is strict about mistakes, so you find out about problems immediately. A config without a `[gateway]` section fails to load. Unknown keys or sections are a startup error, such as a leftover `[voice]` section from an older version. Error messages name the offending file, and a malformed `${...}` interpolation gives a clear error. A browser launch failure, by contrast, is only logged as a warning; it never stops the server. - -## Working with your operating system - -The Workshop is a desktop citizen, not just a web page in a frame. - -You can drag files from your operating system and drop them into the application to attach them. You can open native file and folder picker dialogs from the Workshop. When you click a link to an external website, it opens in your system browser while the Workshop window stays on its own page. Links between pages served by the Workshop itself load inside the application window. - -One protection is worth understanding early: a link to any other local server, even one on the same port spelled `localhost` or `[::1]`, opens in the system browser. No other program on your machine gets the application's desktop features. - -## Safety and limits - -The Workshop is built so that only you, on your own machine, can reach it. - -The window loads its interface only from the local machine, never from a remote address. The Workshop refuses any request a browser marks as coming from another website, and it only answers requests addressed to a loopback host. Requests that change things must declare a JSON body. The live socket for chat only upgrades for the Workshop's own loopback origin or a native client. - -Nothing hangs forever. A stalled request is answered with a timeout error instead of freezing: ordinary routes give up after 10 seconds, and routes that relay a call to the gateway allow up to 35 seconds so a stalled gateway surfaces as a meaningful failure. Live socket sessions are never cut off by a request deadline. A gateway that is down or wedged fails fast in the interface: connections give up after 5 seconds and ordinary requests after 30 seconds. - -Startup also cleans up after previous runs. Leftover temporary files in the state directory are swept away on boot, so a crash during a previous save never leaves residue that affects the next launch. - -You now know what the application is, how it starts, and what it connects to. The next chapter opens the window and walks through its regions. - diff --git a/guide/src/workshop/02-workbench.md b/guide/src/workshop/02-workbench.md deleted file mode 100644 index 85ff2368..00000000 --- a/guide/src/workshop/02-workbench.md +++ /dev/null @@ -1,71 +0,0 @@ -# The Workbench - -You know how the Workshop starts and what its window is. This chapter teaches you how that window is organized: the regions it is divided into, the panels that live in those regions, and how to arrange them to fit the way you work. Everything you do in the Workshop happens inside a panel, so learning the layout once pays off in every later chapter. - -## The three zones - -The Workshop window is a dock area divided into three named zones, rendered in the Cursor Dark visual theme: - -- The left zone holds the workspace tree. -- The main zone holds document editors. -- The right zone holds the agent session. - -Each kind of panel has a default zone it opens in until you move it. The Workshop tree opens on the left, editors open in the main zone, and the agent session opens on the right. On a fresh start you see two panels: the Workshop tree docked on the left, titled "Workshop", and the Agent Session panel docked on the right. The main zone stays empty until you open a document. - -Below the dock area, a permanent full-width status bar runs along the bottom of the window. It is not part of the dock and is never saved as part of the layout. - -## The title bar - -Across the top of the window sits a custom title bar. It shows the PromptForge program icon, holds the five application menus (File, Edit, Model, Window, Help), and leaves an empty center region you can grab. On Windows this bar replaces the native window frame; macOS and Linux keep their decorated windows. The bar is always shown, even when you run the Workshop in a plain browser, because the application menus live there. - -To operate the window from the title bar: - -- Drag the empty center region with the primary mouse button to move the window. -- Double-click the same region to toggle between maximized and restored. -- Click the Minimize, Maximize, or Close control at the right end to operate the window. - -The controls appear in the Windows-standard order: Minimize, Maximize, Close. The maximize control swaps its glyph and label between "Maximize" and "Restore" to match the window's current state, including changes made by Windows Snap or by drag-resizing. The window reopens at its previous size and position on the next launch. The native window controls appear only in the desktop application. In a plain browser the control cluster is hidden, because there is no native window for the commands to act on; the menus still work. - -## Zooming the interface - -You can scale the whole interface to a comfortable size. Zoom applies uniformly to the whole window, so the chat, the editor, and every other surface scale together. - -- Press Ctrl+= to zoom in one step. Ctrl+Shift+= also zooms in. -- Press Ctrl+- to zoom out one step. -- Press Ctrl+0 to reset to 100%. - -Zoom changes in fixed steps of 10 percent, clamped between 50% and 200%. Your chosen level persists across sessions and is re-applied on every boot. A missing, corrupt, or out-of-range saved value leaves the default 100% in place. Zoom keeps working even when the saved value cannot be read or written; only the persistence is skipped. In a plain browser, zoom uses CSS zoom instead of native window zoom. - -## Panels - -A panel is one unit of content in the dock: the Workshop tree, an editor, an agent session, or the Gateway Config panel. Every panel renders a normal chip tab, so tabs are always visible even when a panel is alone in its group. - -A few rules govern how panels open: - -- Reopening a panel that is already open brings it to focus instead of opening a duplicate. -- Each open document gets its own editor tab keyed by its file path. The same file never opens twice. -- Each agent session gets its own panel keyed by its instance id, so multiple agent sessions can be open side by side. -- Panel kinds other than editors and agent sessions are singletons. Only one of each can be open at a time. - -Editor tabs are titled with the file's base name rather than its full path. Panel tabs update their displayed title when the panel's title changes. If an unknown panel is ever requested, you see a labelled placeholder instead of a broken dock. - -You can close an Agent Session tab with the close button on the tab. Right-clicking an Agent Session tab opens a context menu with "Close" and "Close Others" actions. - -- Press Ctrl+B to close the Workshop tree panel. Press Ctrl+B again to reopen it. - -## Rearranging the layout - -The workbench is never locked. You can drag panels to rearrange the layout at any time. - -When you move a panel to another zone, the application remembers that choice and reopens the panel in your chosen zone next time. Moving a panel back to its default zone clears the remembered override, so the panel follows its type's normal placement again. - -Closing or dragging away a zone's last panel leaves the zone in place, empty, at its current size. The next panel opened into that zone fills it, so the layout keeps its familiar shape. - -## Layout persistence - -The panel layout persists across sessions. Layout changes save automatically shortly after you move, resize, open, or close panels. There is no manual save step. - -If the saved layout is missing, corrupt, or from an older version of the application, the Workshop discards it and boots the known-good default layout: the Workshop tree anchored left and the agent session open right. You can never lose the Workshop tree or the agent session. Both panels are restored on every boot even if a stale saved layout dropped them, and the Workshop tree's tab has no close button. - -A few small behaviors keep the workbench predictable. Drag-and-drop of panels inside the application always works, because the application avoids registering an OS-level drop target that would break in-page dragging. The browser's native right-click context menu is suppressed inside the application, so right-clicks always produce Workshop menus. - diff --git a/guide/src/workshop/03-menus.md b/guide/src/workshop/03-menus.md deleted file mode 100644 index 79b925aa..00000000 --- a/guide/src/workshop/03-menus.md +++ /dev/null @@ -1,69 +0,0 @@ -# Menus and Commands - -You know the window's regions and panels. This chapter teaches you the command surface that sits on top of them: the five menus in the title bar, the keyboard shortcuts, and how menus behave. Once you know where the commands live, every later chapter can simply name a command and you will know where to find it. - -## The five menus - -The title bar has five menus: File, Edit, Model, Window, and Help. Click a menu button to open its popover. Here is what each menu holds. - -The File menu: - -- New Agent starts a fresh agent session; it opens or focuses the agent-session panel. New Agent is the only new-conversation command. There is no New Chat. -- Open Workspace from File..., Save Workspace As..., and Duplicate Workspace... manage the `.pfwork` workspace file; Add Folder to Workspace... grants a folder. The Workspace Files chapter covers them. -- Close Window closes the window, also with Alt+F4. - -The Edit menu runs Undo, Redo, Cut, Copy, Paste, and Select All with the standard shortcuts Ctrl+Z, Ctrl+Y, Ctrl+X, Ctrl+C, Ctrl+V, and Ctrl+A. After an Edit command runs, focus returns to the field that had it. - -The Window menu: - -- Workshop Panel toggles the Workshop panel tree, also with Ctrl+B. -- Gateway Config opens or focuses the gateway configuration panel. It sits directly after Workshop Panel. -- New Agent opens or focuses the agent-session panel. It sits directly after Gateway Config. -- Zoom In, Zoom Out, and Reset Zoom zoom the interface, with shortcuts Ctrl+=, Ctrl+-, and Ctrl+0. Ctrl+Shift+= also zooms in. -- Minimize and Maximize/Restore operate the window. These menu commands do exactly what the visible title bar buttons do. - -The Model menu lists every catalog model as a checkable radio row with the selected one checked. Each model's description appears as a tooltip on its row. When the catalog is empty, the Model menu shows a disabled "No models available" row. A Profiles section at the bottom of the Model menu selects the gateway profile; it appears whenever the gateway defines at least one profile, lists "No profile" first and then every profile, and checks the active one. The Models and Profiles chapter covers this menu in depth. - -Help > About PromptForge opens the About dialog, which also shows the desktop update state. The Updates and Configuration chapter covers it. - -## Keyboard shortcuts - -Beyond the menu shortcuts, the application binds a small fixed set of keys: - -- Ctrl+S saves the active editor. The shortcut does nothing when no editor is active. -- Ctrl+W closes the active editor and prompts when there are unsaved changes. -- Ctrl+B toggles the Workshop tree panel open and closed. -- Ctrl+Tab cycles through the open editors and Ctrl+Shift+Tab cycles in reverse, wrapping around at the ends. -- Ctrl+Shift+F opens or activates the Workshop tree and moves keyboard focus into it. - -The bindings are fixed. You cannot customize them, and there are no multi-key chords. Only plain Ctrl combinations are bound; combinations with Alt or Meta are left untouched. Unbound key combinations fall through to the browser and the editor, so typing, selection, clipboard, undo/redo, and in-file find keep their normal behavior. Inside the desktop application the browser's built-in shortcuts are disabled, so the application's own key handling never races them. - -## How menus behave - -Menus in the Workshop follow the desktop conventions you already know, with a few details worth learning once. - -Edit menu commands are enabled only when an editable element (a text input, textarea, or contenteditable element) holds focus. They act on the element that was focused before the menu opened. A disabled command cannot run and does not close the menu. - -You can navigate open menus with the keyboard. ArrowDown and ArrowUp move between rows with wraparound. ArrowRight and ArrowLeft switch menus. Enter runs the focused row. Escape closes the menu and returns focus to its button. While any menu is open, hovering another menu button switches to it. Hovering alone opens nothing when no menu is open. An open menu closes when you click anywhere outside it or when the window loses focus. - -Menu rows show the label on the left and the shortcut hint on the right in muted, smaller text. Disabled rows are muted and do not react to hover. Thin separator lines group related rows. Checkable rows keep a fixed-width check column so labels stay aligned. - -The Model menu is live. It rebuilds its rows from the catalog every time it opens, and again whenever a workbench snapshot arrives while it stays open, so check marks move without reopening the menu. Clicking a model row sends the selection, and the check mark moves only when the server confirms the new selection. Keyboard focus survives a live rebuild of the open menu: focus stays on the equivalent row and falls back to the first row if the focused row disappears. While a profile selection is in progress, every Model menu row disables, and the target profile shows a pending "..." mark in place of its check until the server confirms. The still-active profile keeps its checkmark. - -The same menus work in a plain browser. Only the native window commands (Minimize, Maximize/Restore, Close Window) do nothing there, because only the desktop bridge can run them. - -## Context menus - -Some panels, such as the Workshop tree, open a context menu of action items from a trigger element. Context menus share one set of behaviors: - -- Activating the same trigger a second time closes the menu. At most one menu is open at a time. -- Items can show an icon next to the label, a check mark for the selected choice, and a danger style for destructive actions. -- A right-click invocation opens the menu at the pointer position. The menu flips above the trigger or right-aligns when it would overflow the window. -- Escape dismisses the menu and returns focus to the trigger. ArrowUp, ArrowDown, Home, and End move through the items. Tab closes the menu. -- Activating an item runs its action and closes the menu immediately. -- The trigger announces its expanded state to assistive technology. - -Panels and chat use one consistent set of small inline outline icons. The trash icon deletes an item, the folder-plus icon creates a folder, the microphone icon starts voice input, and the send icon sends the message. The icons are sized 15 or 16 pixels and drawn in the surrounding text color, so they stay legible across themes. - -You can now reach every command the application offers. The next chapter teaches the status bar, which is how the application reports what it is doing while you work. - diff --git a/guide/src/workshop/04-status-bar.md b/guide/src/workshop/04-status-bar.md deleted file mode 100644 index df571575..00000000 --- a/guide/src/workshop/04-status-bar.md +++ /dev/null @@ -1,58 +0,0 @@ -# The Status Bar - -You know the window, its panels, and its menus. This chapter teaches you the status bar, the permanent full-width footer at the bottom of the window. The status bar is how the Workshop tells you what it is doing whenever something takes noticeable time: startup phases, gateway round trips, dictation and transcription, and model downloads. Learning to read it means you always know whether the application is idle, working, or stuck, and why. - -## Reading the bar - -The status bar shows a short label as its text. When startup finishes and nothing is happening, the resting state reads "Ready". Hover over the bar to see a longer description of the current status as a tooltip. Failures appear as errors, visually distinct from ordinary status updates: the text switches to red. Long status text truncates with an ellipsis instead of overflowing the bar, and numbers use fixed-width digits so values do not jitter as they change. The bar announces its updates to assistive technology. - -During startup you see a "Connecting to gateway" update that names the gateway base URL being contacted. When startup finishes and nothing is happening, the bar returns to "Ready". - -## The right slot: progress bar and lights - -The right end of the bar holds one of two things, never both at once. While an operation reports progress, a progress bar fills the slot. Otherwise the slot holds the indicator lights. The slot swaps as a unit. - -When an activity can report how far along it is, you see determinate progress: units completed so far against units expected in total. A model download, for example, shows its label, the file name as the description, and a current-of-total count. Gateway-side work such as model downloads and profile switches renders on the Workshop status bar through the same progress display as local operations. - -When no progress is showing, two small lights sit in the slot: - -- The activity LED pulses green while output tokens arrive and amber while a model turn is thinking. It also tells gateway traffic (green) from dictation activity (amber). Green wins when both coincide. The thinking LED stays lit for the whole thinking period, not just a brief flash. Pulses fade in fast and decay slowly, so a stream of activity reads as one continuous glow. -- The recording LED lights up red while the microphone is recording. - -Both LEDs sit dark when the application is idle. The recording LED sits one LED-width to the left of the activity LED. When a chat is aborted, the activity LED goes dark immediately, even though no final server status arrives for that chat. When an error status arrives, the activity LED goes dark at once and does not light again on its own. - -## Gateway connectivity - -The status bar is where you watch the gateway connection. The Workshop probes the gateway's health endpoint and treats a transport failure, a slow answer, or a non-success status as unreachable. Each probe is bounded at 2 seconds. The Workshop opens and works normally whether or not the gateway has ever answered; only gateway calls wait. - -- When the gateway stops answering, the bar announces "Gateway unreachable" with the explanation "the gateway does not answer its health probe". Calls to the gateway are not attempted while it is down. -- When the gateway returns, the bar announces "Connected to gateway". The model catalog refreshes by itself, because a gateway that was down may serve a different catalog. - -You are notified only when reachability changes. A steady state never re-announces itself. While the gateway is reachable, the Workshop checks its health every 5 seconds, so a recovery is detected within about 5 seconds. While the gateway is down, retries use a jittered, escalating delay: starting at about 5 seconds, doubling per attempt, and never exceeding one minute. A gateway that accepts connections but never answers keeps the escalated schedule, because only useful work resets it. After roughly a full day of continuous outage, the Workshop stops probing and shows "Gateway reconnect stopped" with the advice "the reconnect budget is exhausted; restart the workshop to retry". - -When a gateway call fails in transport, you see the gateway's own summary line as the error message. Every failure you hit surfaces as a short plain-language message near the status text. Production builds show no internal detail; debug builds append the underlying cause chain after the message. - -Gateway progress appears on the status bar only while the gateway is reachable. When the gateway becomes unreachable the progress entry disappears instead of going stale. After a reconnect the progress resumes with a single fresh entry. - -## Live delivery and reconnection - -The application holds one persistent live connection to the server. Status updates, the model catalog, and menu state arrive in the interface as they happen, with no manual refresh. The interface boots with its status bar, catalog, and menu state already populated; there are no loading round trips. Snapshots are pushed on every connect and resent on reconnect, and the newest status update is retained and replayed to late-connecting sessions, so if you reconnect you immediately see the current status. A late-joining session gets a status line recomputed from the current probe, not a stale retained announcement; if real work is in progress, such as a model download or a chat, that work's status frame replays as-is. - -When the connection to the server drops, the status bar returns to a neutral "Reconnecting..." state. The application reconnects automatically: retries start at a one-second wait and double on each failure, capped at 30 seconds. The application connects over a secure socket automatically when the page is served over HTTPS, and a plain socket otherwise. - -Locally-originated messages such as dictation errors appear in the status bar too, and are replaced by the next server status update. - -## Why the bar stays calm - -The status bar is engineered not to flicker, so what you see is always meaningful: - -- An operation that finishes in under one second never disturbs the status bar. -- Once the progress indicator appears, it stays visible for at least half a second. -- The bar never steps backward, even when a new operation starts while the previous bar is still on screen. Back-to-back operations share one continuous bar. -- When an operation has several sub-tasks, the bar shows a single weighted aggregate and the label names the sub-task that is still unfinished. -- Internal instrumentation never reaches the screen. Debug-level updates never change the status bar text or tooltip, though they still pulse the activity LED; only info and error severities are displayed. -- If updates arrive faster than the interface can draw them, the display skips ahead to the newest snapshot instead of lagging behind. -- Updates that arrive while the application is still starting are held and replayed in arrival order once the interface is ready. The holding queue is bounded at 32 pushes with the oldest dropped when full, and if the connection drops before the interface is ready, the queued messages are cleared. - -You can now read everything the application tells you about its state. The next chapter teaches you to choose what the application runs: models and profiles. - diff --git a/guide/src/workshop/05-models.md b/guide/src/workshop/05-models.md deleted file mode 100644 index 1b9bc48f..00000000 --- a/guide/src/workshop/05-models.md +++ /dev/null @@ -1,63 +0,0 @@ -# Models and Profiles - -You can read the status bar, so you can tell when the application is ready. This chapter teaches you to choose what the application runs: the model that answers your chats, and the profile that decides which models exist. By the end you will be able to pick a model, understand when chat is ready, and switch profiles with confidence. - -## The catalog - -The Workshop does not invent its model list. The catalog comes from the configured gateway, which serves it at `GET /v1/models`. The Workshop relays the catalog verbatim, including upstream error bodies, so what you see matches the gateway's answer. Each model lists its id and owner, with an optional description. Each push replaces the previous list in full. - -Every connected session receives each catalog update, so all open sessions show the same current list. A session that connects later receives the current catalog immediately. The catalog also refreshes automatically every time the gateway comes back after an outage, because a gateway that was down may serve a different catalog. A boot-time catalog failure heals itself this way. A failed, declined, or malformed catalog answer is logged and skipped rather than pushed, so your pickers never lose a usable list. - -While the Workshop fetches the catalog, the status bar shows "Loading models...". When the gateway is known to be down, the request is refused immediately with the message "Gateway unreachable". A non-success answer shows "Gateway error: ". A failed connection shows "Connection lost" with the underlying detail. A successful fetch returns the status area to idle. - -## Picking a model - -You pick a model from the Model menu in the title bar. The menu lists every catalog model as a checkable radio row with the selected one checked, and each model's description appears as a tooltip on its row. When the catalog is empty, the menu shows a disabled "No models available" row. - -The agent toolbar offers a second way to pick: a pill-shaped button that displays the id of the currently selected model. To use it: - -1. Click the pill button. A dropdown menu opens listing every model in the catalog. -2. Click a model. It becomes the current model. - -When no model is selected, the pill shows the label "Select model". When the catalog is empty, the dropdown shows a single inert "No models available" row. Hovering the button shows the current model's description as a tooltip. - -One current model selection is shared by every Agent tab and the title-bar Model menu, so the chosen model stays consistent across the whole application. Your pick is sent to the server as a command, and the on-screen selection changes only when the server confirms it. The button label updates only after that confirmation, never optimistically on click. A catalog refresh never silently changes which model is selected, and selection indicators update only on a real change, so the Model menu and Agent tabs do not flicker when the server re-confirms the same model. Picking an unknown model id is refused with an error message, and the previous selection stays in place. - -If a refreshed catalog no longer contains the selected model, the Model menu clears the selection and chat becomes unavailable until you pick again. - -## When chat is ready - -Chat input is enabled only when all of these hold: the catalog has models, a model is selected, no profile switch is in flight, and the gateway is reachable. The server computes this readiness; the interface never derives it. - -On startup and after every reconnect, the application restores the remembered model for the active profile, falling back to the first catalog model when the remembered one is gone. A fresh boot against a live gateway lands ready to chat with no manual pick. While the gateway is unreachable, chat input stays disabled even with a model selected. Your chosen model survives the outage; only chat readiness flips, and the selection is still in place when the gateway returns. - -If a model selection cannot be sent because the connection is down, the status bar shows an error naming the model and the cause: "Could not select : the workshop socket is down". - -## Profiles - -A profile is a named checklist on the gateway that decides which local and speech models it loads at boot. Remote models are always available; the profile governs what runs on the gateway's own machine. The Workshop shows you the list of profiles the gateway offers and which profile is currently active, read from the gateway. You can see the Model menu's full state at a glance: every profile, the active profile, any profile selection in progress, and the selected model. A gateway without profile support shows an empty profile list instead of an error or stale names. - -The gateway loads its local models once, when it starts, so changing the profile means restarting the gateway. When the gateway is a sidecar the Workshop launched and supervises, the Workshop performs that restart for you. To select a profile: - -1. Open the Model menu. -2. Find the Profiles section at the bottom. It appears whenever the gateway defines at least one profile. "No profile" is the first entry, and the active profile is checked. -3. Click the profile you want, or "No profile" to run remote models only. - -The selection runs a sequence of up to three labeled stages shown in order with determinate counts: "Selecting profile..." (1 of 3), "Restarting gateway..." (2 of 3), "Loading models..." (3 of 3). The status bar names the profile being selected while progress is shown. The first stage persists the selection on the gateway. When the gateway is already running the chosen profile, the sequence stops there and the menu settles at once. Otherwise, for a supervised sidecar, the Workshop asks the gateway to shut down and waits up to 90 seconds for its relaunched replacement to come up serving the chosen profile; the replacement's boot then loads the profile's models, which can take minutes while weights load into VRAM. - -When the gateway is one you configured on another machine, the Workshop never stops it. The selection persists on that gateway and the status bar reads "Profile selected" with a notice that you must restart the gateway by hand to load it; the running profile stays active until you do. - -While a selection runs, the menu shows a pending state and chat input is disabled. Only one selection runs at a time; starting a second while one is in flight is refused with an error. - -When a selection completes, the application selects the model last used on that profile, or the first catalog model when none is remembered. Chat becomes ready again and the status bar returns to idle. When a selection fails, you see a "Profile switch failed" notification with the gateway's own error message; if the gateway still serves, the selected model and chat readiness are restored. A sidecar that was shut down and did not return in time reports "gateway did not return after restart", and the Workshop's supervisor keeps looking for it and repopulates the menu when it appears. After any selection that leaves a gateway serving, the profile list and model catalog are refreshed, so the menu reflects the gateway's real state. If the connection is down when you try to select, a local error appears on the status bar: "Could not switch to : the workshop socket is down". - -The application remembers the selected model per profile and restores it across restarts. The memory lives in a `workshop-state.json` file in the server's state directory. A missing, unreadable, or corrupt memory file never blocks startup; the application starts with no memory and selects the first catalog model. - -## The model cache - -You can trigger a download of a model blob into the gateway's cache and watch cumulative progress until the blob is ready or the download fails. When the requested blob is already cached, you get an immediate ready answer instead of a download. The cache feature is meaningful only in the standard local deployment, where the Workshop and the gateway run on the same machine and share the filesystem. - -Before the application receives its first state from the server, you see an empty workbench: no profiles, no active profile, no selected model, and chat gated off. Every server push refreshes the Model menu and chat gating, even when nothing changed, so the display never goes stale. - -You now have a model selected and chat ready. The next chapter teaches the chat surface itself. - diff --git a/guide/src/workshop/06-chat.md b/guide/src/workshop/06-chat.md deleted file mode 100644 index 05b6a350..00000000 --- a/guide/src/workshop/06-chat.md +++ /dev/null @@ -1,96 +0,0 @@ -# The Chat Surface - -You have a model selected and chat is ready. This chapter teaches you the chat surface itself: how to send a prompt, how to read the transcript, and how to steer a session once it is running. Chat is the heart of the Workshop, and everything here builds directly on the Models and Profiles chapter. - -## Your first message - -The Agent Session panel on the right side of the window is where you talk to the selected model. Chat always runs as a live agent session, not a one-shot buffered request. Every reply streams through the open session, which opens instantly and stays open for the whole session. - -The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping `.md` prompt files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a `chat.md` file in the agents directory shadows the built-in one, so you can replace the default chat with your own prompt. An existing `chat.md` that cannot be read surfaces its error instead of silently serving the embedded source. - -To send your first message: - -1. Click into the input box at the bottom of the Agent Session panel. The placeholder reads "Plan, Build, / for skills, @ for context". -2. Type your message. -3. Press Enter. - -Enter sends the prompt. Shift+Enter inserts a newline without sending. If you use a CJK input method, an Enter that commits an IME composition never sends, so you can confirm candidates safely. - -Sending delivers exactly the text you typed, never trimmed. An empty box sends nothing. A failed send keeps the text for retry. A successful send clears the box. The box grows and shrinks with what you type, within a minimum and maximum height (about 36px to 200px), and scrolls past the maximum. - -The prompt box and send button enable only while the agent is asking for input. Otherwise the box is read-only and send is disabled. - -A push-to-talk microphone button sits beside the send button. It stays visible in every state, and when dictation cannot start, a click names the blocker on the status bar. The Voice Input chapter covers dictation. - -## Reading the transcript - -The session reads as a scrolling feed of rows, one row per transcript entry, with each kind of entry styled distinctly. The feed scrolls itself to the newest entry whenever it repaints. New rows are announced to assistive technology as they arrive; settled history is never rebuilt or re-announced during streaming. - -Your own messages appear under a muted "You" label as plain text, right-aligned as bubbles. Text you send is never interpreted as markup, so pasted or typed HTML cannot inject formatting or scripts. - -Agent replies render as formatted Markdown with a muted line above naming the model that produced the reply. Replies and reasoning that are still streaming are drawn with a visible pending style and a blinking caret at the live tail. While a reply streams, you see the answer text arrive chunk by chunk. The status bar shows "Running agent turn" while the agent thinks, "Streaming response..." while the reply streams, and "Ready" when the turn completes. The model's reasoning streams live on its own side channel, separate from the answer text, and appears in a collapsible block titled "Reasoning" or "Reasoning (model)". It stays open while it streams and collapses once it settles. - -Tool calls appear as collapsible cards with a clickable header. The header shows the tool's name (or a generic "Tool call" / "Tool calls" label), a count badge for multi-call batches, and a status dot. A card opens on its own while the call runs and closes when the result arrives. A card you opened by hand stays open. Each call's arguments render as syntax-highlighted JSON. The result appears as a preformatted block labeled with the id of the call it answers. A batch that cannot be parsed still renders as raw text instead of vanishing. - -Errors appear inline in the transcript with a visible "Error: " label, never by color alone. A message that could not be sent because the connection is down appears as a local notice: "The message was not sent: the agent socket is down." - -You can observe per-reply model metrics such as token usage and generation speed attached to the assistant's replies. The log records which model produced each entry, per-reply token usage (prompt, completion, cached, and reasoning tokens), and per-reply timings (time to first token, generation speed in tokens per second, and end-to-end latency). - -## Mentions and the composer extras - -You can mention files with @ and pick them from a typeahead popup that opens next to the cursor. The list filters its entries by case-insensitive substring match against the text typed after the @. While the popup is open, ArrowUp and ArrowDown move the highlight through the suggestion list with wraparound, and Enter inserts the highlighted item instead of sending the message. Clicking a row inserts that file without moving focus out of the editor. Escape dismisses the popup. A query with no matches hides the popup. - -Each referenced file appears as an inline pill inside the prompt editor, with a file icon and the file's label. The pill behaves as a single unit, not editable text. Clicking the X button on the pill removes the whole mention. The suggestion list currently offers three canned file entries (README.md, src/main.ts, Cargo.toml) as a stand-in until the workspace file index exists. - -## The agent toolbar - -A toolbar above the input bar groups the mode chip, the model picker, and a context-usage ring in one row. - -The mode chip lets you choose among five agent interaction modes: Agent, Plan, Debug, Multitask, and Ask. The chip starts in Agent mode. Click it and pick a mode; the chip's icon and label update immediately and the change is announced to the rest of the application. Re-picking the current mode produces no change and no event. - -The context ring is a small 16px gauge showing how much of the model's context window the current session has used. The arc fills in proportion to the percentage used. The ring reads 0 percent until real usage data exists, and readings are clamped between 0 and 100. Assistive technology hears it announced as "Context usage" with the current percentage. - -The model picker in the toolbar is the pill button from the Models and Profiles chapter; it shares the same selection as the title-bar Model menu. - -## Sessions that survive - -A session is more durable than its connection. Agent sessions survive a dropped connection. The socket reconnects on its own and reattaches to the same session. The server replays the session's event log from the beginning, and a per-client cursor drops duplicates, so you see each event exactly once and in order. Every unanswered prompt is re-announced in the order it was asked. - -You can also attach to an already running session by its session id, resuming where that session stands. Sessions outlive sockets. - -Your run history is recorded as an event log the Workshop keeps in memory for the life of the session: every reconnect replays it from the beginning in its original ordering, and new events append to the same record. The log does not survive an application restart; a durable, resumable run history arrives with the harness's run log. - -The chat shows both sides of the conversation back to the model each turn, rebuilding the message list from the recorded user and agent messages. The conversation accumulates turn over turn, and what you typed reaches the model byte-exact, with newlines, quotes, and unicode preserved. Selecting another model takes effect on the next turn, and each reply is attributed to the model that produced it. A relaunch over retained history resumes the conversation exactly where it stood. - -## Cancelling and failing gracefully - -You can cancel a running turn. Cancellation is a stop reason, never an error. Pending prompts close as cancelled, and the relaunched agent returns to waiting over its retained history. The chat is immediately usable again. - -The chat survives a transport failure: the session surfaces the failure and returns to waiting for the next message. When a single model round fails, you see an error message naming the agent; the agent survives the failure and returns to waiting for input. When a run fails outright, you see an "Agent failed" notification with the error text. If stream chunks are dropped on a slow connection, the completed transcript event repairs the text. Late chunks that arrive after a cancel are discarded, so you never see duplicate or orphaned streaming text. - -Closing a session ends the agent run for good with no relaunch. The saved transcript stays on disk. - -## When the agent asks you a question - -Some agent programs pause and ask for input. When an agent program needs input, the Workshop presents a prompt in the session's input box and waits for you to type an answer. The input box stays pinned to that request until it is answered. Each prompt accepts exactly one answer, and your typed answer reaches the agent byte-exact as typed, preserving newlines, quotes, braces, backslashes, and non-ASCII characters. - -Cancelling a turn while a prompt is pending dismisses that prompt, so the input box is never left stuck on a dead question. A prompt that dies unresolved is explicitly cancelled on screen, never silently abandoned. A pending prompt survives a lost connection: on reconnect, every unanswered prompt is shown again in the order it was asked, and a stale prompt vanishes. You can answer a prompt that was asked while the socket was down; the answer is delivered normally once the session is back. - -## The agent panel - -You work with one agent session per panel. Opening a new panel starts a fresh session. Closing the panel ends the session and releases its connection. The panel automatically launches the "chat" agent when the server reports available agents, falling back to the first available agent when "chat" is not present. You can open additional agent sessions from the Agents menu (New Agent) or the Workshop menu (Open Agent Session). Each new session gets its own panel in the right zone. Agent windows are modal: one window serves one session at a time, and trying to open a second session in the same window is refused with an explanation. - -While the panel has no active session, you see a launchable-agent menu labeled "Agents" for assistive technology, with the lead line "Launch an agent to start a session." There is one button per discovered agent, labeled with the agent's name; clicking it launches a session. When no agents are discovered, you see the message "No agents discovered." After you launch an agent, every launch button disables until the server answers, preventing a double launch. A refused launch shows the server's error message and re-enables the buttons for another try. When the agent socket is down, you see "The agent socket is down; it reconnects by itself. Try again shortly." and no launch is sent. The whole menu disappears once the session acknowledgment arrives, replaced by the session surface. Starting or reattaching to a session clears any pending input prompt; a same-session reattach keeps the transcript, and a new session starts the transcript fresh. - -## What chat content can contain - -Model-authored chat content renders as Markdown: headings, bold, italic, inline code, lists, blockquotes, tables, links, and images. Fenced code blocks are syntax-highlighted in the application's dark theme in twelve languages: bash, css, html, javascript, json, lua, markdown, python, rust, toml, typescript, and yaml. A code block in an unrecognized language renders as a plain code block, and if highlighting fails to initialize, code blocks still render as plain preformatted text. - -You can size an image embedded in chat content by appending a ` =WxH` or ` =Wx` dimension suffix to the image source. Links show a tooltip on hover that defaults to the link URL. - -Model-authored markup is sanitized before display. Scripts, inline event handlers, and dangerous URLs such as javascript: links are stripped. Tool results render as plain text, so markup inside a result can never execute. - -Launching an agent is refused when the gateway settings cannot produce a usable model client. The error tells you to check `gateway.base_url` and `gateway.api_key` in `workshop.toml`. The rest of the Workshop keeps serving. - -You can now hold a full conversation, steer it, and recover from anything that interrupts it. The next chapter teaches you to speak your prompts instead of typing them. - diff --git a/guide/src/workshop/07-voice.md b/guide/src/workshop/07-voice.md deleted file mode 100644 index 9f872e1e..00000000 --- a/guide/src/workshop/07-voice.md +++ /dev/null @@ -1,62 +0,0 @@ -# Voice Input - -You can type prompts into the chat surface. This chapter teaches you to speak them instead. Dictation uses a push-to-talk microphone button beside the send button, and the transcript lands in the prompt exactly as if you had typed it. If voice is not available on your machine, this chapter also teaches you how to tell and why. - -The desktop application keeps the microphone connection same-origin: its Workshop server relays `/v1/realtime` to the gateway's fixed `/v1/realtime?intent=transcription` target. The relay authenticates upstream but never parses speech payloads or owns speech state. The gateway credential stays in the server process and is never exposed to the webview. - -## Dictating a prompt - -To dictate into the chat input: - -1. Click the microphone button beside the send button. Its tooltip reads "Push to talk". -2. Speak your message. -3. Click the microphone button again to stop. The tooltip now reads "Stop recording". - -While you speak, you see one evolving transcript. Each revision replaces the previous hypothesis in the same editor range, so revised phrases do not accumulate. One continuous recording remains one item and one take for arbitrary duration, with one commit when you stop and one authoritative completion. The gateway compacts finalized audio while retaining at most 30 seconds of resident, queued, and actively decoding PCM, so recording duration is not capped at 30 seconds. When you stop, the authoritative completion replaces the hypothesis and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; other committed takes may finish independently. - -Dictation splices the transcript into the current selection, behaving like typing at the cursor. Newlines in the transcript become line breaks. Dictating over a selection replaces the selection outright. Consecutive takes compose, because each take captures the cursor position fresh at record start. You never see stale transcription text from a previous take: takes are numbered per connection, and frames from a superseded take are discarded. - -While a take records, the input locks against typing and shows a recording ring, so the insertion geometry cannot be disturbed. You can still press Enter to send what the box shows. Sending during a take sends the visible text, interim transcript included, and discards the take. Discarding a live take, for example by closing the tab or starting a new session, restores the pre-take text and unlocks the input. An empty take tells you no speech was detected, with the number of captured audio frames. - -The status bar shows a red recording LED while the microphone is capturing, and the mic button shows a solid danger-colored fill with a matching ring while recording. - -## When the mic does nothing - -The mic stays visible and clickable in every state. Dictation is gated by the agent's pending input wait. Clicking it at another time names the blocker on the status bar: "The agent isn't asking for input; the mic opens when it does." The first eligible click may connect the Realtime session and ask you to try again in a moment; the session then reconnects with bounded backoff after a dropped connection. - -Failures during dictation are named too. Microphone permission denial or capture failure is named on the status bar. A dropped dictation connection is reported on the status bar, including drops before the final transcript lands. Arbitrary-duration capture requires the accurate transcription worker to keep pace on average. If it falls behind until all 30 retained seconds are owned, Workshop stops capture, preserves the already accepted visible transcript, flushes the microphone, and commits the still-valid input without clearing or rolling it back. Other server errors retain the ordinary failure behavior and restore the pre-take text. A browser without microphone, audio, or WebSocket support is told "Dictation is not available in this browser." - -Under the hood, the Workshop serves a payload-opaque Realtime socket at `/v1/realtime`. Browser capture applies echo cancellation and noise suppression, resamples to 24 kHz, converts samples to signed little-endian PCM16, and sends canonical Base64 audio appends. Stop flushes the capture worklet before committing the input buffer, so the final short block is included. - -## Microphone permission on each platform - -Each platform handles the microphone grant differently: - -- On Windows, the application grants the microphone permission automatically. You are never interrupted by a microphone permission prompt. Every other permission kind keeps the normal browser behavior. -- On Linux, the application turns on media capture in its webview and grants microphone and camera capture requests automatically. Other permission requests, such as notifications and geolocation, remain denied by default. -- On macOS, the application holds the audio-input entitlement that permits microphone capture for local dictation. The system permission prompt explains: "PromptForge uses the microphone you select for local voice dictation." - -If microphone setup fails at startup, you can keep working in the application and only voice input stays unavailable. - -## Voice configuration - -Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[stt]` section of the gateway boot config: - -```` -[stt] -window_seconds = 15 -interval_ms = 500 -```` - -You can add a `vocabulary` list of domain terms to bias recognition: - -```` -vocabulary = ["MCP", "GGUF", "Lua"] -```` - -Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and the gateway saves only `[stt]`. - -First run provisions two recommended speech-to-text models: `whisper-base-en` for interim results and `whisper-small-en` for final results. They download from Hugging Face with pinned sha256 checksums and stated VRAM requirements of 1.0 GB and 2.0 GB. The generated configuration boots the gateway into a profile named `default` that activates both provisioned whisper models. - -You can now speak or type your prompts. The next chapter teaches you to give the agent files to work on by granting folders to the workspace. - diff --git a/guide/src/workshop/08-workspace.md b/guide/src/workshop/08-workspace.md deleted file mode 100644 index bb561ed8..00000000 --- a/guide/src/workshop/08-workspace.md +++ /dev/null @@ -1,58 +0,0 @@ -# The Workspace - -You can converse with an agent. This chapter teaches you to give the agent files to work on. The Workshop never roams your disk on its own: you grant it access to specific folders, and the Workshop tree panel on the left shows you exactly what you have granted. By the end you will know how to grant folders, browse them, and take access away. - -## Granting a folder - -The fastest way to grant a folder is drag and drop. In the desktop application, drop a folder onto the window and it becomes a workspace root. Dropping a single file grants the application access to the file's parent folder instead of just the file. On Windows you can drop files or folders straight from Explorer, and the application receives the real OS paths of the dropped items. Each successfully dropped path is confirmed on the status bar with a message naming the path. When one dropped path cannot be opened, the status bar shows an error for that path and the remaining dropped paths are still added. - -- Dropping a file onto the window never by itself gives the application access to the file's bytes. The page grants each dropped path through the workspace API first. -- Dropping files onto the window never navigates the page away from your session. In-page drags such as panel tab drags keep their normal behavior; only drags of OS files are intercepted. - -You can also add a folder without dragging. Click the header "+" button labeled "Add Folder to Workspace...", or right-click empty space in the panel and choose the same item. In the desktop application you pick a folder through the native folder picker. In a plain browser you type the path into an "Add Folder to Workspace" dialog. The drop-to-grant feature is desktop only; in a plain browser, dropping files keeps the normal HTML drag/drop behavior of reading file contents and never grants workspace access. - -The outcome of adding or removing a folder is always announced on the status bar, as a success or an error. Grants registered through any session are visible to every open session immediately, and open panels such as the Workshop tree refresh automatically to show new grants. - -Folder grants are held in memory. Until you save a workspace they last only for the current session; once a workspace file is open, every grant and removal is written into it as it happens. The next chapter covers workspace files. - -## Browsing the tree - -The Workshop tree lists the granted workspace roots and browses one directory at a time. When no folder is selected, the panel shows the granted folders as the top level of the tree. When no folders are granted, you see the hint "Drop a folder onto the window to browse it here." - -Each granted folder row shows the folder's own name rather than the full path, with the full path available as the row tooltip. A drive root shows its path. Directory listings show folders before files, each group sorted alphabetically by name. Each entry includes its name, full path, kind (directory or file), byte size, and modification time. Browsing is paths only: the tree lists names and never reads file contents. - -To browse: - -1. Click a directory's chevron to expand it. Click again to collapse it. -2. Click a file to open it in the editor zone. The Editor chapter covers what happens next. - -Your expansion state and fetched listings persist for the session. Closing and reopening the Workshop panel restores the tree as it was left. A directory load failure appears as an error row inside the affected list, exposed to assistive technology as an alert. Pressing Ctrl+Shift+F activates the file tree and moves keyboard focus into it, even while the tree is empty. - -A granted folder that has been deleted from disk still appears in the panel, flagged as missing so you can clean it up: a struck-through name in the danger color plus a "missing" text label. - -## Confined access - -The grant boundary is enforced, not cosmetic. You cannot open, list, or save any path outside the granted folders; the application refuses with a "path is outside every granted root" error. - -The refusal messages are precise about what went wrong: - -- Paths containing `..` are refused before any disk access, however they were encoded, with "path contains a forbidden component". On Windows, file names containing a colon are refused. -- A path that is not a regular file fails with "path is not a file". -- A tree listing for something that is not a directory fails with "path is not a directory". -- A missing path reports "path does not exist". - -Nested grants are independent. Revoking a parent folder's grant leaves a separately granted child intact, and files under the child stay reachable. - -Dropped paths keep their native spelling, including backslashes, spaces, and Unicode characters. Any Windows verbatim prefix is removed. On older WebView2 runtimes, Explorer drops degrade gracefully instead of failing the application. - -## Revoking a grant - -To take access away: - -1. Right-click the root row of the granted folder. -2. Choose "Remove from Workspace". - -Files under the removed folder lose access on their next operation. Removing an unknown root reports "path is not a granted root". A root deleted from disk stays removable, so you can always clean up a missing entry. - -You can now grant folders and browse them. The next chapter teaches workspace files, which remember those grants between launches. - diff --git a/guide/src/workshop/09-workspace-files.md b/guide/src/workshop/09-workspace-files.md deleted file mode 100644 index e33e7e30..00000000 --- a/guide/src/workshop/09-workspace-files.md +++ /dev/null @@ -1,104 +0,0 @@ -# Workspace Files - -You can grant folders and browse them. This chapter teaches you to keep that arrangement: a workspace file remembers your granted folders and your window layout, so they come back the next time you launch. By the end you will know how to save a workspace, open one, duplicate one, and what a workspace file does and does not hold. - -## What a workspace file is - -A workspace is a single file with the extension `.pfwork`. It is an ordinary file you can see in your file manager, copy, move, back up, and delete. Inside, it is a small embedded database; you never need to look inside it, but if you are curious, any Turso or SQLite inspector opens it. - -A workspace file holds your arrangement of that workspace: - -- The granted folders, in the order you granted them. The folders themselves are not copied; the file remembers their paths. -- The window's size, position, and maximized state. -- The panel layout, which folders are expanded in the tree, and the list of editors you have closed (for Reopen Closed Editor). - -That is all. Your files stay where they are on disk, and your agent sessions are unaffected. The workspace is a bag of preferences, not a project archive. The "What persists" section below spells out what lives in the workspace and what follows you between workspaces. - -The workspace commands use native file dialogs, so they are desktop only. In a plain browser the three File menu rows are disabled. - -## Ephemeral until saved - -When you launch the Workshop for the first time, or open no workspace, you are working in an ephemeral workspace. Everything works exactly as in the previous chapter, and nothing is remembered: folder grants and the window layout last only for the current session. This is the state the previous chapter described when it said grants are held in memory. - -To start remembering, save the workspace once. From then on there is nothing more to save. - -## Saving a workspace - -1. Open the File menu. -2. Choose "Save Workspace As...". -3. In the save dialog, pick a folder and a name. The dialog suggests `Untitled.pfwork` for an ephemeral workspace and the current workspace's name otherwise. The `.pfwork` extension is added for you if you leave it off. - -The Workshop creates exactly one file at the path you chose. It does not create a folder around it. The current grants and window layout are written into it, the Workshop switches to it, and the file appears under File > Open Recent. - -While the Workshop has a workspace open, a second file named `Name.pfwork-wal` may sit beside it. It is the database's write-ahead log, holding the most recent changes until they are folded into the workspace file, which happens when you quit. It is not a stray: leave it alone while the Workshop is running. If you want to copy or back up a workspace, quit first so the workspace is one complete file. - -From now on every change is saved as it happens. Grant a folder and it lands in the file; remove one and it leaves the file; move or resize the window and the new geometry is saved a moment after you stop dragging, and once more when you close the window. There is no unsaved state, no dirty marker, and no Save command, because the file is a live mirror of what you see. - -If you save while a workspace is already open, you get a second file with the same grants and layout and the Workshop switches to the new one. The original stays where it is, unchanged from that point on. - -## Reopening at launch - -The Workshop remembers which workspace was open when you last quit. When you launch it again, that workspace is reopened before the window appears: your granted folders are back in the tree and the window opens at its saved size and position. - -If the file has been moved, deleted, or damaged since, the Workshop starts with an ephemeral workspace instead and notes the reason in its log. Launch never fails because of a workspace file. - -## Opening a workspace - -1. Open the File menu. -2. Choose "Open Workspace from File...". -3. Pick a `.pfwork` file in the file dialog. - -The file's grants replace your current grants entirely, the tree refreshes, and the window moves to the file's saved geometry. Opening a workspace is the same trust gesture as dropping a folder onto the window: you are deliberately granting the Workshop access to the folders the file names, and every restored folder is visible in the tree. A granted folder that no longer exists on disk still appears, flagged as missing, so you can remove it. - -A file that is not a PromptForge workspace is refused with a message naming the file, and a workspace saved by a newer version of the Workshop is refused with the version it needs. In both cases nothing changes: your current grants stay, and the refused file is not touched. - -Recently opened and saved workspaces are listed under File > Open Recent in their own group above recently opened files. Choosing a workspace there opens it directly, with no file dialog, exactly as if you had picked it under "Open Workspace from File...". The same refusals apply: a damaged or newer-version file is declined with a message and your current workspace stays. - -## Duplicating a workspace - -1. Open the File menu. -2. Choose "Duplicate Workspace...". -3. Pick a folder and a name for the copy. - -The Workshop makes a complete, independent copy of the current workspace and switches to it. Changes you make afterwards go to the copy; the original is untouched, and vice versa. If no workspace file is open, there is nothing to copy, so Duplicate behaves exactly like Save Workspace As: a new file is created from the current grants and layout. - -Save Workspace As and Duplicate Workspace look alike today because a workspace is one file. They differ in what travels. Save As means "my preferences under a new name": only the workspace file is written. Duplicate means "the whole world comes along": in future versions, when a workspace has grown companion folders beside it (see below), Duplicate copies them too and Save As leaves them with the original. - -## Companion folders - -A workspace file may in future gain sibling folders beside it, created only when there is something to put in them: `agents/` for agent databases, `runs/` for saved runs, and so on. They are plain folders with plain names, so their relationship to the workspace file is self-evident in your file manager. Nothing in the current version creates them. - -Because siblings are named for their role rather than for the workspace, two `.pfwork` files in the same folder would share them. Keep one workspace per folder. The Workshop does not stop you from doing otherwise, but you will find the arrangement confusing later. - -## What persists - -The Workshop remembers your interface state in two buckets, split by whether the state belongs to a workspace or to you. - -The workspace bucket lives in the `.pfwork` file and comes back whenever that workspace is open: - -- The granted folders and the window geometry, as described above. -- The panel layout: which panels are open, where they sit, and their sizes. -- Which folders are expanded in the Workshop tree. Restored folders load their listings on demand, so an expanded folder shows its children. -- The closed-editor list, so Reopen Closed Editor works across launches. - -The user bucket lives in the Workshop's own state directory and follows you from workspace to workspace: - -- Editor toggles: word wrap, rendered whitespace, control characters, column selection. -- The zoom level. -- Recent files and recent workspaces under File > Open Recent. -- The command palette's history. - -Both buckets save as you go. There is no Save command for either. While a workspace is ephemeral, the workspace bucket has nowhere to go and lasts only for the session; the user bucket saves regardless. - -Opening a workspace applies its bucket in place of what you see. The live layout is replaced by the file's layout, and every open editor is disposed, including editors with unsaved text, so save your work before you open another workspace. The tree collapses to the file's expanded folders. A restored agent panel is a panel, not a conversation: it starts a fresh session, and your earlier sessions stay in the state directory as before. Saving a workspace under a new name copies the live layout, tree, and closed-editor list into the new file so it opens as you left it. - -If either bucket cannot be read or written, the Workshop starts from defaults for that bucket, notes the reason in its log, and keeps working; nothing you do in the interface is blocked by a persistence failure. - -## What is not in the workspace - -- Your files. The workspace remembers paths, not contents. -- Agent sessions and their transcripts. Those live in the Workshop's own state directory, as before. -- Anything from before this version. Existing state is not imported; save a workspace to start one. -- Editor toggles, zoom, recent files, and command history. Those are yours, not the workspace's, and stay the same as you move between workspaces. - -You can now save, open, and duplicate workspaces, and you know which of your settings travel with a workspace and which follow you. The next chapter teaches the editor, where you open and change the files those folders contain. diff --git a/guide/src/workshop/10-editor.md b/guide/src/workshop/10-editor.md deleted file mode 100644 index 4449fa3a..00000000 --- a/guide/src/workshop/10-editor.md +++ /dev/null @@ -1,60 +0,0 @@ -# The Editor - -You have granted folders and you can browse them in the Workshop tree. This chapter teaches you to open the files those folders contain, edit them, and save them safely. The editor is where reading the agent's work and making your own changes happen, and it is built so you never lose text or silently overwrite someone else's. - -## Opening a file - -To open a file, click it in the Workshop tree. The file opens in its own tabbed editor panel in the main zone, with one panel per file. The tab title shows the file's base name rather than its full path. - -You can open a text file from a granted folder and see its full contents, up to a 1 MiB size limit. The editor targets source text, not media. A larger read fails with an error that states the byte limit. Binary files cannot be edited; the attempt is rejected with "file is binary, not text". Files that are not valid UTF-8 are rejected with "file is not utf-8 text". - -The editing surface is a CodeMirror-based text editor. Syntax highlighting is chosen automatically from the file extension: JavaScript, TypeScript, JSX, TSX, Python, Rust, JSON, Markdown, YAML, and TOML. Files with unknown or missing extensions open as plain text with no highlighting mode. You can search within the open document using the editor's built-in search panel, styled to match the application's dark theme. - -## Editing and saving - -Edit the text as you would in any code editor. A dot marker appears in the tab title when the document has unsaved changes, and clears when the document is clean again. - -To save the active editor, press Ctrl+S. The shortcut does nothing when no editor is active. To close the active editor, press Ctrl+W; a clean panel closes immediately. To move between open editors, press Ctrl+Tab to cycle forward and Ctrl+Shift+Tab to cycle in reverse, wrapping around at the ends. - -You can create a new file inside a granted folder by saving to a path that does not exist yet. - -Saves are atomic. You never see a half-written file or a leftover temporary file after a save. A crash or power loss during a save leaves either the old contents or the new, never a truncation. You also never lose unsaved typing to a slow save: edits made while a save write is still in flight remain marked as unsaved after the save completes. Triggering a second save while one is in flight does nothing, so you cannot stack overlapping writes. - -Load and save failures appear as an alert bar above the editor. The newest error replaces the previous one. The editor also warns when a panel opens with no file path. - -## Conflicts - -When you save a file that changed on disk since it was read, the save is refused with a conflict instead of silently overwriting. Each save sends the version token from the previous successful write, so the editor never silently overwrites a file that changed elsewhere. You get a "File changed on disk" dialog with two choices: - -- Reload discards the editor's text and loads the on-disk text. -- Overwrite writes your changes over the file on disk, re-reading the fresh token first so the write succeeds. - -## Closing with unsaved changes - -Closing a panel with unsaved changes opens an "Unsaved changes" dialog with three choices: - -- Save writes the file and closes the panel. -- Discard abandons your changes and closes the panel. -- Cancel returns you to the editor. - -A failed or conflicted save leaves the panel open. The panel closes only after a successful write. - -## Dialogs and read-only mode - -Modal prompts, such as the editor's conflict and close prompts and the tree's Add Folder prompt, appear as a themed dialog box overlaid on the panel you are working in, dimming the rest of that panel. Dialog behavior is consistent across panels: - -- You read a title and a message line at the top of each prompt. -- Prompts can show a labeled single-line text field. -- When a dialog opens, focus moves into it, landing in the text field or on the first button. -- Destructive actions are styled as danger buttons. -- Value-dependent buttons stay disabled until you type something. -- Enter inside the text field submits the dialog through its primary button. -- Escape dismisses the dialog without taking any action. -- Tab and Shift+Tab cycle focus within the dialog's controls and cannot escape to the panel behind it. -- When the dialog closes, focus returns to the element that had focus before the dialog opened. -- Re-invoking an already-open dialog does nothing. - -You can toggle the editor between editable and read-only without losing the document, the undo history, or the view state. When the workspace reloads a file from the server, the reload lands in place as one marked transaction instead of an editor rebuild: you keep undo history, selection, and scroll position, and you can undo back across the reload. A reloaded file arrives clean and is not flagged as an unsaved change. - -You can now open, edit, and save workspace files with confidence. The final chapter teaches you to keep the application current and tuned: updates, the About dialog, and the Gateway Config panel. - diff --git a/guide/src/workshop/11-updates.md b/guide/src/workshop/11-updates.md deleted file mode 100644 index 60b99ceb..00000000 --- a/guide/src/workshop/11-updates.md +++ /dev/null @@ -1,61 +0,0 @@ -# Updates and Configuration - -You can operate the whole application: the window, the panels, the menus, the status bar, models, chat, voice, the workspace, and the editor. This final chapter teaches you to keep the Workshop current and tuned: the update flow, the About dialog, and the embedded Gateway Config panel. - -## Keeping the Workshop up to date - -The installed application automatically checks the latest GitHub Release shortly after startup and installs only cryptographically verified updates. Downloaded updates are verified against a pinned public key before installation, so tampered updates are rejected. The automatic check runs on the desktop application only, and update checks give up after 30 seconds rather than hanging. On Windows, updates install passively, applying with minimal interruption to your session. - -Platform notes: - -- On Linux the update flow is available only when running as an AppImage. Package-managed installations show the update flow as unsupported and never contact the update endpoint. -- In a plain browser session the update flow stays inert. -- Nightly builds do not produce updater artifacts, so a nightly install does not receive automatic in-app updates. - -When an update is available, you see a banner floating at the bottom-right corner of the window, above the status bar. The banner shows the new version number and a one-line summary of the release notes. You have two choices: - -- Click "Remind me later" to dismiss the banner and bring the prompt back later. -- Click "Update now" to start the update immediately. - -While an update downloads, installs, or restarts, a full-screen modal overlay takes over the window. You watch download progress as a percentage and a progress bar, with bytes received against the total size. After the download finishes, the application installs the update and restarts itself. - -When an update download or install fails, you see the failure reason and can dismiss the overlay with a Close button to return to the application. You can expand an "Update log" section in the overlay to read the raw log lines produced during the update. When the application is already up to date, the update state reports that no update is available. When an update check fails, you see an error message. - -## The About dialog - -Open Help > About PromptForge to see the About dialog. It names the product, the application version, and the license, shown as "License: BSL-1.0". A development build shows the version "dev" instead of a release number. - -The About dialog is also where you trigger an update check manually. The update button reflects the state: - -- "Desktop updates unavailable" in a browser. -- "Updates are managed by your package manager" on package-managed installs. -- "Checking for updates..." while a check runs. -- "Show update " when an update is ready. -- "Retry update check" after a failed check. - -The About dialog traps keyboard focus: Tab and Shift+Tab cycle between its buttons and never leave the modal. You can dismiss it with the Escape key or the Close button, and focus returns to the element that opened it. Only one About dialog can be open at a time. - -## The Gateway Config panel - -You can view and change gateway configuration without leaving the Workshop, in the Gateway Config panel. The panel opens in the main zone through the application's Gateway Config command, titled "Gateway Config". Opening it a second time focuses the existing panel instead of opening a duplicate, and you can close it from its tab's close action. - -The panel embeds the gateway's configuration web interface, served same-origin through the Workshop at the `/gateway/config/` route in panel mode. It opens in the dark theme on the local gateway view. From the panel you can: - -- View the gateway's current configuration. -- Edit and save gateway configuration and environment values. -- Apply or revert pending configuration changes, and see whether the configuration has unsaved edits or changes waiting to be applied. -- Search and browse Hugging Face models. -- View gateway status, system information, model information, chat templates, environment, and orphaned files. -- View the downloaded model cache and delete a cached model to free disk space. -- Trigger the gateway's reveal action. - -Panel actions are announced on the Workshop status bar: "Gateway configuration applied", "Gateway configuration changes reverted", and "Gateway download started". Long-running panel operations such as cache downloads can stream for minutes without being cut off by a timeout. When the gateway is unreachable, the panel reports the failure instead of hanging. - -You never handle the gateway access key. The Workshop server attaches the bearer key on the server side of every forwarded panel request. Neither the Workshop page nor the embedded config panel ever sees it, and the key is never written to logs. The panel's API requests go through an allowlisted proxy; anything outside the configuration surface is refused, including chat completions, progress subscriptions, health checks, and direct cache uploads. Deleting a cached model is allowed only by its 64-character lowercase hex digest. Requests with malformed or absolute targets are refused locally with a forbidden status before anything leaves the application. The panel is reachable only from your own machine, never from the local network, and the embedded configuration interface runs in a restricted sandbox limited to running scripts within the same origin. - -## Reskinning the interface - -If you build the Workshop from source, you can reskin the entire interface by editing CSS custom properties in the `:root` block of `ui/style.css`. Every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a custom property there. To reskin without editing the shipped stylesheet, add a `` after `/style.css` in `ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade. Focus on menus and controls is shown through state backgrounds, opacity, or underlines, never through outline rings or focus boxes. - -You have completed the tour. You can install and start the Workshop, read its window and status bar, pick models and switch profiles, converse with an agent by keyboard or voice, grant folders, edit files, and keep the application current and configured. - diff --git a/guide/src/workshop/index.md b/guide/src/workshop/index.md deleted file mode 100644 index f51238ca..00000000 --- a/guide/src/workshop/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# The Workshop - -- [The Application](01-application.md) -- [The Workbench](02-workbench.md) -- [Menus and Commands](03-menus.md) -- [The Status Bar](04-status-bar.md) -- [Models and Profiles](05-models.md) -- [The Chat Surface](06-chat.md) -- [Voice Input](07-voice.md) -- [The Workspace](08-workspace.md) -- [Workspace Files](09-workspace-files.md) -- [The Editor](10-editor.md) -- [Updates and Configuration](11-updates.md) diff --git a/tools/document.md b/tools/document.md index feb4cdbd..8d121c6c 100644 --- a/tools/document.md +++ b/tools/document.md @@ -30,10 +30,10 @@ This tool rebuilds the PromptForge user guides. It reads the repository sources. ## Dispatch -Run variable: LENS. Values: `workshop`, `gateway`, `language`, `agent`, `intro`, `all`. +Run variable: LENS. Values: `gateway`, `language`, `agent`, `intro`, `all`. - LENS names a set: run the pipeline for that one lens. -- LENS is empty or `all`: run `workshop`, `gateway`, `language`, `agent` in that order. Then run `intro`. Then run the assembler with `cargo run -p build-user-guide`. +- LENS is empty or `all`: run `gateway`, `language`, `agent` in that order. Then run `intro`. Then run the assembler with `cargo run -p build-user-guide`. Each lens block declares the audience, the target paths, the extraction guidance, the noise filter, the output directory, and the template shape. @@ -100,15 +100,6 @@ The `intro` lens runs a reduced pipeline. It has no extract stage and no tier st ## Lens blocks - -Audience: the end user of the Workshop desktop application. -Targets: `crates/workshop/shell/`, `crates/workshop/server/`, including `crates/workshop/ui/src/`. -Extract: what the user sees and operates. The chat and agent surface. The editor. The status bar. The menus. Voice input. The update flow. Routes and protocol only where they produce user-visible behavior. -Noise: Rust internals, wire protocol details, test infrastructure. -Output: `guide/src/workshop/`. -Template: the Tour. Dependency order. Each chapter builds on the last. - - Audience: the gateway operator. Targets: `crates/gateway/` (the whole family: `app/`, `config/`, `config-ui/`, `local/`, `logging/`, `protocol/`, `routing/`, `web-search/`, `stt/`), `crates/shared-loopback/`, `gateway.local.example.toml`. diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 44833a5b..7a5d724f 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -661,7 +661,7 @@ Components, in dependency order: -### Step 2: Delete the workshop's human docs +### Step 2: Delete the workshop's human docs [completed] - Component: Workshop docs removal - Piece: docs removal From 8a17ee0bc7f52a4f3aa963ab713005c30551c15b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 13:57:34 -0700 Subject: [PATCH 03/44] Fail the symlink tests under CI and cover the jail's edge cases Replace the two silent symlink skips in the workspace tests with a CI-aware helper that panics when CI is set, so a runner that cannot create a symlink turns the job red instead of skipping silently. Pin the confinement behavior for the path-spelling tricks a request can arrive in. - `crates/workshop/workspace/src/workspace-tests-jail.rs` adds `symlink_unavailable(ci, reason)` and jail tests for verbatim `\\?\` spellings, UNC spellings, case-only respellings, and a directory junction. - The two skip sites in `workspace-tests.rs` now call `jail::symlink_unavailable` with `std::env::var_os("CI").is_some()`. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .../workspace/src/workspace-tests-jail.rs | 163 ++++++++++++++++++ .../workshop/workspace/src/workspace-tests.rs | 6 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 3 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 crates/workshop/workspace/src/workspace-tests-jail.rs diff --git a/crates/workshop/workspace/src/workspace-tests-jail.rs b/crates/workshop/workspace/src/workspace-tests-jail.rs new file mode 100644 index 00000000..3ea378cf --- /dev/null +++ b/crates/workshop/workspace/src/workspace-tests-jail.rs @@ -0,0 +1,163 @@ +//! Jail edge cases: the path-spelling tricks that must never escape the +//! grants, and the CI-aware helper that turns a silent symlink skip into +//! a CI failure. +//! +//! The confinement pipeline in `workspace-confine.rs` rejects `..` and +//! Windows alternate-data-stream colons lexically, canonicalizes the rest +//! (resolving symlinks, junctions, case, and verbatim `\\?\` prefixes), and +//! prefix-matches the canonical path against the canonical grants. These +//! tests pin that behavior for the spellings a request can arrive in. + +use super::*; + +/// Turns a silent skip into a failure under CI, and prints the reason +/// otherwise so the caller can `return`. The `ci` flag is read by the +/// caller through `std::env::var_os("CI").is_some()`, so no test ever calls +/// `std::env::set_var`, which is `unsafe` in Rust 2024 and forbidden here. +pub(super) fn symlink_unavailable(ci: bool, reason: &str) { + assert!(!ci, "{reason}"); + eprintln!("skipping: {reason}"); +} + +#[test] +#[should_panic(expected = "symlink creation failed")] +fn the_ci_flag_turns_a_skip_into_a_failure() { + symlink_unavailable(true, "symlink creation failed"); +} + +#[test] +fn without_ci_a_skip_prints_and_returns() { + symlink_unavailable(false, "symlink creation failed"); +} + +#[cfg(windows)] +fn verbatim(path: &Path) -> PathBuf { + // `\\?\` is the Win32 verbatim (extended-length) prefix: the same file, + // spelled a different way. Build it from the simplified DOS form so the + // test always exercises the prefix; `canonicalize_simplified` strips it. + PathBuf::from(format!("\\\\?\\{}", simplified(path).display())) +} + +#[cfg(windows)] +fn unc(path: &Path) -> PathBuf { + // `\\localhost\C$\...` is the administrative-share spelling of a local + // path; it canonicalizes to a UNC form that never matches a local grant. + let text = simplified(path).to_string_lossy().into_owned(); + let drive = &text[..1]; + let rest = &text[3..]; + PathBuf::from(format!("\\\\localhost\\{drive}$\\{rest}")) +} + +#[cfg(windows)] +#[test] +fn a_verbatim_spelling_of_a_granted_path_is_admitted() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + fs::write(&file, "hello").expect("seed the granted file"); + let read = workspace + .read_file(&verbatim(&file)) + .expect("a verbatim spelling of a granted path reads"); + assert_eq!(read.text, "hello"); +} + +#[cfg(windows)] +#[test] +fn a_verbatim_spelling_of_an_ungranted_path_is_rejected() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + fs::write(dir.path().join("a.txt"), "a").expect("seed the ungranted file"); + let error = workspace + .read_file(&verbatim(&dir.path().join("a.txt"))) + .expect_err("a verbatim spelling of an ungranted path is rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} + +#[cfg(windows)] +#[test] +fn a_unc_spelling_never_escapes_the_grants() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + fs::write(dir.path().join("a.txt"), "a").expect("seed the local file"); + // The UNC spelling either canonicalizes to a form that no local grant + // prefix-matches (OutsideGrants) or fails to resolve on a host without + // the administrative share (NotFound or ResolvePath). It must never + // admit the path. + let error = workspace + .read_file(&unc(&dir.path().join("a.txt"))) + .expect_err("a UNC spelling must never be admitted"); + assert!( + matches!( + error, + WorkspaceError::OutsideGrants + | WorkspaceError::NotFound + | WorkspaceError::ResolvePath { .. } + ), + "expected a rejection, got {error:?}" + ); +} + +#[cfg(windows)] +#[test] +fn a_unc_spelling_of_a_granted_path_is_rejected() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + fs::write(&file, "hello").expect("seed the granted file"); + // A UNC spelling canonicalizes to a UNC form that never prefix-matches a + // local grant, so even a granted path is refused (OutsideGrants); on a + // host without the administrative share the resolution fails instead + // (NotFound or ResolvePath). It is never admitted. + let error = workspace + .read_file(&unc(&file)) + .expect_err("a UNC spelling of a granted path is never admitted"); + assert!( + matches!( + error, + WorkspaceError::OutsideGrants + | WorkspaceError::NotFound + | WorkspaceError::ResolvePath { .. } + ), + "expected a rejection, got {error:?}" + ); +} + +#[cfg(windows)] +#[test] +fn a_case_only_respelling_of_a_granted_root_is_admitted() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + fs::write(&file, "hello").expect("seed the granted file"); + let respelled = PathBuf::from(file.to_string_lossy().to_ascii_uppercase()); + let read = workspace + .read_file(&respelled) + .expect("a case-only respelling of a granted path reads"); + assert_eq!(read.text, "hello"); +} + +#[cfg(windows)] +#[test] +fn a_junction_inside_a_grant_pointing_outside_is_rejected() { + let (workspace, dir) = granted_dir(); + let outside = tempfile::TempDir::new().expect("outside tempdir"); + fs::write(outside.path().join("secret.txt"), "secret").expect("seed the secret"); + let junction = dir.path().join("junction"); + let outcome = std::process::Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(&junction) + .arg(outside.path()) + .output(); + let created = matches!(&outcome, Ok(output) if output.status.success()); + if !created { + symlink_unavailable(std::env::var_os("CI").is_some(), "junction creation failed"); + return; + } + let error = workspace + .read_file(&junction.join("secret.txt")) + .expect_err("a junction escape must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} diff --git a/crates/workshop/workspace/src/workspace-tests.rs b/crates/workshop/workspace/src/workspace-tests.rs index 9d97d05d..39114935 100644 --- a/crates/workshop/workspace/src/workspace-tests.rs +++ b/crates/workshop/workspace/src/workspace-tests.rs @@ -6,6 +6,8 @@ use super::*; mod backing; #[path = "workspace-tests-grants.rs"] mod grants; +#[path = "workspace-tests-jail.rs"] +mod jail; #[path = "workspace-tests-pointer.rs"] mod pointer; #[path = "workspace-tests-ui-state.rs"] @@ -140,7 +142,7 @@ fn a_symlink_escape_is_rejected() { let linked = std::os::windows::fs::symlink_dir(outside.path(), &link); let Ok(()) = linked else { // Symlink creation needs a privilege some Windows hosts lack. - eprintln!("skipping: symlink creation failed"); + jail::symlink_unavailable(std::env::var_os("CI").is_some(), "symlink creation failed"); return; }; let error = workspace @@ -164,7 +166,7 @@ fn a_dangling_symlink_write_is_rejected() { let linked = std::os::windows::fs::symlink_file(&target, &link); let Ok(()) = linked else { // Symlink creation needs a privilege some Windows hosts lack. - eprintln!("skipping: symlink creation failed"); + jail::symlink_unavailable(std::env::var_os("CI").is_some(), "symlink creation failed"); return; }; let error = workspace diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 7a5d724f..4544e504 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -681,7 +681,7 @@ Components, in dependency order: -### Step 3: Fail the symlink tests under CI and cover the jail's edge cases +### Step 3: Fail the symlink tests under CI and cover the jail's edge cases [completed] - Component: Trustworthy tests - Piece: flaky-tests and security-tests, the workspace half of each From 6b23dd754ac16d4544549415df4076d012919fed Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 14:11:17 -0700 Subject: [PATCH 04/44] Replace fixed sleeps in workshop tests with event-driven waits Remove the wall-clock delays from four workshop tests so the suite no longer depends on fixed timing. A stalled-browser relay test drops a redundant sleep and lets its timeout-bounded read wait for the cleanup, a recovery fixture parks on a channel gate instead of a five-second sleep, and two quiet-window assertions advance the paused clock or read after the end-of-stream signal instead of sleeping. - `realtime_relay/overload.rs` drops the 750 ms sleep; the `RECV_TIMEOUT` read already waits for the relay to release the stalled browser. - `shell/src/gateway/tests/recovery.rs` parks the hanging child on an mpsc gate the test drops, instead of a five-second thread sleep. - `heartbeat_loop/startup_convergence.rs` advances the paused clock across the quiet window instead of sleeping four intervals. - `chat_gate.rs` turns the quiet-window helper into a zero-deadline read after the models frame, the catalog's end-of-stream signal. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- crates/workshop/server/tests/it/chat_gate.rs | 9 ++++++--- crates/workshop/server/tests/it/chat_gate/lifecycle.rs | 4 ++-- .../tests/it/heartbeat_loop/startup_convergence.rs | 6 +++++- .../workshop/server/tests/it/realtime_relay/overload.rs | 1 - crates/workshop/shell/src/gateway/tests/recovery.rs | 6 +++++- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/workshop/server/tests/it/chat_gate.rs b/crates/workshop/server/tests/it/chat_gate.rs index be6f4e1d..bdc65d85 100644 --- a/crates/workshop/server/tests/it/chat_gate.rs +++ b/crates/workshop/server/tests/it/chat_gate.rs @@ -266,9 +266,12 @@ async fn launch_chat(socket: &mut JsonSocket) -> String { .to_owned() } -/// Asserts that no input wait or error arrives during `duration`. -async fn assert_chat_quiet(socket: &mut JsonSocket, duration: Duration) { - let frame = tokio::time::timeout(duration, socket.recv_json()).await; +/// Asserts that no input wait or error is buffered. The models frame the +/// test has just received is the catalog's end-of-stream signal: with no +/// chat-capable model selected the agent stays dormant, so a zero-deadline +/// read reports any premature wait without a wall-clock quiet window. +async fn assert_chat_quiet(socket: &mut JsonSocket) { + let frame = tokio::time::timeout(Duration::ZERO, socket.recv_json()).await; assert!( frame.is_err(), "chat must stay dormant until a chat-capable catalog exists, got {frame:?}" diff --git a/crates/workshop/server/tests/it/chat_gate/lifecycle.rs b/crates/workshop/server/tests/it/chat_gate/lifecycle.rs index 448d9453..7a509a73 100644 --- a/crates/workshop/server/tests/it/chat_gate/lifecycle.rs +++ b/crates/workshop/server/tests/it/chat_gate/lifecycle.rs @@ -76,7 +76,7 @@ async fn gate_delayed_catalog_starts_chat_only_after_a_chat_model_arrives() { .await; assert_eq!(initial["models"], json!([])); - assert_chat_quiet(&mut socket, Duration::from_millis(150)).await; + assert_chat_quiet(&mut socket).await; server.state.catalog().publish(vec![ json!({"id": "whisper-base-en", "kind": "transcription", "object": "model"}), json!({"id": "whisper-small-en", "kind": "transcription", "object": "model"}), @@ -95,7 +95,7 @@ async fn gate_delayed_catalog_starts_chat_only_after_a_chat_model_arrives() { json!([]), "the shared catalog feeding both model menus publishes no speech-only choices" ); - assert_chat_quiet(&mut socket, Duration::from_millis(150)).await; + assert_chat_quiet(&mut socket).await; server.state.catalog().publish(vec![ json!({"id": "whisper-base-en", "kind": "transcription", "object": "model"}), diff --git a/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs b/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs index 331105ef..d2e35941 100644 --- a/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs +++ b/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs @@ -145,7 +145,11 @@ async fn a_healthy_gateway_retries_refresh_until_its_catalog_is_ready() { ); let requests_after_restore = state.requests.load(Ordering::Relaxed); - tokio::time::sleep(TEST_INTERVAL * 4).await; + // Advance the paused clock across several ticks instead of sleeping on + // the wall clock, so the quiet window costs no real time. + tokio::time::pause(); + tokio::time::advance(TEST_INTERVAL * 4).await; + tokio::time::resume(); assert_eq!( state.requests.load(Ordering::Relaxed), requests_after_restore, diff --git a/crates/workshop/server/tests/it/realtime_relay/overload.rs b/crates/workshop/server/tests/it/realtime_relay/overload.rs index c98cb27b..767c70ed 100644 --- a/crates/workshop/server/tests/it/realtime_relay/overload.rs +++ b/crates/workshop/server/tests/it/realtime_relay/overload.rs @@ -16,7 +16,6 @@ async fn stalled_browser_cleanup_is_bounded_after_gateway_disconnect() { .await .expect("the Gateway fills the relay's browser send"); - tokio::time::sleep(std::time::Duration::from_millis(750)).await; let first = tokio::time::timeout(RECV_TIMEOUT, socket.next()) .await .expect("bounded relay cleanup releases the stalled browser"); diff --git a/crates/workshop/shell/src/gateway/tests/recovery.rs b/crates/workshop/shell/src/gateway/tests/recovery.rs index d618c3ca..74f3b866 100644 --- a/crates/workshop/shell/src/gateway/tests/recovery.rs +++ b/crates/workshop/shell/src/gateway/tests/recovery.rs @@ -262,6 +262,7 @@ fn dropping_a_candidate_signals_without_waiting_for_an_unresponsive_child() { let gateway = validated_gateway("hanging-key"); let reference = gateway.validate("hanging-key", 1_778_000_001, "2026-09-08T18:00:01Z"); let hang = Arc::new(AtomicBool::new(false)); + let (_hang_tx, hang_rx) = std::sync::mpsc::channel::<()>(); let listener = TcpListener::bind("127.0.0.1:0").expect("bind the hanging fixture"); let port = listener.local_addr().expect("the fixture address").port(); std::thread::spawn({ @@ -271,7 +272,10 @@ fn dropping_a_candidate_signals_without_waiting_for_an_unresponsive_child() { let mut buffer = [0_u8; 1024]; if hang.load(Ordering::SeqCst) { let _ = stream.read(&mut buffer); - std::thread::sleep(Duration::from_secs(5)); + // Park the shutdown connection until the test drops the + // gate, so the child stays unresponsive for as long as + // the test needs, without a fixed wall-clock delay. + let _ = hang_rx.recv(); continue; } while let Ok(read) = stream.read(&mut buffer) { diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 4544e504..400debff 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -701,7 +701,7 @@ Components, in dependency order: -### Step 4: Replace fixed sleeps with event-driven waits +### Step 4: Replace fixed sleeps with event-driven waits [completed] - Component: Trustworthy tests - Piece: flaky-tests From 62d14c97d81a7d4dfbfb8872902b140010c2c594 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 14:39:53 -0700 Subject: [PATCH 05/44] Add unit tests for the realtime relay's refusals Pin the realtime relay's two refusals with tests against a live server. Each test asserts the exact status and empty body of a rejected upgrade, one for an origin outside the allowlist and one for a requested subprotocol, so the refusals are recorded as the browser sees them without changing production behavior. - `request_with` builds the upgrade request with an optional origin and subprotocol, so both refusal tests share one handshake path and differ only in the rejected header. - `refused_response` runs the handshake and returns the HTTP response that refused it, turning the socket error into the status and body each test asserts. - `a_foreign_origin_is_refused_with_an_empty_forbidden_body` asserts a foreign origin is refused with status 403 and an empty body. - `a_requested_subprotocol_is_refused_with_an_empty_bad_request_body` asserts a requested subprotocol is refused with status 400 and an empty body. - `realtime.rs` gains only the `#[cfg(test)]` `#[path = "realtime-tests.rs"] mod tests;` block, so the relay's production behavior is pinned without being changed. Design: new pure-function @ crates/workshop/server/src/routes/realtime-tests.rs::request_with deps: &str,Option<&str>,Option<&str> Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .../server/src/routes/realtime-tests.rs | 95 +++++++++++++++++++ crates/workshop/server/src/routes/realtime.rs | 4 + vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 3 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 crates/workshop/server/src/routes/realtime-tests.rs diff --git a/crates/workshop/server/src/routes/realtime-tests.rs b/crates/workshop/server/src/routes/realtime-tests.rs new file mode 100644 index 00000000..caef57fd --- /dev/null +++ b/crates/workshop/server/src/routes/realtime-tests.rs @@ -0,0 +1,95 @@ +//! Realtime relay refusal tests: the origin allowlist and the +//! no-subprotocol rule, asserted against a live server so each refusal's +//! status and body are pinned exactly as the browser sees them. + +use axum::http::StatusCode; +use tokio_tungstenite::tungstenite::Error as SocketError; +use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; + +use crate::app::fixtures::config_for; + +/// Builds a `/v1/realtime` upgrade request with an optional `Origin` and an +/// optional requested subprotocol. +fn request_with( + base: &str, + origin: Option<&str>, + subprotocol: Option<&str>, +) -> tokio_tungstenite::tungstenite::http::Request<()> { + let address = base + .strip_prefix("http://") + .expect("the server URL is http"); + let mut request = format!("ws://{address}/v1/realtime") + .into_client_request() + .expect("the WebSocket request builds"); + if let Some(origin) = origin { + request.headers_mut().insert( + "origin", + origin + .parse() + .expect("the test Origin is a valid header value"), + ); + } + if let Some(subprotocol) = subprotocol { + request.headers_mut().insert( + "sec-websocket-protocol", + subprotocol + .parse() + .expect("the test subprotocol is a valid header value"), + ); + } + request +} + +/// Runs the handshake and returns the HTTP response that refused it. +async fn refused_response( + request: tokio_tungstenite::tungstenite::http::Request<()>, +) -> tokio_tungstenite::tungstenite::http::Response>> { + let error = tokio_tungstenite::connect_async(request) + .await + .expect_err("the WebSocket handshake is refused"); + let SocketError::Http(response) = error else { + panic!("the refusal is an HTTP response, got {error:?}"); + }; + *response +} + +#[tokio::test] +async fn a_foreign_origin_is_refused_with_an_empty_forbidden_body() { + let state_dir = tempfile::TempDir::new().expect("tempdir"); + let mut config = config_for("http://127.0.0.1:1", state_dir.path()); + config.server.bind = "127.0.0.1:0".to_string(); + let server = crate::serve::spawn_resolved(config).expect("server spawns"); + + let response = refused_response(request_with( + server.url(), + Some("https://evil.example"), + None, + )) + .await; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + response.body().as_deref().is_none_or(<[u8]>::is_empty), + "the refusal body is empty" + ); + + server.shutdown().expect("graceful shutdown succeeds"); +} + +#[tokio::test] +async fn a_requested_subprotocol_is_refused_with_an_empty_bad_request_body() { + let state_dir = tempfile::TempDir::new().expect("tempdir"); + let mut config = config_for("http://127.0.0.1:1", state_dir.path()); + config.server.bind = "127.0.0.1:0".to_string(); + let server = crate::serve::spawn_resolved(config).expect("server spawns"); + + let response = refused_response(request_with(server.url(), None, Some("realtime"))).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!( + response.body().as_deref().is_none_or(<[u8]>::is_empty), + "the refusal body is empty" + ); + + server.shutdown().expect("graceful shutdown succeeds"); +} diff --git a/crates/workshop/server/src/routes/realtime.rs b/crates/workshop/server/src/routes/realtime.rs index 288c0b97..ee47e60a 100644 --- a/crates/workshop/server/src/routes/realtime.rs +++ b/crates/workshop/server/src/routes/realtime.rs @@ -180,3 +180,7 @@ async fn close_browser(browser: &mut WebSocket) { async fn close_gateway(gateway: &mut GatewayRealtimeSocket) { let _bounded = tokio::time::timeout(RELAY_IO_DEADLINE, gateway.close(None)).await; } + +#[cfg(test)] +#[path = "realtime-tests.rs"] +mod tests; diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 400debff..fabea138 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -718,7 +718,7 @@ Components, in dependency order: -### Step 5: Pin the realtime relay refusals +### Step 5: Pin the realtime relay refusals [completed] - Component: Trustworthy tests - Piece: security-tests From 82c9b744eb5e69f80bf5e7904696970e06edd0ef Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 15:31:45 -0700 Subject: [PATCH 06/44] Pin the /ws frames in a shared fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workshop socket's wire frames are now pinned by one shared fixture asserted on both the Rust and TypeScript sides, so a drift on either side fails that side's test. The model-selection event gains a typed frame beside the profile-selection event, and the server parses it the same typed way rather than reading the model field out of the raw JSON. Both suites assert the same case list, so a frame added on one side no longer goes unnoticed on the other. - `SelectModelFrame` — a typed inbound frame beside `SwitchProfileFrame`: equality over its single `model` field, no methods, no id, re-exported from the crate root. - `workshop-frames.json` — one fixture keyed by case name holds the six `/ws` frames, so both suites pin the same wire contract. - `workshop_frames` — the Rust test serializes each server-to-client frame and deserializes each client-to-server frame against the fixture, and pins the exact case list. - `workshop-wire-fixtures.mjs` — the TypeScript test drives the socket through every fixture frame and checks the client sends match their entries. - `select_model` — no behavior change: the refusal text is unchanged and no existing frame gains or loses a field. Design: new value-object @ crates/workshop/protocol/src/menu.rs::SelectModelFrame boundary: wire Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- crates/workshop/protocol/src/lib.rs | 20 ++- crates/workshop/protocol/src/menu.rs | 18 +- .../tests/fixtures/workshop-frames.json | 23 +++ crates/workshop/protocol/tests/it/main.rs | 1 + .../protocol/tests/it/workshop_frames.rs | 110 ++++++++++++ .../server/src/agents/session-menu.rs | 6 +- crates/workshop/ui/src/services/protocol.ts | 16 +- .../ui/src/services/workshop-socket.ts | 4 +- .../ui/test/workshop-wire-fixtures.mjs | 156 ++++++++++++++++++ vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 10 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 crates/workshop/protocol/tests/fixtures/workshop-frames.json create mode 100644 crates/workshop/protocol/tests/it/workshop_frames.rs create mode 100644 crates/workshop/ui/test/workshop-wire-fixtures.mjs diff --git a/crates/workshop/protocol/src/lib.rs b/crates/workshop/protocol/src/lib.rs index 657e0612..c2006b01 100644 --- a/crates/workshop/protocol/src/lib.rs +++ b/crates/workshop/protocol/src/lib.rs @@ -11,9 +11,12 @@ //! fixture `tests/fixtures/agent-frames.json`, asserted as the same JSON //! by the fixture test here and by the SPA suite's //! `crates/workshop/ui/test/agent-wire-fixtures.mjs`, so drift on either -//! side fails that side's tests. The wire shapes are additionally frozen -//! end to end by the characterization tests in `workshop-server`'s -//! `tests/it`. +//! side fails that side's tests. The workshop-socket frame family is +//! pinned the same way by `tests/fixtures/workshop-frames.json`, asserted +//! by the `workshop_frames` test here and by the SPA suite's +//! `crates/workshop/ui/test/workshop-wire-fixtures.mjs`. The wire shapes +//! are additionally frozen end to end by the characterization tests in +//! `workshop-server`'s `tests/it`. //! //! ## Invariants //! @@ -26,10 +29,11 @@ //! //! # Inbound workshop-socket frames //! -//! `{"type":"select_model","model":"..."}` selects the chat model: the -//! menu validates the id against the retained catalog and publishes a -//! fresh [`WorkbenchFrame`] on success; an unknown model is refused -//! with an `error` frame. `{"type":"switch_profile","name":"..."}` +//! `{"type":"select_model","model":"..."}` +//! ([`SelectModelFrame`]) selects the chat model: the menu validates the +//! id against the retained catalog and publishes a fresh +//! [`WorkbenchFrame`] on success; an unknown model is refused with an +//! `error` frame. `{"type":"switch_profile","name":"..."}` //! ([`SwitchProfileFrame`]) selects a gateway profile, `null` selecting //! no profile: the pending snapshot publishes immediately, the steps of //! the selection arrive as [`StatusFrame`]s, and the settled menu @@ -140,6 +144,6 @@ pub use agent::{ pub use catalog::{CatalogFrame, CatalogPush, is_chat_capable}; pub use error::{ErrorEnvelope, ErrorFrame}; pub use input::{InputFrame, InputResponse}; -pub use menu::SwitchProfileFrame; +pub use menu::{SelectModelFrame, SwitchProfileFrame}; pub use status::{Activity, Severity, StatusBarUpdate, StatusFrame}; pub use workbench::{WorkbenchFrame, WorkbenchSnapshot}; diff --git a/crates/workshop/protocol/src/menu.rs b/crates/workshop/protocol/src/menu.rs index 1b3c317c..0bb9edc3 100644 --- a/crates/workshop/protocol/src/menu.rs +++ b/crates/workshop/protocol/src/menu.rs @@ -1,7 +1,23 @@ -//! Inbound Model-menu frames: the profile selection event. +//! Inbound Model-menu frames: the model and profile selection events. use serde::{Deserialize, Deserializer}; +/// The inbound model selection: `{"type":"select_model","model":"..."}`. +/// +/// `model` is the retained catalog id to select; the key itself is +/// required, so a frame that omits it is malformed rather than an empty +/// selection. The session routes on the envelope's `type` and +/// deserializes the body with serde, which ignores the envelope tag and +/// the optional `id` the session echoes on a refusal. Like every inbound +/// frame it takes no delivery classification, because the server pushes +/// none. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] +pub struct SelectModelFrame { + /// The catalog id to select for chat. + pub model: String, +} + /// The inbound profile selection: `{"type":"switch_profile","name":...}`. /// /// `name` is a profile name, or `null` to select no profile; the key diff --git a/crates/workshop/protocol/tests/fixtures/workshop-frames.json b/crates/workshop/protocol/tests/fixtures/workshop-frames.json new file mode 100644 index 00000000..b35a4b01 --- /dev/null +++ b/crates/workshop/protocol/tests/fixtures/workshop-frames.json @@ -0,0 +1,23 @@ +{ + "error": { "type": "error", "message": "unknown model", "id": 3 }, + "models": { "type": "models", "models": [{ "id": "test-model", "object": "model" }] }, + "select_model": { "type": "select_model", "model": "test-model" }, + "status": { + "type": "status", + "label": "Ready", + "description": "idle", + "severity": "info", + "activity": "general", + "busy": false + }, + "switch_profile": { "type": "switch_profile", "name": "beta" }, + "workbench": { + "type": "workbench", + "profiles": ["main", "coding"], + "active": "main", + "switching": null, + "switch_in_flight": false, + "selected": "test-model", + "chat_ready": true + } +} diff --git a/crates/workshop/protocol/tests/it/main.rs b/crates/workshop/protocol/tests/it/main.rs index 6caa20d6..3ce38a01 100644 --- a/crates/workshop/protocol/tests/it/main.rs +++ b/crates/workshop/protocol/tests/it/main.rs @@ -2,3 +2,4 @@ mod fixture; mod frames; +mod workshop_frames; diff --git a/crates/workshop/protocol/tests/it/workshop_frames.rs b/crates/workshop/protocol/tests/it/workshop_frames.rs new file mode 100644 index 00000000..99e75388 --- /dev/null +++ b/crates/workshop/protocol/tests/it/workshop_frames.rs @@ -0,0 +1,110 @@ +//! The shared workshop-frame fixture pins: the same JSON the SPA suite +//! (`crates/workshop/ui/test/workshop-wire-fixtures.mjs`) asserts, so a +//! wire drift on either side fails that side's fixture test. + +use workshop_protocol::{ + Activity, CatalogPush, ErrorFrame, SelectModelFrame, Severity, StatusBarUpdate, + SwitchProfileFrame, WorkbenchSnapshot, +}; + +/// The shared workshop-frame fixture, asserted as the same JSON by the +/// SPA suite: a wire drift on either side fails that side's fixture test. +const WORKSHOP_FRAME_FIXTURE: &str = include_str!("../fixtures/workshop-frames.json"); + +/// Parses the shared fixture into one object keyed by case name. +fn workshop_fixture() -> serde_json::Value { + match serde_json::from_str(WORKSHOP_FRAME_FIXTURE) { + Ok(fixture) => fixture, + Err(error) => panic!("the fixture is valid JSON: {error}"), + } +} + +#[test] +fn the_shared_fixture_pins_exactly_the_agreed_case_list() { + let fixture = workshop_fixture(); + let mut cases: Vec<&str> = fixture + .as_object() + .expect("the fixture is one object keyed by case name") + .keys() + .map(String::as_str) + .collect(); + cases.sort_unstable(); + assert_eq!( + cases, + [ + "error", + "models", + "select_model", + "status", + "switch_profile", + "workbench", + ], + "both suites pin exactly the same case list, so a case added on \ + one side fails the other" + ); +} + +#[test] +fn server_to_client_workshop_frames_match_the_shared_fixture() { + // Each typed frame serializes to its fixture entry, compared as + // values so key order in the file is free. + let fixture = workshop_fixture(); + assert_eq!( + serde_json::to_value( + StatusBarUpdate { + label: "Ready".to_owned(), + description: "idle".to_owned(), + busy: false, + severity: Severity::Info, + activity: Activity::General, + } + .frame(), + ) + .expect("the frame serializes"), + fixture["status"] + ); + assert_eq!( + serde_json::to_value( + CatalogPush { + models: vec![serde_json::json!({"id": "test-model", "object": "model"})], + } + .frame(), + ) + .expect("the frame serializes"), + fixture["models"] + ); + assert_eq!( + serde_json::to_value( + WorkbenchSnapshot { + profiles: vec!["main".to_owned(), "coding".to_owned()], + active: Some("main".to_owned()), + switching: None, + switch_in_flight: false, + selected_model: Some("test-model".to_owned()), + chat_ready: true, + } + .frame(), + ) + .expect("the frame serializes"), + fixture["workbench"] + ); + let id = serde_json::json!(3); + assert_eq!( + serde_json::to_value(ErrorFrame::new("unknown model".to_owned(), Some(&id))) + .expect("the frame serializes"), + fixture["error"] + ); +} + +#[test] +fn client_to_server_workshop_frames_match_the_shared_fixture() { + // Both inbound frames parse through their typed bodies, which ignore + // the envelope tag and the optional `id` the session echoes. + let fixture = workshop_fixture(); + let select: SelectModelFrame = serde_json::from_value(fixture["select_model"].clone()) + .expect("the fixture select_model parses"); + assert_eq!(select.model, "test-model"); + let switch: SwitchProfileFrame = serde_json::from_value(fixture["switch_profile"].clone()) + .expect("the fixture switch_profile parses"); + assert_eq!(switch.name.as_deref(), Some("beta")); +} diff --git a/crates/workshop/server/src/agents/session-menu.rs b/crates/workshop/server/src/agents/session-menu.rs index 6fdc9347..245d7e44 100644 --- a/crates/workshop/server/src/agents/session-menu.rs +++ b/crates/workshop/server/src/agents/session-menu.rs @@ -15,7 +15,7 @@ use workshop_gateway::{ GatewayClient, GatewayError, GatewayResponse, GatewaySnapshot, SwitchResponse, }; use workshop_menu::{MenuBus, SwitchOutcome}; -use workshop_protocol::{Activity, SwitchProfileFrame}; +use workshop_protocol::{Activity, SelectModelFrame, SwitchProfileFrame}; use workshop_registry::Push; use crate::agents::relay::value_from_bytes; @@ -36,7 +36,7 @@ pub(super) async fn select_model( frame: &serde_json::Value, socket: &mut WebSocket, ) { - let Some(model) = frame.get("model").and_then(serde_json::Value::as_str) else { + let Ok(request) = serde_json::from_value::(frame.clone()) else { send_error(socket, id, "select_model needs a \"model\" string").await; return; }; @@ -44,7 +44,7 @@ pub(super) async fn select_model( send_error(socket, id, "the model menu is unavailable").await; return; }; - if let Err(refusal) = menu.set_selected(model) { + if let Err(refusal) = menu.set_selected(&request.model) { send_error(socket, id, refusal.to_string()).await; } } diff --git a/crates/workshop/ui/src/services/protocol.ts b/crates/workshop/ui/src/services/protocol.ts index 20ec78cd..e60d413c 100644 --- a/crates/workshop/ui/src/services/protocol.ts +++ b/crates/workshop/ui/src/services/protocol.ts @@ -9,7 +9,10 @@ // crates/workshop/protocol/tests/fixtures/agent-frames.json, // asserted as the same JSON by both suites (test/agent-wire-fixtures.mjs // here, the workshop-protocol fixture test there), so drift on either side fails -// that side's tests. +// that side's tests. The workshop-socket frame family is pinned the same +// way by crates/workshop/protocol/tests/fixtures/workshop-frames.json, +// asserted by test/workshop-wire-fixtures.mjs here and the workshop_frames +// fixture test there. /** One observer status update, as sent by the server. */ export interface StatusFrame { @@ -61,6 +64,17 @@ export interface WorkbenchFrame { chat_ready: boolean; } +/** + * The client frame selecting the chat model: + * `{"type":"select_model","model":"..."}`. The server validates the id + * against the retained catalog and publishes a fresh workbench snapshot + * on success; an unknown model is refused with an `error` frame. + */ +export interface SelectModelFrame { + type: "select_model"; + model: string; +} + // --- Agent-session frames (/agents/ws) -------------------------------------- // The Rust half of this family is the frame structs in // crates/workshop/protocol/src and the routing in diff --git a/crates/workshop/ui/src/services/workshop-socket.ts b/crates/workshop/ui/src/services/workshop-socket.ts index 723d38ae..7e3db585 100644 --- a/crates/workshop/ui/src/services/workshop-socket.ts +++ b/crates/workshop/ui/src/services/workshop-socket.ts @@ -7,7 +7,7 @@ import { Emitter, type Event } from "../base/event"; import { Disposable, toDisposable } from "../base/lifecycle"; -import type { CatalogModel, StatusFrame, WorkbenchFrame } from "./protocol"; +import type { CatalogModel, SelectModelFrame, StatusFrame, WorkbenchFrame } from "./protocol"; interface ServerFrame { type?: unknown; @@ -181,7 +181,7 @@ export class WorkshopSocket extends Disposable { * refusal from the server arrives as an error frame, not here. */ selectModel(id: string): boolean { - return this.sendFrame({ type: "select_model", model: id }); + return this.sendFrame({ type: "select_model", model: id } satisfies SelectModelFrame); } /** diff --git a/crates/workshop/ui/test/workshop-wire-fixtures.mjs b/crates/workshop/ui/test/workshop-wire-fixtures.mjs new file mode 100644 index 00000000..220873c9 --- /dev/null +++ b/crates/workshop/ui/test/workshop-wire-fixtures.mjs @@ -0,0 +1,156 @@ +// The TS half of the workshop-frame wire contract: every frame in the +// shared fixture crates/workshop/protocol/tests/fixtures/workshop-frames.json +// routes through WorkshopSocket unchanged (server-to-client), and every +// frame the socket sends matches its fixture entry byte-for-byte as parsed +// JSON (client-to-server). The Rust half is the fixture test in +// crates/workshop/protocol/tests/it/workshop_frames.rs; both suites pin the +// same case list, so a wire drift or a case added on one side fails the +// other. +// Run: node test/workshop-wire-fixtures.mjs +import { readFile, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { isDeepStrictEqual } from "node:util"; +import * as esbuild from "esbuild"; +import { assertNoLeaks } from "./helpers/leak-check.mjs"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +const bundle = await esbuild.build({ + stdin: { + contents: ` + export * as lifecycle from "./src/base/lifecycle.ts"; + export { WorkshopSocket } from "./src/services/workshop-socket.ts"; + `, + resolveDir: path.join(testDir, ".."), + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); + +const bundlePath = path.join(os.tmpdir(), "promptforge-workshop-wire-fixtures-test.mjs"); +await writeFile(bundlePath, bundle.outputFiles[0].text); +const { lifecycle, WorkshopSocket } = await import(pathToFileURL(bundlePath).href); + +const fixture = JSON.parse( + await readFile( + path.join(testDir, "..", "..", "..", "workshop", "protocol", "tests", "fixtures", "workshop-frames.json"), + "utf8", + ), +); + +const failures = []; +function check(name, condition) { + if (!condition) failures.push(name); +} + +// Both suites pin exactly the same case list, so a case added on one side +// fails the other. This list is mirrored by the Rust fixture test. +const CASES = [ + "error", + "models", + "select_model", + "status", + "switch_profile", + "workbench", +]; +check( + "the fixture holds exactly the cases both suites pin", + isDeepStrictEqual(Object.keys(fixture).sort(), CASES), +); + +const fakeSockets = []; +class FakeWebSocket { + static OPEN = 1; + readyState = 0; + sent = []; + onopen = null; + onclose = null; + onerror = null; + onmessage = null; + constructor(url) { + this.url = url; + fakeSockets.push(this); + } + send(data) { + this.sent.push(JSON.parse(data)); + } + close() { + this.readyState = 3; + } + // Test-side controls, not part of the WebSocket surface. + open() { + this.readyState = 1; + this.onopen?.(); + } + message(frame) { + this.onmessage?.({ data: JSON.stringify(frame) }); + } +} +globalThis.WebSocket = FakeWebSocket; + +await assertNoLeaks(lifecycle, async () => { + // --- Server-to-client: each fixture frame routes through unchanged ------ + + const socket = new WorkshopSocket("ws://fake/ws"); + const statuses = []; + const models = []; + const workbenches = []; + socket.onStatus((frame) => statuses.push(frame)); + socket.onModels((list) => models.push(list)); + socket.onWorkbench((frame) => workbenches.push(frame)); + socket.ready(); + socket.connect(); + const wire = fakeSockets[0]; + wire.open(); + + wire.message(fixture.status); + wire.message(fixture.models); + wire.message(fixture.workbench); + + check( + "the status fixture frame delivers verbatim", + isDeepStrictEqual(statuses, [fixture.status]), + ); + check( + "the models fixture frame delivers its catalog verbatim", + isDeepStrictEqual(models, [fixture.models.models]), + ); + check( + "the workbench fixture frame delivers verbatim", + isDeepStrictEqual(workbenches, [fixture.workbench]), + ); + + // The error frame is a refusal answered to an inbound event; the + // workshop socket has no emitter for it, so it must not surface as a + // push. + wire.message(fixture.error); + check( + "an error fixture frame does not surface as a push", + statuses.length === 1 && models.length === 1 && workbenches.length === 1, + ); + + // --- Client-to-server: each send matches its fixture entry -------------- + + socket.selectModel(fixture.select_model.model); + socket.switchProfile(fixture.switch_profile.name); + check( + "select_model and switch_profile sends match their fixture entries", + isDeepStrictEqual(wire.sent, [fixture.select_model, fixture.switch_profile]), + ); + socket.dispose(); +}); + +if (failures.length > 0) { + console.error(`workshop-wire-fixtures: ${failures.length} failure(s)`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("workshop-wire-fixtures: all assertions passed"); +process.exit(0); diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index fabea138..655aa7c5 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -731,7 +731,7 @@ Components, in dependency order: -### Step 6: Pin the /ws frames in a shared fixture +### Step 6: Pin the /ws frames in a shared fixture [completed] - Component: Trustworthy tests - Piece: wire-fixture From 5dc6a032d62494aeefef3923b142b6c1761efa1b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 16:05:55 -0700 Subject: [PATCH 07/44] Refuse non-loopback addresses in reuse_bind The workshop server may only bind to loopback, since a wildcard or LAN address would expose it to other hosts. The bind helper now refuses a non-loopback address with an invalid-input error before it creates a socket. Repairs: workshop server binds only to loopback @ crates/workshop/server/src/serve.rs::reuse_bind - non-loopback addresses were accepted, exposing the workshop server to other hosts Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- crates/workshop/server/src/serve-tests.rs | 37 ++++++++++++++++++++ crates/workshop/server/src/serve.rs | 6 ++++ vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/workshop/server/src/serve-tests.rs b/crates/workshop/server/src/serve-tests.rs index 3ce6bc15..f9b45d7b 100644 --- a/crates/workshop/server/src/serve-tests.rs +++ b/crates/workshop/server/src/serve-tests.rs @@ -323,3 +323,40 @@ fn a_bind_conflict_fails_spawn_with_io_error() { "expected Io, got {error:?}" ); } + +/// The server may only ever bind to loopback: a wildcard or LAN address +/// would expose the workshop to other hosts. `reuse_bind` refuses those +/// before it creates a socket, so the error is `InvalidInput` rather than +/// a late bind failure. +#[tokio::test] +async fn non_loopback_binds_are_refused_with_invalid_input() { + for address in ["0.0.0.0:0", "[::]:0", "192.168.1.10:0"] { + let error = reuse_bind(address).expect_err("a non-loopback address must be refused"); + assert_eq!( + error.kind(), + std::io::ErrorKind::InvalidInput, + "refusing {address} must be an InvalidInput error" + ); + } +} + +#[tokio::test] +async fn a_loopback_address_binds() { + let listener = reuse_bind("127.0.0.1:0").expect("a loopback address binds"); + drop(listener); +} + +/// A runner without IPv6 may fail an `[::1]` bind for a platform reason, +/// but the refusal itself must never be the loopback check, so the error +/// kind is anything but `InvalidInput`. +#[tokio::test] +async fn an_ipv6_loopback_bind_is_not_refused_with_invalid_input() { + match reuse_bind("[::1]:0") { + Ok(listener) => drop(listener), + Err(error) => assert_ne!( + error.kind(), + std::io::ErrorKind::InvalidInput, + "an IPv6 loopback bind must not be refused with InvalidInput" + ), + } +} diff --git a/crates/workshop/server/src/serve.rs b/crates/workshop/server/src/serve.rs index 780f8f5a..c3f9534e 100644 --- a/crates/workshop/server/src/serve.rs +++ b/crates/workshop/server/src/serve.rs @@ -328,6 +328,12 @@ fn reuse_bind(address: &str) -> std::io::Result { let addr: std::net::SocketAddr = address .parse() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + if !addr.ip().is_loopback() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("refusing to bind {addr}: the workshop server binds only to loopback"), + )); + } let socket = socket2::Socket::new( socket2::Domain::for_address(addr), socket2::Type::STREAM, diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 655aa7c5..8ab2211b 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -753,7 +753,7 @@ Components, in dependency order: -### Step 7: Refuse non-loopback binds +### Step 7: Refuse non-loopback binds [completed] - Component: Behavior fixes - Piece: bind From f6903e2df573449970c20c82709a4856f0f038eb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 16:32:29 -0700 Subject: [PATCH 08/44] Answer the route deadline with a JSON error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requests that run past their route deadline now answer a JSON error envelope instead of an empty 408 body. The envelope carries a machine-readable code and a message naming the elapsed deadline, so clients can distinguish a deadline failure from a raw timeout. A test-only stall makes a slow write deterministic, so the timeout and the write that still lands on disk are both reachable without waiting out the ten-second production deadline. - `DEADLINE_ELAPSED_CODE` — the machine-readable code `deadline_elapsed` is a public constant exported from support, so the middleware and the shape test share one source instead of a repeated string. - `deadline_elapsed_message` — the user-visible message is a pure function of the elapsed `Duration`, exported beside the code. - `routes_with_deadline` — a `test-fixtures`-gated builder binds the workspace router on a test deadline, so no production knob is added. - `workspace-stall.rs` — a test-only stall holds the next write on the blocking pool until the test releases it, making the timeout deterministic. - `save_timeout` — the end-to-end test asserts a `PUT /workspace/file` that outlasts its deadline answers a 408 whose content type is JSON and whose body equals `workshop_protocol::ErrorEnvelope::new(deadline_elapsed_message(TEST_DEADLINE), DEADLINE_ELAPSED_CODE)`. - `stall_wait` — the deadline abandons but does not cancel the blocking write; releasing the stall lets it still land on disk, which the test asserts. - `serde_json::Value` — the shape test compares parsed JSON values, so `ErrorEnvelope` gains no `Deserialize` and the protocol crate is untouched. Design: new surface-growth @ crates/workshop/support/src/deadline.rs [boundary: wire] Design: new pure-function @ crates/workshop/support/src/deadline.rs::deadline_elapsed_message [deps: Duration] [boundary: pub] Repairs: route-deadline 408 answers a JSON error envelope @ crates/workshop/support/src/deadline.rs - a write outlasting its deadline answered an empty 408 body Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- Cargo.lock | 1 + crates/workshop/server/Cargo.toml | 2 + crates/workshop/server/tests/it/main.rs | 1 + .../workshop/server/tests/it/save_timeout.rs | 75 +++++++++++ crates/workshop/support/Cargo.toml | 1 + crates/workshop/support/src/deadline.rs | 46 ++++++- crates/workshop/support/src/lib.rs | 5 +- crates/workshop/workspace/src/handlers.rs | 15 ++- crates/workshop/workspace/src/lib.rs | 7 ++ .../workshop/workspace/src/workspace-stall.rs | 119 ++++++++++++++++++ crates/workshop/workspace/src/workspace.rs | 6 + vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 12 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 crates/workshop/server/tests/it/save_timeout.rs create mode 100644 crates/workshop/workspace/src/workspace-stall.rs diff --git a/Cargo.lock b/Cargo.lock index 8a1d9a5c..20c5e942 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8627,6 +8627,7 @@ version = "0.0.0" dependencies = [ "axum", "serde", + "serde_json", "tempfile", "thiserror 2.0.19", "tokio", diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index be6adfb0..009f2fff 100644 --- a/crates/workshop/server/Cargo.toml +++ b/crates/workshop/server/Cargo.toml @@ -71,6 +71,8 @@ gateway-api-discovery = { workspace = true, features = ["test-fixtures"] } tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } tower.workspace = true +# The save-timeout test drives the workspace router directly. +workshop-workspace.workspace = true # The build script bundles the UI with esbuild into OUT_DIR through the # shared helper; nothing UI-built lands in the repository. diff --git a/crates/workshop/server/tests/it/main.rs b/crates/workshop/server/tests/it/main.rs index 6e782a60..cb8eac5b 100644 --- a/crates/workshop/server/tests/it/main.rs +++ b/crates/workshop/server/tests/it/main.rs @@ -10,6 +10,7 @@ mod chat_gate; mod heartbeat; mod heartbeat_loop; mod realtime_relay; +mod save_timeout; mod session; mod user_state; mod workspace_shutdown; diff --git a/crates/workshop/server/tests/it/save_timeout.rs b/crates/workshop/server/tests/it/save_timeout.rs new file mode 100644 index 00000000..76615f89 --- /dev/null +++ b/crates/workshop/server/tests/it/save_timeout.rs @@ -0,0 +1,75 @@ +//! The save-timeout behavior end to end: a `PUT /workspace/file` whose +//! write outlasts the route deadline answers a 408 whose body is the JSON +//! error envelope, and the blocking write - abandoned, not cancelled - +//! still lands on disk once the test releases it. + +use std::time::Duration; + +use axum::body::Body; +use axum::http::Request; +use axum::http::StatusCode; +use tower::ServiceExt; + +use workshop_workspace::Workspace; + +/// The test-only route deadline: short enough that the stalled write's +/// 408 is reachable without waiting out the production 10 seconds. +const TEST_DEADLINE: Duration = Duration::from_secs(1); + +#[tokio::test] +async fn a_write_that_outlasts_its_deadline_answers_408_and_still_lands() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let file = dir.path().join("note.txt"); + let workspace = Workspace::new(); + workspace.grant(dir.path()).expect("grant the tempdir"); + + // Arm the stall: the write blocks on the blocking pool until released, + // so the route deadline elapses first and its 408 is observable. + let stall = workspace.stall_next_write_for_test(); + + let router = workshop_workspace::routes_with_deadline(workspace, TEST_DEADLINE); + let request = Request::builder() + .method("PUT") + .uri("/workspace/file") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ "path": file, "text": "late write" }).to_string(), + )) + .expect("static request parts are valid"); + + let response = router + .oneshot(request) + .await + .expect("the router is infallible"); + + assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .map(axum::http::header::HeaderValue::as_bytes), + Some(b"application/json".as_slice()), + "the deadline answers JSON" + ); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already"); + let envelope: serde_json::Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + let expected = serde_json::to_value(workshop_protocol::ErrorEnvelope::new( + workshop_support::deadline_elapsed_message(TEST_DEADLINE), + workshop_support::DEADLINE_ELAPSED_CODE, + )) + .expect("the envelope serializes"); + assert_eq!(envelope, expected, "the 408 body is the wire envelope"); + + // The write was abandoned by the deadline, not cancelled: releasing the + // stall lets it land on disk. + stall.release(); + stall.await_completion(); + assert_eq!( + std::fs::read_to_string(&file).expect("the file exists"), + "late write", + "the released write still lands on disk" + ); +} diff --git a/crates/workshop/support/Cargo.toml b/crates/workshop/support/Cargo.toml index d0ea4537..a0dc5413 100644 --- a/crates/workshop/support/Cargo.toml +++ b/crates/workshop/support/Cargo.toml @@ -17,6 +17,7 @@ test-fixtures = [] [dependencies] axum.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tokio.workspace = true toml.workspace = true diff --git a/crates/workshop/support/src/deadline.rs b/crates/workshop/support/src/deadline.rs index fac1624a..bc9906f3 100644 --- a/crates/workshop/support/src/deadline.rs +++ b/crates/workshop/support/src/deadline.rs @@ -5,6 +5,7 @@ use std::time::Duration; +use axum::Json; use axum::Router; use axum::extract::Request; use axum::http::StatusCode; @@ -21,6 +22,21 @@ pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(10); /// route deadline. pub const RELAY_DEADLINE: Duration = Duration::from_secs(35); +/// The machine-readable wire code of a deadline-elapsed failure. Both UIs +/// key on this string, so it is a wire contract. +pub const DEADLINE_ELAPSED_CODE: &str = "deadline_elapsed"; + +/// The user-visible message a deadline-elapsed failure answers with: the +/// elapsed deadline in seconds, and that the abandoned operation may +/// still complete - a blocking write cannot be cancelled. +#[must_use] +pub fn deadline_elapsed_message(limit: Duration) -> String { + format!( + "the request did not finish within its {}s deadline; the operation may still complete", + limit.as_secs() + ) +} + /// Bounds every route already in `router` on `limit`: a response not /// produced by the deadline is abandoned and answered with 408 instead. /// @@ -38,7 +54,13 @@ where Ok(response) => response, Err(_elapsed) => { tracing::warn!(%uri, ?limit, "request deadline elapsed"); - StatusCode::REQUEST_TIMEOUT.into_response() + let body = serde_json::json!({ + "error": { + "message": deadline_elapsed_message(limit), + "code": DEADLINE_ELAPSED_CODE, + } + }); + (StatusCode::REQUEST_TIMEOUT, Json(body)).into_response() } } }, @@ -75,7 +97,7 @@ mod tests { "unreachable" }), ), - Duration::from_millis(50), + Duration::from_secs(1), ); let request = Request::builder() .uri("/stalled") @@ -86,6 +108,26 @@ mod tests { .await .expect("the router is infallible"); assert_eq!(response.status(), StatusCode::REQUEST_TIMEOUT); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .map(axum::http::HeaderValue::as_bytes), + Some(b"application/json".as_slice()), + "the deadline answers JSON" + ); + let body: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!( + body, + serde_json::json!({ + "error": { + "message": deadline_elapsed_message(Duration::from_secs(1)), + "code": DEADLINE_ELAPSED_CODE, + } + }), + "the 408 body is the error envelope" + ); } #[tokio::test] diff --git a/crates/workshop/support/src/lib.rs b/crates/workshop/support/src/lib.rs index 3667935a..fcde918b 100644 --- a/crates/workshop/support/src/lib.rs +++ b/crates/workshop/support/src/lib.rs @@ -26,4 +26,7 @@ pub use config::{ AgentsConfig, Config, ConfigError, DEFAULT_ADDR, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, }; -pub use deadline::{DEFAULT_DEADLINE, RELAY_DEADLINE, with_deadline}; +pub use deadline::{ + DEADLINE_ELAPSED_CODE, DEFAULT_DEADLINE, RELAY_DEADLINE, deadline_elapsed_message, + with_deadline, +}; diff --git a/crates/workshop/workspace/src/handlers.rs b/crates/workshop/workspace/src/handlers.rs index 2eebe432..e57b6fee 100644 --- a/crates/workshop/workspace/src/handlers.rs +++ b/crates/workshop/workspace/src/handlers.rs @@ -30,6 +30,19 @@ mod prompts; /// `/workspace/file/state` ui-state bucket from `file_state`. Every /// route runs under the default deadline tier. pub fn routes(state: Workspace) -> axum::Router { + build(state, DEFAULT_DEADLINE) +} + +/// The workspace routes bound on an explicit deadline, exposed only to +/// tests so the 408 is reachable without waiting out the production +/// 10-second default. +#[cfg(feature = "test-fixtures")] +pub fn routes_with_deadline(state: Workspace, limit: std::time::Duration) -> axum::Router { + build(state, limit) +} + +/// Assembles the workspace routes and bounds them on `limit`. +fn build(state: Workspace, limit: std::time::Duration) -> axum::Router { with_deadline( axum::Router::new() .route("/workspace/tree", get(tree)) @@ -40,7 +53,7 @@ pub fn routes(state: Workspace) -> axum::Router { .merge(file_state::routes()) .merge(prompts::routes()) .with_state(state), - DEFAULT_DEADLINE, + limit, ) } diff --git a/crates/workshop/workspace/src/lib.rs b/crates/workshop/workspace/src/lib.rs index 22d24c43..5b1722e3 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -33,9 +33,14 @@ mod handlers; pub mod handles; mod workspace; mod workspace_file; +#[cfg(feature = "test-fixtures")] +#[path = "workspace-stall.rs"] +mod workspace_stall; pub use error::WorkspaceError; pub use handlers::routes; +#[cfg(feature = "test-fixtures")] +pub use handlers::routes_with_deadline; pub use handles::{register, register_tasks}; pub use workspace::{ EntryKind, FileContents, GrantEntry, TreeEntry, TreeListing, Workspace, WorkspaceSummary, @@ -43,3 +48,5 @@ pub use workspace::{ #[cfg(any(test, feature = "test-fixtures"))] pub use workspace_file::create_alien_database_for_test; pub use workspace_file::{WindowState, WorkspaceFileError}; +#[cfg(feature = "test-fixtures")] +pub use workspace_stall::WriteStallHandle; diff --git a/crates/workshop/workspace/src/workspace-stall.rs b/crates/workshop/workspace/src/workspace-stall.rs new file mode 100644 index 00000000..70604b6f --- /dev/null +++ b/crates/workshop/workspace/src/workspace-stall.rs @@ -0,0 +1,119 @@ +//! A test-only write stall behind `test-fixtures`: it holds the next +//! [`Workspace::write_file`] on the blocking pool until the test releases +//! it, so the route deadline's 408 is reachable deterministically without +//! a slow real write. nextest runs one test per process, so the per-write +//! rendezvous held on the [`Workspace`] cannot leak across tests. +//! +//! [`Workspace`]: super::Workspace + +use std::sync::mpsc; +use std::sync::{Mutex, PoisonError}; + +use super::Workspace; + +/// The armed rendezvous for one stalled write: `release` blocks the +/// writer, `done` reports once the write has landed. +pub(crate) struct WriteStall { + armed: Mutex>, +} + +impl WriteStall { + pub(crate) fn new() -> Self { + Self { + armed: Mutex::new(None), + } + } +} + +impl std::fmt::Debug for WriteStall { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("WriteStall").finish_non_exhaustive() + } +} + +/// One armed stall's channels: the writer blocks on `release` and sends on +/// `done` once the write has landed. Neither is `Debug`, so the struct +/// stays plain data. +struct WriteRendezvous { + release: mpsc::Receiver<()>, + done: mpsc::Sender<()>, +} + +/// Signals that a released write has landed when dropped at the end of +/// [`Workspace::write_file`]. +pub(crate) struct WriteDone(mpsc::Sender<()>); + +impl Drop for WriteDone { + fn drop(&mut self) { + // The write may have been abandoned by the route deadline and never + // cancelled; a failed send just means the test stopped listening. + let _ = self.0.send(()); + } +} + +/// The test's end of an armed stall: releases the stalled write, then +/// reports once the write has landed on disk. +#[must_use] +pub struct WriteStallHandle { + release: mpsc::Sender<()>, + done: mpsc::Receiver<()>, +} + +impl std::fmt::Debug for WriteStallHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WriteStallHandle") + .finish_non_exhaustive() + } +} + +impl WriteStallHandle { + /// Lets the stalled write proceed. + pub fn release(&self) { + let _ = self.release.send(()); + } + + /// Blocks until the released write has landed on disk. + pub fn await_completion(&self) { + let _ = self.done.recv(); + } +} + +impl Workspace { + /// Arms a stall on the next write: the write blocks on the blocking + /// pool until the returned handle is released, after which + /// [`WriteStallHandle::await_completion`] reports the write landed. + pub fn stall_next_write_for_test(&self) -> WriteStallHandle { + let (release_tx, release_rx) = mpsc::channel(); + let (done_tx, done_rx) = mpsc::channel(); + *self + .stall + .armed + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(WriteRendezvous { + release: release_rx, + done: done_tx, + }); + WriteStallHandle { + release: release_tx, + done: done_rx, + } + } + + /// Blocks on the armed stall, if any, and returns a guard that reports + /// completion on drop. Runs on the blocking pool inside + /// [`Workspace::write_file`], where a blocking wait is expected. + pub(crate) fn stall_wait(&self) -> Option { + let rendezvous = self + .stall + .armed + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take()?; + // The route deadline may abandon this write while it waits; the + // blocking-pool task is not cancellable, so it stays here until the + // test releases it, then lands the write. + let _ = rendezvous.release.recv(); + Some(WriteDone(rendezvous.done)) + } +} diff --git a/crates/workshop/workspace/src/workspace.rs b/crates/workshop/workspace/src/workspace.rs index a374e05d..798de165 100644 --- a/crates/workshop/workspace/src/workspace.rs +++ b/crates/workshop/workspace/src/workspace.rs @@ -175,6 +175,8 @@ pub struct Workspace { /// Where the last-used file is remembered between runs; `None` when /// built without a state directory (see [`Workspace::with_state_dir`]). pointer: Option, + #[cfg(feature = "test-fixtures")] + pub(crate) stall: Arc, } impl Default for Workspace { @@ -187,6 +189,8 @@ impl Default for Workspace { switches: Arc::default(), closed: Arc::default(), pointer: None, + #[cfg(feature = "test-fixtures")] + stall: Arc::new(crate::workspace_stall::WriteStall::new()), } } } @@ -390,6 +394,8 @@ impl Workspace { Err(source) if source.kind() == io::ErrorKind::NotFound => {} Err(source) => return Err(WorkspaceError::InspectPath { source }), } + #[cfg(feature = "test-fixtures")] + let _done = self.stall_wait(); workshop_support::write_atomic(&canonical, text.as_bytes()) .map_err(|source| WorkspaceError::WriteFile { source })?; let metadata = diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 8ab2211b..d1e4ffb6 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -765,7 +765,7 @@ Components, in dependency order: -### Step 8: Answer the route deadline with a JSON 408 +### Step 8: Answer the route deadline with a JSON 408 [completed] - Component: Behavior fixes - Piece: save-timeout From 222247b1880f428541109ae5bb534e65899505d7 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 17:00:31 -0700 Subject: [PATCH 09/44] Render route timeouts as readable errors in the UIs When a request runs past the server's deadline, the answer may carry an empty body, which the transport floor misread as a JSON shape failure. The floor now recognizes the timeout and renders a readable timed-out message, while a deadline that carries the error envelope still shows the server's message. A new regression test covers both shapes through the write boundary. - `readJson` now returns `null` for a 408 whose body is empty or non-JSON, so the non-OK handler renders a readable timeout instead of a shape failure. - `errorMessage` now answers a 408 with the route timed out and still returns the envelope's message when one is present. - `json-request-timeout.mjs` pins both 408 shapes, including a write through the boundary. Repairs: route timeout renders readably @ crates/workshop/ui/src/services/json-request.ts::readJson - an empty 408 body surfaced a non-JSON shape failure Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .../workshop/ui/src/services/json-request.ts | 13 +- .../workshop/ui/test/json-request-timeout.mjs | 132 ++++++++++++++++++ vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 crates/workshop/ui/test/json-request-timeout.mjs diff --git a/crates/workshop/ui/src/services/json-request.ts b/crates/workshop/ui/src/services/json-request.ts index 3207e8e0..d0c622f6 100644 --- a/crates/workshop/ui/src/services/json-request.ts +++ b/crates/workshop/ui/src/services/json-request.ts @@ -18,6 +18,9 @@ export function errorMessage(body: unknown, status: number, route: string): stri if (isRecord(body) && isRecord(body.error) && typeof body.error.message === "string") { return body.error.message; } + if (status === 408) { + return `${route} timed out`; + } return `${route} answered ${status}`; } @@ -44,11 +47,19 @@ export async function request(url: string, route: string, init?: RequestInit): P } } -/** Parses one response body; a non-JSON answer is a shape failure. */ +/** + * Parses one response body; a non-JSON answer is a shape failure. A 408 + * timeout is the exception: its body may be empty or non-JSON, and reads + * as `null` so the caller's non-OK handler renders a readable timeout + * instead of a shape failure. + */ export async function readJson(response: Response, route: string): Promise { try { return await response.json(); } catch (error) { + if (response.status === 408) { + return null; + } throw new CatalogError(ErrorCatalog.UnexpectedShape, `${route} returned a non-JSON answer`, { status: response.status, cause: error, diff --git a/crates/workshop/ui/test/json-request-timeout.mjs b/crates/workshop/ui/test/json-request-timeout.mjs new file mode 100644 index 00000000..7185e917 --- /dev/null +++ b/crates/workshop/ui/test/json-request-timeout.mjs @@ -0,0 +1,132 @@ +// Unit test for the route-timeout rendering in the shared HTTP floor +// (src/services/json-request.ts) and its adoption at the write boundary +// (src/services/workspace-api.ts). Bundles the TS modules with esbuild and +// imports them via a data URL. Covers: a 408 whose body is the JSON error +// envelope yields the envelope's message and the `deadline_elapsed` code; a +// 408 whose body is empty reads as `null` and renders a readable timeout +// error, never the non-JSON-answer shape failure. +// Run: node --test test/json-request-timeout.mjs +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); + +const bundle = await esbuild.build({ + stdin: { + contents: ` + export * as jsonRequest from "./src/services/json-request.ts"; + export * as workspace from "./src/services/workspace-api.ts"; + `, + resolveDir: path.join(uiDir, ".."), + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); +const code = bundle.outputFiles[0].text; +const { jsonRequest, workspace } = await import( + `data:text/javascript;base64,${Buffer.from(code).toString("base64")}` +); + +const failures = []; +function check(name, condition) { + if (!condition) failures.push(name); +} + +// The server's deadline envelope, as support/deadline.rs answers it. +const TIMEOUT_MESSAGE = + "the request did not finish within its 10s deadline; the operation may still complete"; +const ENVELOPE = { error: { message: TIMEOUT_MESSAGE, code: "deadline_elapsed" } }; + +const jsonResponse = (status, body) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, +}); + +// An empty 408 body: json() rejects the way a real empty Response does. +const emptyResponse = { + ok: false, + status: 408, + json: async () => { + throw new SyntaxError("unexpected end of JSON input"); + }, +}; + +// Scripts globalThis.fetch per case: `respond` is a fake Response. +function withFetch(respond, run) { + const previous = globalThis.fetch; + globalThis.fetch = async () => respond; + return run().finally(() => { + globalThis.fetch = previous; + }); +} + +// --- The shared floor: a 408 JSON envelope yields message and code --------- + +{ + check( + "a 408 JSON envelope yields the envelope's message", + jsonRequest.errorMessage(ENVELOPE, 408, "PUT /workspace/file") === TIMEOUT_MESSAGE, + ); + check( + "a 408 JSON envelope yields the deadline_elapsed code", + jsonRequest.errorCode(ENVELOPE) === "deadline_elapsed", + ); +} + +// --- The shared floor: an empty 408 body reads as null, renders a timeout -- + +{ + const body = await jsonRequest.readJson(emptyResponse, "PUT /workspace/file"); + check("an empty 408 body reads as null", body === null); + check( + "an empty 408 body renders a readable timeout, not a non-JSON answer", + jsonRequest.errorMessage(body, 408, "PUT /workspace/file") === "PUT /workspace/file timed out", + ); +} + +// --- Through the write boundary: the envelope message reaches the caller --- + +await withFetch(jsonResponse(408, ENVELOPE), async () => { + let caught = null; + try { + await workspace.writeFile("/tmp/note.txt", "late write", null); + } catch (error) { + caught = error; + } + check( + "a write's 408 envelope keeps the server's timeout message", + caught !== null && caught.message === TIMEOUT_MESSAGE, + ); + check("a write's 408 envelope keeps the status", caught !== null && caught.status === 408); +}); + +await withFetch(emptyResponse, async () => { + let caught = null; + try { + await workspace.writeFile("/tmp/note.txt", "late write", null); + } catch (error) { + caught = error; + } + check( + "a write's empty 408 body renders a readable timeout", + caught !== null && caught.message === "PUT /workspace/file timed out", + ); + check( + "a write's empty 408 body never reports a non-JSON answer", + caught !== null && !caught.message.includes("non-JSON"), + ); +}); + +if (failures.length > 0) { + console.error(`json-request-timeout: ${failures.length} failure(s)`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("json-request-timeout: all assertions passed"); diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index d1e4ffb6..aa328c46 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -782,7 +782,7 @@ Components, in dependency order: -### Step 9: Render route timeouts readably in the UIs +### Step 9: Render route timeouts readably in the UIs [completed] - Component: Behavior fixes - Piece: save-timeout From 6d44740de1bc616d42b35f4079af87f00ec698cb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 17:21:43 -0700 Subject: [PATCH 10/44] Recover from a timed-out save through unknown token state A save that times out no longer leaves the editor assuming its conflict token is still valid. The editor marks the token unknown after a deadline and tells the user the write may or may not have landed. The next save re-reads the file, adopts the fresh token when the disk still holds what was last sent, and otherwise falls back to the conflict dialog, so a stale token never reaches the write. - `tokenUnknown` records an unknown-token state beside the known token, paired with `lastSentText` as the last write attempt; both reset whenever a read or write establishes a fresh token. - `DeadlineElapsed` adds a deadline_elapsed code to the error catalog, which `isDeadlineElapsed` narrows from a caught error. - `httpFailure` maps a 408 response to the deadline code rather than the generic HTTP status, and `save` re-reads the file while the token is unknown, adopting the fresh token on a disk match and showing the conflict dialog otherwise. - `token` never travels stale: no save path forwards a token the editor does not currently know. Design: extends dispatch-on-tag @ crates/workshop/ui/src/services/workspace-api.ts::httpFailure deps: number,string,unknown Design: new pure-function @ crates/workshop/ui/src/services/workspace-api.ts::isDeadlineElapsed deps: unknown Repairs: the editor never sends a stale token @ crates/workshop/ui/src/parts/editor/editor-panel.ts::save - the next save after a timed-out write re-sent the stale token Repairs: a 408 maps to the DeadlineElapsed catalog code @ crates/workshop/ui/src/services/workspace-api.ts::httpFailure - a timed-out write was reported as a generic HttpStatus error Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .../ui/src/parts/editor/editor-panel.ts | 50 ++- .../workshop/ui/src/services/error-catalog.ts | 2 + .../workshop/ui/src/services/workspace-api.ts | 8 + .../workshop/ui/test/editor-save-timeout.mjs | 291 ++++++++++++++++++ .../workshop/ui/test/json-request-timeout.mjs | 7 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 6 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 crates/workshop/ui/test/editor-save-timeout.mjs diff --git a/crates/workshop/ui/src/parts/editor/editor-panel.ts b/crates/workshop/ui/src/parts/editor/editor-panel.ts index cf398732..b42b95df 100644 --- a/crates/workshop/ui/src/parts/editor/editor-panel.ts +++ b/crates/workshop/ui/src/parts/editor/editor-panel.ts @@ -22,6 +22,7 @@ import { showPanelDialog } from "./editor-dialog"; import { CodeMirrorSurface, languageIdForPath, type EditorSurface } from "./editor-surface"; import { fetchFile, + isDeadlineElapsed, isModifiedConflict, writeFile, type WorkspaceFile, @@ -70,6 +71,10 @@ export class EditorPanel extends WorkshopPart { private untitled = false; private title = "Editor"; private token: string | null = null; + /** True while a timed-out save leaves the token unknown. */ + private tokenUnknown = false; + /** The text of the last write attempt, for reconciling an unknown token. */ + private lastSentText: string | null = null; private saving = false; constructor(private readonly deps: EditorPanelDeps = {}) { @@ -166,9 +171,13 @@ export class EditorPanel extends WorkshopPart { /** * Saves through the workspace API with the token from the last read. * A stale token means the file changed on disk: rather than overwriting - * silently, the conflict dialog offers reload or overwrite. An - * untitled buffer has no write target, so its save runs Save As, - * which resolves this panel through the dock's active panel. + * silently, the conflict dialog offers reload or overwrite. A timed-out + * save (a 408) leaves the token unknown - the write may or may not have + * landed - so the next save re-reads the file before sending any token, + * adopting the fresh token when the disk still holds what was last sent, + * and falling back to the conflict dialog otherwise. An untitled buffer + * has no write target, so its save runs Save As, which resolves this + * panel through the dock's active panel. */ async save(): Promise { if (this.path === null) { @@ -181,16 +190,36 @@ export class EditorPanel extends WorkshopPart { return; } this.saving = true; + // The text is captured once: the write and the saved baseline must + // agree, or keystrokes typed while the PUT is in flight would be + // baselined as saved and silently lost. + const text = this.surface.text(); try { - // The text is captured once: the write and the saved baseline must - // agree, or keystrokes typed while the PUT is in flight would be - // baselined as saved and silently lost. - const text = this.surface.text(); - const written = await this.writer()(this.path, text, this.token); + // A timed-out save left the token unknown: reconcile with the file + // on disk before sending any token, so a stale token never reaches + // the write boundary. + let expectedToken = this.token; + if (this.tokenUnknown) { + const onDisk = await this.reader()(this.path); + if (onDisk.text !== this.lastSentText) { + // The write may not have landed, or the file changed again: + // resolve through the conflict dialog instead of overwriting. + this.showConflictDialog(); + return; + } + this.token = onDisk.token; + expectedToken = onDisk.token; + this.tokenUnknown = false; + } + this.lastSentText = text; + const written = await this.writer()(this.path, text, expectedToken); this.token = written.token; this.surface.markSaved(text); } catch (error: unknown) { - if (isModifiedConflict(error)) { + if (isDeadlineElapsed(error)) { + this.tokenUnknown = true; + this.showError("The save timed out; the file may or may not have been written."); + } else if (isModifiedConflict(error)) { this.showConflictDialog(); } else { this.showError(error); @@ -222,6 +251,7 @@ export class EditorPanel extends WorkshopPart { this.untitled = false; this.title = baseName(path); this.token = written.token; + this.tokenUnknown = false; this.surface.markSaved(text); this.updateTitle(); getServiceOrNull(RECENT_FILES_STORE)?.add(path); @@ -291,6 +321,7 @@ export class EditorPanel extends WorkshopPart { private async load(path: string): Promise { const file = await this.reader()(path); this.token = file.token; + this.tokenUnknown = false; this.surface.open({ path, text: file.text }); } @@ -416,6 +447,7 @@ export class EditorPanel extends WorkshopPart { const text = this.surface.text(); const written = await this.writer()(this.path, text, fresh.token); this.token = written.token; + this.tokenUnknown = false; this.surface.markSaved(text); } catch (error: unknown) { if (isModifiedConflict(error)) { diff --git a/crates/workshop/ui/src/services/error-catalog.ts b/crates/workshop/ui/src/services/error-catalog.ts index 602abd76..ce478e02 100644 --- a/crates/workshop/ui/src/services/error-catalog.ts +++ b/crates/workshop/ui/src/services/error-catalog.ts @@ -23,6 +23,8 @@ export enum ErrorCatalog { UnexpectedShape = "unexpected_shape", /** The server refused a write because the file changed on disk. */ ModifiedConflict = "modified_conflict", + /** The server answered a write past its deadline: it may still land. */ + DeadlineElapsed = "deadline_elapsed", /** The server refused to grant a workspace root. */ GrantRefused = "grant_refused", } diff --git a/crates/workshop/ui/src/services/workspace-api.ts b/crates/workshop/ui/src/services/workspace-api.ts index b45c4ae7..db355ad0 100644 --- a/crates/workshop/ui/src/services/workspace-api.ts +++ b/crates/workshop/ui/src/services/workspace-api.ts @@ -45,6 +45,11 @@ export function isModifiedConflict(error: unknown): error is CatalogError { return isCatalogError(error, ErrorCatalog.ModifiedConflict); } +/** Narrows a caught error to a route deadline (408) from writeFile. */ +export function isDeadlineElapsed(error: unknown): error is CatalogError { + return isCatalogError(error, ErrorCatalog.DeadlineElapsed); +} + function parseEntry(value: unknown): TreeEntry | null { if (!isRecord(value)) { return null; @@ -93,6 +98,9 @@ function httpFailure(body: unknown, status: number, route: string): never { if (status === 409 && errorCode(body) === "modified_conflict") { throw new CatalogError(ErrorCatalog.ModifiedConflict, message, { status }); } + if (status === 408) { + throw new CatalogError(ErrorCatalog.DeadlineElapsed, message, { status }); + } throw new CatalogError(ErrorCatalog.HttpStatus, message, { status }); } diff --git a/crates/workshop/ui/test/editor-save-timeout.mjs b/crates/workshop/ui/test/editor-save-timeout.mjs new file mode 100644 index 00000000..bfa596aa --- /dev/null +++ b/crates/workshop/ui/test/editor-save-timeout.mjs @@ -0,0 +1,291 @@ +// Save-timeout test for the editor panel (src/parts/editor/editor-panel.ts): +// a 408 on save (the deadline_elapsed error) leaves the conflict token +// unknown, and the next save reconciles with the file on disk instead of +// re-sending a token that may now be stale. Covers four cases: the 408 +// marks the token unknown and sends no stale token; a disk match adopts +// the fresh token and saves; a mismatch shows the conflict dialog; and a +// late write that lands after the re-read surfaces the conflict dialog, +// not a raw error. Drives the real EditorPanel with a stubbed surface and +// scripted reader/writer, the same way editor-save-race.mjs does. +// Run: node test/editor-save-timeout.mjs +import { writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as esbuild from "esbuild"; +import { JSDOM } from "jsdom"; +import { assertNoLeaks } from "./helpers/leak-check.mjs"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); + +const bundle = await esbuild.build({ + stdin: { + contents: ` + export * as lifecycle from "./src/base/lifecycle.ts"; + export { EditorPanel } from "./src/parts/editor/editor-panel.ts"; + export { CatalogError, ErrorCatalog } from "./src/services/error-catalog.ts"; + `, + resolveDir: path.join(uiDir, ".."), + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", + // The panel imports its colocated CSS; strip it - the test drives only + // the JS, and jsdom applies no stylesheets anyway. + loader: { ".css": "empty" }, +}); + +const dom = new JSDOM("", { + url: "http://127.0.0.1:7913/", + pretendToBeVisual: true, +}); +const { window } = dom; + +for (const key of [ + "document", + "navigator", + "HTMLElement", + "Node", + "Element", + "Event", + "CustomEvent", + "KeyboardEvent", + "MutationObserver", + "getComputedStyle", + "requestAnimationFrame", + "cancelAnimationFrame", +]) { + if (!(key in globalThis) && key in window) { + globalThis[key] = window[key]; + } +} +globalThis.window = window; +globalThis.document = window.document; + +const bundlePath = path.join(os.tmpdir(), "promptforge-editor-save-timeout-test.mjs"); +await writeFile(bundlePath, bundle.outputFiles[0].text); +const { lifecycle, EditorPanel, CatalogError, ErrorCatalog } = await import( + pathToFileURL(bundlePath).href +); + +const failures = []; +function check(name, condition) { + if (!condition) failures.push(name); +} + +async function flush() { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +const FILE_PATH = "C:\\project\\save-timeout.txt"; + +// The panel's contract stub, mirroring editor-save-race.mjs: markSaved +// rebaselines against the written text, dirty recomputes against the live +// text. +function createStubSurface() { + const listeners = new Set(); + return { + element: window.document.createElement("div"), + currentText: "", + dirty: false, + open(document) { + this.currentText = document.text; + this.setDirty(false); + }, + text() { + return this.currentText; + }, + markSaved(text) { + this.setDirty(this.currentText !== text); + }, + isDirty() { + return this.dirty; + }, + setReadOnly() {}, + onDirtyChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + focus() {}, + dispose() {}, + setDirty(dirty) { + if (dirty === this.dirty) return; + this.dirty = dirty; + for (const listener of listeners) listener(dirty); + }, + type(text) { + this.currentText = text; + this.setDirty(true); + }, + }; +} + +function fakeParameters(filePath) { + return { params: { path: filePath }, api: { setTitle() {}, close() {} } }; +} + +// The typed failures the writer surfaces, matching the write boundary's +// codes so the panel's narrowing helpers recognize them. +const deadlineError = () => + new CatalogError(ErrorCatalog.DeadlineElapsed, "save timed out", { status: 408 }); +const conflictError = () => + new CatalogError(ErrorCatalog.ModifiedConflict, "file changed on disk", { status: 409 }); + +const errorBar = (panel) => panel.element.querySelector(".ws-editor-panel__error"); +const conflictOverlay = (panel) => panel.element.querySelector(".ws-editor-conflict-overlay"); + +await assertNoLeaks(lifecycle, async () => { + // --- A 408 leaves the token unknown and sends no stale token --------------- + { + const stub = createStubSurface(); + const puts = []; + let disk = { text: "old", token: "t100" }; + const panel = new EditorPanel({ + createSurface: () => stub, + readFile: async () => ({ + path: FILE_PATH, size: disk.text.length, token: disk.token, text: disk.text, + }), + writeFile: (filePath, text, expectedToken) => { + puts.push({ path: filePath, text, expectedToken }); + // The write never lands: the server answered 408 before it wrote. + return Promise.reject(deadlineError()); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + check( + "a 408 tells the user the save may not have landed", + errorBar(panel)?.textContent.includes("may or may not"), + ); + + await panel.save(); + check( + "an unknown token never sends the stale token on the next save", + puts.length === 1 && puts[0].expectedToken === "t100", + ); + panel.dispose(); + } + + // --- A disk match adopts the fresh token and saves ------------------------- + { + const stub = createStubSurface(); + const puts = []; + let disk = { text: "old", token: "t100" }; + let timedOut = true; + const panel = new EditorPanel({ + createSurface: () => stub, + readFile: async () => ({ + path: FILE_PATH, size: disk.text.length, token: disk.token, text: disk.text, + }), + writeFile: (filePath, text, expectedToken) => { + puts.push({ path: filePath, text, expectedToken }); + if (timedOut) { + timedOut = false; + // The late write lands: the disk now holds what was sent. + disk = { text, token: "t200" }; + return Promise.reject(deadlineError()); + } + return Promise.resolve({ path: filePath, size: text.length, token: "t300", text }); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + await panel.save(); + check( + "a disk match adopts the fresh token and saves with it", + puts.length === 2 && puts[1].expectedToken === "t200" && puts[1].text === "new", + ); + check("the adopted-token save clears the dirty state", !panel.isDirty()); + panel.dispose(); + } + + // --- A mismatch shows the conflict dialog ---------------------------------- + { + const stub = createStubSurface(); + const puts = []; + let disk = { text: "old", token: "t100" }; + const panel = new EditorPanel({ + createSurface: () => stub, + readFile: async () => ({ + path: FILE_PATH, size: disk.text.length, token: disk.token, text: disk.text, + }), + writeFile: (filePath, text, expectedToken) => { + puts.push({ path: filePath, text, expectedToken }); + return Promise.reject(deadlineError()); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + // The file changed externally while the write was unknown. + disk = { text: "external edit", token: "t500" }; + await panel.save(); + check("a mismatched disk shows the conflict dialog", conflictOverlay(panel) !== null); + check("a mismatch never writes with the stale token", puts.length === 1); + panel.dispose(); + } + + // --- A late write landing after the re-read shows the conflict dialog ------- + { + const stub = createStubSurface(); + const puts = []; + let disk = { text: "old", token: "t100" }; + let timedOut = true; + const panel = new EditorPanel({ + createSurface: () => stub, + readFile: async () => ({ + path: FILE_PATH, size: disk.text.length, token: disk.token, text: disk.text, + }), + writeFile: (filePath, text, expectedToken) => { + puts.push({ path: filePath, text, expectedToken }); + if (timedOut) { + timedOut = false; + // The late write lands before the re-read, so the disk matches. + disk = { text, token: "t200" }; + return Promise.reject(deadlineError()); + } + // The adopted token was fresh at re-read time, but a late write + // landed afterward and bumped the token: the write conflicts. + return Promise.reject(conflictError()); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + errorBar(panel)?.remove(); + await panel.save(); + check( + "a late write landing after the re-read shows the conflict dialog", + conflictOverlay(panel) !== null, + ); + check( + "a late write landing after the re-read is not a raw error", + errorBar(panel) === null, + ); + panel.dispose(); + } +}); + +if (failures.length > 0) { + console.error(`editor-save-timeout: ${failures.length} failure(s)`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("editor-save-timeout: all assertions passed"); +process.exit(0); diff --git a/crates/workshop/ui/test/json-request-timeout.mjs b/crates/workshop/ui/test/json-request-timeout.mjs index 7185e917..7446e176 100644 --- a/crates/workshop/ui/test/json-request-timeout.mjs +++ b/crates/workshop/ui/test/json-request-timeout.mjs @@ -17,6 +17,7 @@ const bundle = await esbuild.build({ contents: ` export * as jsonRequest from "./src/services/json-request.ts"; export * as workspace from "./src/services/workspace-api.ts"; + export { ErrorCatalog } from "./src/services/error-catalog.ts"; `, resolveDir: path.join(uiDir, ".."), loader: "ts", @@ -29,7 +30,7 @@ const bundle = await esbuild.build({ logLevel: "silent", }); const code = bundle.outputFiles[0].text; -const { jsonRequest, workspace } = await import( +const { jsonRequest, workspace, ErrorCatalog } = await import( `data:text/javascript;base64,${Buffer.from(code).toString("base64")}` ); @@ -105,6 +106,10 @@ await withFetch(jsonResponse(408, ENVELOPE), async () => { caught !== null && caught.message === TIMEOUT_MESSAGE, ); check("a write's 408 envelope keeps the status", caught !== null && caught.status === 408); + check( + "a write's 408 is typed as DeadlineElapsed, not HttpStatus", + caught !== null && caught.code === ErrorCatalog.DeadlineElapsed, + ); }); await withFetch(emptyResponse, async () => { diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index aa328c46..4706f9b9 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -797,7 +797,7 @@ Components, in dependency order: -### Step 10: Track an unknown save token in the editor +### Step 10: Track an unknown save token in the editor [completed] - Component: Behavior fixes - Piece: save-timeout From 3a40b0dea60df21f52c1349a640a923ac5a0f10c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 17:57:28 -0700 Subject: [PATCH 11/44] Delete WorkshopObserver, StatusBus helpers, and re-exports The workshop's append-only run-event log is deleted, and the gateway drops the engine dependency that the log imported. The status bus loses the convenience helpers that fixed a severity and activity, so its tests now push a fixed update through the bus directly. The server stops re-exporting the stale cache types and the alias to the deleted log. - `WorkshopObserver`: the shared, append-only run-event log and its broadcast fan-out are removed, and the gateway drops the `promptforge` dependency the log imported. - `StatusBus`: the `report`, `info`, `debug`, `error`, and `idle` helpers are removed; their tests now call `emit` with an explicit update. - `CacheEvent`, `CacheResponse`, and `SsePayloadStream`: the server stops re-exporting these cache types and the `observer` module alias. Design: removes shared-mutable-state @ crates/workshop/gateway/src/observer.rs::WorkshopObserver boundary: pub Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- Cargo.lock | 1 - crates/workshop/gateway/Cargo.toml | 4 +- crates/workshop/gateway/src/lib.rs | 2 - crates/workshop/gateway/src/observer-tests.rs | 165 ------------------ crates/workshop/gateway/src/observer.rs | 163 ----------------- crates/workshop/server/src/agents/status.rs | 3 +- crates/workshop/server/src/lib.rs | 9 +- crates/workshop/status/src/status.rs | 76 ++------ vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 9 files changed, 23 insertions(+), 402 deletions(-) delete mode 100644 crates/workshop/gateway/src/observer-tests.rs delete mode 100644 crates/workshop/gateway/src/observer.rs diff --git a/Cargo.lock b/Cargo.lock index 20c5e942..3ea9a554 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8510,7 +8510,6 @@ dependencies = [ "futures-util", "gateway-api-discovery", "gateway-api-types", - "promptforge", "reqwest", "serde", "serde_json", diff --git a/crates/workshop/gateway/Cargo.toml b/crates/workshop/gateway/Cargo.toml index 95a631fc..ea7cb00e 100644 --- a/crates/workshop/gateway/Cargo.toml +++ b/crates/workshop/gateway/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true -description = "Workshop gateway subsystem: the bearer-authenticated gateway HTTP client, endpoint binding and discovery, heartbeat, progress subscriber, and the run event log" +description = "Workshop gateway subsystem: the bearer-authenticated gateway HTTP client, endpoint binding and discovery, heartbeat, and progress subscriber" [features] test-fixtures = ["dep:tempfile"] @@ -17,8 +17,6 @@ futures-util.workspace = true # The gateway's public wire vocabulary: the progress snapshot the # `GET /admin/progress` stream sends. gateway-api-types.workspace = true -# The engine's public API: the events the run event log records. -promptforge.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index 10cffc47..a24eec09 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -25,7 +25,6 @@ pub mod gateway_binding; pub mod gateway_progress; pub mod handles; pub mod heartbeat; -pub mod observer; pub mod resolve; #[cfg(any(test, feature = "test-fixtures"))] pub mod test_gateway; @@ -39,5 +38,4 @@ pub use gateway_binding::{ }; pub use handles::{GatewayHandles, register, register_tasks}; pub use heartbeat::{GatewayHealth, Heartbeat}; -pub use observer::WorkshopObserver; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; diff --git a/crates/workshop/gateway/src/observer-tests.rs b/crates/workshop/gateway/src/observer-tests.rs deleted file mode 100644 index c84cf8e9..00000000 --- a/crates/workshop/gateway/src/observer-tests.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Workshop observer tests: concurrent appends, consistent reads, and poisoned-lock recovery. - -use std::sync::Arc; - -use promptforge::ids::{ChainId, Provenance, TaskId}; - -use super::*; - -/// A user-input event under `section` containing `text`, stamped with the -/// root task's zeroth sequence: the payload is what these tests read back. -fn input(section: &str, text: &str) -> Event { - Event::UserInput { - execution: "run".to_owned(), - section: section.to_owned(), - provenance: Provenance { - task: TaskId::from(ChainId::root()), - seq: 0, - }, - text: text.to_owned(), - } -} - -/// The text of a user-input event, the field the assertions compare. -fn text_of(event: &Event) -> &str { - match event { - Event::UserInput { text, .. } => text, - other => panic!("these tests append user-input events only, got {other:?}"), - } -} - -fn collect(log: &WorkshopObserver) -> Vec { - (0..log.len()) - .map(|index| log.get(index).expect("every index below len() reads")) - .collect() -} - -#[test] -fn concurrent_appends_lose_nothing_and_preserve_per_producer_order() { - let log = Arc::new(WorkshopObserver::new()); - - let mut producers = Vec::new(); - for producer in 0..4 { - let log = Arc::clone(&log); - producers.push(std::thread::spawn(move || { - let section = format!("producer-{producer}"); - for sequence in 0..25 { - log.append(input(§ion, &sequence.to_string())); - } - })); - } - for producer in producers { - producer.join().expect("producer threads finish"); - } - - assert_eq!(log.len(), 100, "no append may be lost"); - let events = collect(&log); - let expected: Vec = (0..25).map(|sequence| sequence.to_string()).collect(); - for producer in 0..4 { - let section = format!("producer-{producer}"); - let sequence: Vec<&str> = events - .iter() - .filter(|event| event.section() == section) - .map(text_of) - .collect(); - assert_eq!( - sequence, expected, - "{section} must keep its own append order through the interleaving" - ); - } -} - -#[test] -fn event_log_reads_see_a_consistent_prefix() { - let log = Arc::new(WorkshopObserver::new()); - let writer = Arc::clone(&log); - let producer = std::thread::spawn(move || { - for sequence in 0..200 { - writer.append(input("chat", &sequence.to_string())); - } - }); - - // Every observed length is a fully readable prefix, and an entry - // once appended never changes. - loop { - let len = log.len(); - for index in 0..len { - let event = log - .get(index) - .expect("every index below an observed len() must read"); - assert_eq!( - text_of(&event), - index.to_string(), - "entry {index} must be the entry that was appended there" - ); - } - if len == 200 { - break; - } - std::thread::yield_now(); - } - producer.join().expect("the producer thread finishes"); -} - -#[test] -fn subscribe_receives_every_entry_in_log_order() { - let log = WorkshopObserver::new(); - let mut entries = log.subscribe(); - for text in ["hi", "pondering", "hello"] { - log.append(input("chat", text)); - } - - for expected in ["hi", "pondering", "hello"] { - let received = entries.try_recv().expect("every appended entry broadcasts"); - assert_eq!(text_of(&received), expected); - } - assert!( - matches!( - entries.try_recv(), - Err(broadcast::error::TryRecvError::Empty) - ), - "no entry may broadcast that was not appended" - ); -} - -#[test] -fn reads_past_the_end_are_none_and_an_empty_log_says_so() { - let log = WorkshopObserver::new(); - assert!(log.is_empty()); - assert_eq!(log.get(0), None); - log.append(input("chat", "only")); - assert!(!log.is_empty()); - assert_eq!(log.get(1), None, "reads at or past len must return None"); -} - -#[test] -fn a_poisoned_lock_recovers_for_appends_and_reads() { - let log = Arc::new(WorkshopObserver::new()); - - let poisoner = Arc::clone(&log); - let panicked = std::thread::spawn(move || { - let _guard = poisoner - .events - .write() - .expect("the lock is not yet poisoned"); - panic!("poisoning the event log lock on purpose"); - }) - .join(); - assert!(panicked.is_err(), "the poisoning thread must panic"); - assert!(log.events.is_poisoned(), "the lock must be poisoned"); - - // The poison is recovered, not propagated - appends, reads, and - // broadcast all keep working. - let mut entries = log.subscribe(); - log.append(input("chat", "after the poison")); - assert_eq!(log.len(), 1); - assert_eq!(log.get(0).as_ref().map(text_of), Some("after the poison")); - assert_eq!( - text_of( - &entries - .try_recv() - .expect("the broadcast survives the poison") - ), - "after the poison" - ); -} diff --git a/crates/workshop/gateway/src/observer.rs b/crates/workshop/gateway/src/observer.rs deleted file mode 100644 index f546e015..00000000 --- a/crates/workshop/gateway/src/observer.rs +++ /dev/null @@ -1,163 +0,0 @@ -//! The workshop's run event log: an append-only in-memory log of the -//! engine's [`Event`] values with live broadcast fan-out. -//! -//! The engine reports every boundary of a run as an [`Event`] value the -//! host receives from its run loop; a session appends the ones its -//! transcript shows here, reads them back by index for a socket's -//! per-client cursor, and wakes attached sockets through the broadcast. -//! The log is memory-only: nothing persists across a server restart. The -//! Turso run log the harness brings takes over durable storage, and this -//! type serves reconnect until then. - -use std::fmt; -use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; - -use promptforge::event::Event; -use tokio::sync::broadcast; - -/// Capacity of the broadcast channel behind -/// [`WorkshopObserver::subscribe`]. A receiver that falls further behind -/// misses the overwritten entries and recovers them by index through the -/// log itself, which retains every entry. -const BROADCAST_CAPACITY: usize = 256; - -/// The workshop's append-only run event log. -/// -/// One instance records one session's [`Event`]s. [`append`](Self::append) -/// is the write side, [`len`](Self::len) and [`get`](Self::get) the -/// indexed read side, and [`subscribe`](Self::subscribe) fans every -/// appended entry out live. Entry order and broadcast order agree because -/// both advance under the same write guard, so an index once valid stays -/// valid and its entry never changes. -/// -/// A lock poisoned by a panicking peer recovers the value rather than -/// wedging the process. -pub struct WorkshopObserver { - /// The append-only in-memory log; an index once valid stays valid. - events: RwLock>, - /// The live fan-out; entries are sent under the write guard, so - /// receivers observe log order. - sender: broadcast::Sender, -} - -impl WorkshopObserver { - /// Opens a fresh, empty log. - /// - /// # Examples - /// ``` - /// use promptforge::event::Event; - /// use promptforge::ids::{ChainId, Provenance, TaskId}; - /// use workshop_gateway::WorkshopObserver; - /// - /// let log = WorkshopObserver::new(); - /// log.append(Event::UserInput { - /// execution: "run".to_owned(), - /// section: "chat".to_owned(), - /// provenance: Provenance { task: TaskId::from(ChainId::root()), seq: 0 }, - /// text: "hello".to_owned(), - /// }); - /// assert_eq!(log.len(), 1); - /// ``` - #[must_use] - pub fn new() -> Self { - Self { - events: RwLock::new(Vec::new()), - sender: broadcast::channel(BROADCAST_CAPACITY).0, - } - } - - /// Appends one event to the log and to the broadcast, under the one - /// write guard so the two orders agree. - pub fn append(&self, event: Event) { - let mut events = self.write(); - events.push(event.clone()); - // A send without receivers is the channel's resting state, not a - // fault; entries stay readable by index regardless. - let _ = self.sender.send(event); - } - - /// Returns the number of events recorded so far. - #[must_use] - pub fn len(&self) -> u64 { - self.read().len() as u64 - } - - /// Returns whether no event has been recorded. - #[must_use] - pub fn is_empty(&self) -> bool { - self.read().is_empty() - } - - /// Returns the event at `index`, or `None` at or past - /// [`len`](Self::len). The log is append-only, so every index below a - /// witnessed `len()` reads. - #[must_use] - pub fn get(&self, index: u64) -> Option { - let events = self.read(); - usize::try_from(index) - .ok() - .and_then(|index| events.get(index).cloned()) - } - - /// Subscribes to every entry appended from this call on. - /// - /// Entries arrive in log order, each sent after it is readable - /// through [`get`](Self::get). Earlier entries never replay here - - /// read them by index instead - and a receiver that lags past the - /// channel capacity misses the overwritten entries and recovers them - /// the same way. - /// - /// # Examples - /// ``` - /// use promptforge::event::Event; - /// use promptforge::ids::{ChainId, Provenance, TaskId}; - /// use workshop_gateway::WorkshopObserver; - /// - /// let log = WorkshopObserver::new(); - /// let mut entries = log.subscribe(); - /// log.append(Event::UserInput { - /// execution: "run".to_owned(), - /// section: "chat".to_owned(), - /// provenance: Provenance { task: TaskId::from(ChainId::root()), seq: 0 }, - /// text: "hello".to_owned(), - /// }); - /// let Event::UserInput { text, .. } = entries.try_recv()? else { - /// panic!("the appended entry broadcasts"); - /// }; - /// assert_eq!(text, "hello"); - /// # Ok::<(), Box>(()) - /// ``` - #[must_use] - pub fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() - } - - /// The read guard, recovering a lock poisoned by a panicking peer - /// rather than wedging the process. - fn read(&self) -> RwLockReadGuard<'_, Vec> { - self.events.read().unwrap_or_else(PoisonError::into_inner) - } - - /// The write guard; the same poison recovery as [`Self::read`]. - fn write(&self) -> RwLockWriteGuard<'_, Vec> { - self.events.write().unwrap_or_else(PoisonError::into_inner) - } -} - -impl Default for WorkshopObserver { - fn default() -> Self { - Self::new() - } -} - -impl fmt::Debug for WorkshopObserver { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("WorkshopObserver") - .field("len", &self.read().len()) - .finish_non_exhaustive() - } -} - -#[cfg(test)] -#[path = "observer-tests.rs"] -mod tests; diff --git a/crates/workshop/server/src/agents/status.rs b/crates/workshop/server/src/agents/status.rs index 57853e1c..d94346b5 100644 --- a/crates/workshop/server/src/agents/status.rs +++ b/crates/workshop/server/src/agents/status.rs @@ -1,6 +1,5 @@ //! The shell's status relay for one agent session: the status-bar frames -//! and the backoff reset the session's run used to push from inside the -//! sessions crate, now derived in the shell from the session's live +//! and the backoff reset, derived in the shell from the session's live //! events, deltas, and error reports. //! //! One relay task per session, spawned at launch. It holds only the diff --git a/crates/workshop/server/src/lib.rs b/crates/workshop/server/src/lib.rs index 588773ca..4243602d 100644 --- a/crates/workshop/server/src/lib.rs +++ b/crates/workshop/server/src/lib.rs @@ -60,9 +60,7 @@ mod serve; // The extracted subsystem crates, aliased at their pre-decomposition // module paths so the shell's internals read as they did before the // split. The tier graph is enforced by `cargo test -p build-xtask`. -pub use workshop_gateway::{ - gateway, gateway_binding, gateway_progress, heartbeat, observer, resolve, -}; +pub use workshop_gateway::{gateway, gateway_binding, gateway_progress, heartbeat, resolve}; pub use workshop_menu::{catalog, menu}; pub use workshop_status::status; @@ -90,10 +88,7 @@ pub mod fixtures; pub use agents::AgentSessions; pub use app::{AppState, DEFAULT_ADDR, StateError, router}; pub use cross_site::{guard as cross_site_guard, origin_allowed}; -pub use gateway::{ - CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, - SwitchOutcome, SwitchResponse, -}; +pub use gateway::{GatewayClient, GatewayError, GatewayResponse, SwitchOutcome, SwitchResponse}; pub use gateway_binding::{GatewayPublicationError, GatewayUpdater}; /// The refusal an answered input wait returns when its token names no /// unresolved wait: the harness's own, named here so an embedding host diff --git a/crates/workshop/status/src/status.rs b/crates/workshop/status/src/status.rs index 0d5fcc61..cdcaac79 100644 --- a/crates/workshop/status/src/status.rs +++ b/crates/workshop/status/src/status.rs @@ -18,7 +18,7 @@ use tokio::sync::broadcast; -use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +use workshop_protocol::StatusBarUpdate; use workshop_support::RetainedBus; /// Ring capacity of the status bus. Covers a startup burst plus an agent @@ -62,59 +62,6 @@ impl StatusBus { pub fn emit(&self, update: StatusBarUpdate) { self.bus.send(update); } - - /// Broadcasts one non-busy update at the given severity. - pub fn report( - &self, - label: impl Into, - description: impl Into, - severity: Severity, - activity: Activity, - ) { - self.emit(StatusBarUpdate { - label: label.into(), - description: description.into(), - busy: false, - severity, - activity, - }); - } - - /// Broadcasts a user-visible status text. - pub fn info( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.report(label, description, Severity::Info, activity); - } - - /// Broadcasts an internal instrumentation pulse the UI does not - /// display. - pub fn debug( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.report(label, description, Severity::Debug, activity); - } - - /// Broadcasts a failure the user should see. - pub fn error( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.report(label, description, Severity::Error, activity); - } - - /// Returns the bar to its resting state. - pub fn idle(&self) { - self.info("Ready", "idle", Activity::General); - } } impl Default for StatusBus { @@ -126,19 +73,32 @@ impl Default for StatusBus { #[cfg(test)] mod tests { use super::*; + use workshop_protocol::{Activity, Severity}; + + /// A non-busy, info-severity update: the tests read the label back, + /// so the severity and activity are fixed. + fn update(label: &str, description: &str) -> StatusBarUpdate { + StatusBarUpdate { + label: label.to_owned(), + description: description.to_owned(), + busy: false, + severity: Severity::Info, + activity: Activity::General, + } + } #[tokio::test] async fn emitting_with_no_subscribers_is_a_no_op() { let bus = StatusBus::new(); - bus.info("Ready", "idle", Activity::General); + bus.emit(update("Ready", "idle")); } #[test] fn the_newest_update_is_retained_for_the_connect_snapshot() { let bus = StatusBus::new(); assert!(bus.latest().is_none(), "an untouched bus has no snapshot"); - bus.info("one", "", Activity::General); - bus.info("two", "", Activity::General); + bus.emit(update("one", "")); + bus.emit(update("two", "")); let latest = bus.latest().expect("the bus retains the newest update"); assert_eq!( latest.label, "two", @@ -153,7 +113,7 @@ mod tests { let sent = STATUS_CHANNEL_CAPACITY + 10; for index in 0..sent { // Sends never block, however far behind the receiver is. - bus.debug(format!("update {index}"), "", Activity::General); + bus.emit(update(&format!("update {index}"), "")); } let lag = match receiver.recv().await { Err(broadcast::error::RecvError::Lagged(skipped)) => skipped, diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 4706f9b9..7bc68e29 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -818,7 +818,7 @@ Components, in dependency order: -### Step 11: Delete the dead gateway, status, and server code +### Step 11: Delete the dead gateway, status, and server code [completed] - Component: Dead code - Piece: dead code From 1ad965dc6548d12abdf2e58ced0276ab195ad07e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 18:11:16 -0700 Subject: [PATCH 12/44] Give the gateway app its own copies of the icons The gateway app now keeps its own byte-identical icon set instead of reading the workshop's icons across the crate boundary. The build script and the embedding test point at the local copies, so the icons travel with the crate's own files. The rule that keeps icon copies synchronized with their masters is extended to cover these new copies. - `assets/icon.ico`: the byte-identical copy now feeds the Windows exe embedding, and `assets/32x32.png` and `assets/64x64.png` bring the brand glyph sources into the crate beside the existing tray rgba assets. - `ICON`: the build-script constant now names the local copy, and the embedding test reads the same `assets/icon.ico`, so the Windows exe embeds the crate's own file rather than the workshop's. - `../../workshop/shell/icons/icon.ico`: the out-of-crate reference is gone from every gateway app file. Design: removes hidden-dependency @ crates/gateway/app/build.rs::ICON Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- crates/gateway/app/Cargo.toml | 5 +++-- crates/gateway/app/assets/32x32.png | Bin 0 -> 2139 bytes crates/gateway/app/assets/64x64.png | Bin 0 -> 6032 bytes crates/gateway/app/assets/icon.ico | Bin 0 -> 68688 bytes crates/gateway/app/build.rs | 11 +++++------ crates/gateway/app/src/tray/linux.rs | 4 ++-- crates/gateway/app/src/tray/macos.rs | 8 ++++---- crates/gateway/app/src/tray/windows.rs | 7 +++---- crates/gateway/app/tests/it/icon.rs | 11 +++++------ crates/workshop/shell/icons/AGENTS.md | 2 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 11 files changed, 24 insertions(+), 26 deletions(-) create mode 100644 crates/gateway/app/assets/32x32.png create mode 100644 crates/gateway/app/assets/64x64.png create mode 100644 crates/gateway/app/assets/icon.ico diff --git a/crates/gateway/app/Cargo.toml b/crates/gateway/app/Cargo.toml index 44d4e7c9..fe2cd008 100644 --- a/crates/gateway/app/Cargo.toml +++ b/crates/gateway/app/Cargo.toml @@ -16,8 +16,9 @@ documentation = "https://cppalliance.github.io/promptforge/" name = "promptforge-gateway" path = "src/main.rs" -# build.rs embeds the program icon (../../workshop/shell/icons/icon.ico) into the -# Windows exe as an RT_GROUP_ICON resource. Host-gated: the table is +# build.rs embeds the program icon (assets/icon.ico, a copy of the +# workshop master) into the Windows exe as an RT_GROUP_ICON resource. +# Host-gated: the table is # evaluated against the build host, and the script is a no-op elsewhere. [target.'cfg(windows)'.build-dependencies] embed-resource.workspace = true diff --git a/crates/gateway/app/assets/32x32.png b/crates/gateway/app/assets/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..9a3d7a93227dd85c21b86f42e93b7e753e17ad89 GIT binary patch literal 2139 zcmV-h2&DIkP)WUo*2G%r5)E0xc__Wm_6Str$^$wHTu{m9W7-O>7z)jq!u{vk^%Xqd$zl zOza;^^dFd-7A2*%QX~kaQbdRb(okVxcV}mJneNWL^KtJz$LE}xf$8oRuyHnb@11+k zec$JO-uHdpGZOdz5&QS=caI-G?rN=Jj`olFZl0MnX6=0&y^l4LM&*G62a*N%yLfao zuOl9F?GFtQzN2`lJ~n`+k(T4WXPoR{-MBp+jc4RQ)LXI6 zEpykb-5>_~`;qZ|E=-}47zvMRwTfD;hWRkU)btFBQw5Ero~#>r)Eiv83Dd>mFOM8~ zWnas7yFa)6i=m1O(%rjvztBH8xbJUA|By_M-;f&~-r_yb--|%SQ1b%}$ zk|H4h$#V}adISBUI3IZz|M9LW7ADk&t=oV2_~W}G+J1h|o;}Xt!-vyF4747MKK0e@ zoxLkgUp#wCm1d^J$jD|f901)(4IWaP^OTNaf~fN>RpXuk6INo(Ruhy=TR0v{se{t& zw0>aKYTY}$QQjQ8_Sp*;E}U%#ahrjWkr4|fzkJ|3lamuD6eiOLhlf2iUBs@>)$#f5 zSyUrB6kH}6I-rSP2!{aJ56VRt1-Le*@Wz=kPTwe_)ALd5=n~Gn=a0> zFvTJUTKW0L-o580Zj5c4yg8-@db(uK{5*d0wK6`rqmJ}U2oXw%I@1^sK!xx5rfPX~ zO2}Sn`|2)(HJ-9?hX z=vA7q)%YT#t=XFnpYm|nek{Ux2FUhkxH*9pgE`DH#_?M? zWHd){WfTfzvaEpt7j6%;NTu|a@qy2J=b}HJOz`tRRS;&fs441$gjLQvHSp04nCOMo zdQ&_sMAa-LX>wlIHWi+uk?R9W>oPcdjx|mz2`Mg=61;t-ioX{KyptWl!mW{YpNz!{ zcV(bKzu7=2W&7WtHO?U$PE93KtROvsfcqf7OJHIm#!p{n?gN23m&Qh=0|&A@gDOqe z2p}~rs2lAKTTl0@0YjD6w)hw*{ikqMVuy~={e&G5Cyi)4(lWH%!EDB3wM$gB0U{7- z$>@c3+&WBKr+P61i`pdh0P=XU>r)0xv!;x<8mX%di`{mwkX!36WG+v}JMm?D4*z*-~TR8e$Vf6s41yiKx zsUqi7J5p)Gu|j5|R}C<>f^=i9Ea87C9Y}R-L1;U4BDUUNPNkTfaP2G*#);Vn*r+Hv z7c?$Uq^R(LDa1s#7|>1UM*Ykrw4$c(3c%!Ja~#{EkC_TbdWfWppRv`%sS1m(sp8s* zQG;T$HE=sTiV$0_nRhfJZ?SG9-5^hO3UQ|wKeQjvP5)|cZfSuEDgK}|F+=*2!WWe4G!|+Ad^}euj zXq7uSv?{TC|F}l6OOSP_fWk1-fNbclQ0WrE^e{pzxvG&qI|R9GXlMwo<9PqRe3={N z%8Lufp|e1TB)8cA<4DJKCI3mdzEqAcW9dn@nyg8m=Rnr^J(iA42ulmc z=q@RfQt+O`_sZo^ULU)j)T`n5N^{{$?a|-$r&$mLYaPegL31~%3{bM6J8&ENm+v!u{{prop!h#N R6dnKo002ovPDHLkV1h;77+nAW literal 0 HcmV?d00001 diff --git a/crates/gateway/app/assets/64x64.png b/crates/gateway/app/assets/64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..39330d8bec5f44e58fe109fb4a3d73ececd0de31 GIT binary patch literal 6032 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 KMNUMnLSTX#hNu+) literal 0 HcmV?d00001 diff --git a/crates/gateway/app/assets/icon.ico b/crates/gateway/app/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..875eb11cdc94b62d63876adec3ba0be562863c36 GIT binary patch literal 68688 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

DJF~~2N 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 b0O#WitYudcI*Dr+00000NkvXXu0mjfBA_81 literal 0 HcmV?d00001 diff --git a/crates/gateway/app/build.rs b/crates/gateway/app/build.rs index 9d395746..c2f58abc 100644 --- a/crates/gateway/app/build.rs +++ b/crates/gateway/app/build.rs @@ -4,11 +4,10 @@ //! glyph. On every other host this script only declares its input and //! exits. //! -//! The icon sits in `crates/workshop/icons/icon.ico`, outside this -//! crate, because the workshop's Tauri bundle is the one source of the -//! icon set. That path would break `cargo package`, which only sees the -//! crate's own files, but the gateway is `publish = false`, so the -//! out-of-crate path is accepted. +//! The icon is a copy kept in `assets/icon.ico`, byte-identical to the +//! workshop's master icon set. The gateway app embeds its own copy instead +//! of reading across the crate boundary, so the icon travels with this +//! crate's files. //! //! The manifest declares the common-controls v6 dependency. Workspace //! builds unify `muda`'s `common-controls-v6` feature on (the workshop's @@ -20,7 +19,7 @@ use std::path::{Path, PathBuf}; /// The icon, relative to this crate's manifest directory. -const ICON: &str = "../../workshop/shell/icons/icon.ico"; +const ICON: &str = "assets/icon.ico"; /// The application manifest: the common-controls v6 dependency that /// `muda`'s `common-controls-v6` feature requires. The resource script diff --git a/crates/gateway/app/src/tray/linux.rs b/crates/gateway/app/src/tray/linux.rs index 455c88e3..2ce04385 100644 --- a/crates/gateway/app/src/tray/linux.rs +++ b/crates/gateway/app/src/tray/linux.rs @@ -50,8 +50,8 @@ const STATUS_INTERVAL: Duration = Duration::from_secs(5); /// RGBA, the same brand asset the Windows backend draws on. const ICON_SIZE: i32 = 32; -/// The brand icon as raw RGBA (regenerate from -/// `crates/workshop/shell/icons/32x32.png` when the brand changes). +/// The brand icon as raw RGBA (regenerate from `assets/32x32.png` when the +/// brand changes). const BRAND_RGBA: &[u8] = include_bytes!("../../assets/tray-icon.rgba"); // The asset is exactly one 32x32 RGBA image. diff --git a/crates/gateway/app/src/tray/macos.rs b/crates/gateway/app/src/tray/macos.rs index e3315772..278c072a 100644 --- a/crates/gateway/app/src/tray/macos.rs +++ b/crates/gateway/app/src/tray/macos.rs @@ -60,10 +60,10 @@ const STATUS_INTERVAL: f64 = 5.0; /// RGBA, an 18pt template glyph at @2x. const ICON_SIZE: u32 = 36; -/// The brand glyph as raw RGBA, derived from the workshop's `64x64.png` -/// brand asset (PIL: `Image.open(...).convert("RGBA").resize((36, 36), -/// Image.LANCZOS).tobytes()`; regenerate from -/// `crates/workshop/shell/icons/64x64.png` when the brand changes). +/// The brand glyph as raw RGBA, derived from the `64x64.png` brand asset +/// copy (PIL: `Image.open(...).convert("RGBA").resize((36, 36), +/// Image.LANCZOS).tobytes()`; regenerate from `assets/64x64.png` when the +/// brand changes). const BRAND_RGBA: &[u8] = include_bytes!("../../assets/tray-icon-template.rgba"); // The asset is exactly one 36x36 RGBA image. diff --git a/crates/gateway/app/src/tray/windows.rs b/crates/gateway/app/src/tray/windows.rs index 69a136c0..fc7fdf51 100644 --- a/crates/gateway/app/src/tray/windows.rs +++ b/crates/gateway/app/src/tray/windows.rs @@ -46,10 +46,9 @@ const STATUS_INTERVAL_MS: u32 = 5_000; /// RGBA. const ICON_SIZE: u32 = 32; -/// The brand icon as raw RGBA, derived from the workshop's `32x32.png` -/// brand asset (PIL: `Image.open(...).convert("RGBA").tobytes()`; -/// regenerate from `crates/workshop/shell/icons/32x32.png` when the brand -/// changes). +/// The brand icon as raw RGBA, derived from the `32x32.png` brand asset +/// copy (PIL: `Image.open(...).convert("RGBA").tobytes()`; regenerate from +/// `assets/32x32.png` when the brand changes). const BRAND_RGBA: &[u8] = include_bytes!("../../assets/tray-icon.rgba"); // The asset is exactly one 32x32 RGBA image. diff --git a/crates/gateway/app/tests/it/icon.rs b/crates/gateway/app/tests/it/icon.rs index 691d7b95..6b14345a 100644 --- a/crates/gateway/app/tests/it/icon.rs +++ b/crates/gateway/app/tests/it/icon.rs @@ -1,7 +1,7 @@ -//! The Windows exe icon: the crate's `build.rs` compiles -//! `crates/workshop/icons/icon.ico` into `promptforge-gateway.exe` as an -//! icon resource. An `RT_ICON` resource stores each image of the `.ico` -//! byte for byte, so every image must appear verbatim in the built binary. +//! The Windows exe icon: the crate's `build.rs` compiles the icon copy at +//! `assets/icon.ico` into `promptforge-gateway.exe` as an icon resource. +//! An `RT_ICON` resource stores each image of the `.ico` byte for byte, so +//! every image must appear verbatim in the built binary. use std::path::Path; @@ -38,8 +38,7 @@ fn ico_images(ico: &[u8]) -> Vec<&[u8]> { #[test] fn the_exe_embeds_every_image_of_the_program_icon() { let exe = std::fs::read(env!("CARGO_BIN_EXE_promptforge-gateway")).unwrap(); - let ico_path = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../workshop/shell/icons/icon.ico"); + let ico_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/icon.ico"); let ico = std::fs::read(&ico_path).unwrap(); let images = ico_images(&ico); diff --git a/crates/workshop/shell/icons/AGENTS.md b/crates/workshop/shell/icons/AGENTS.md index 00b84e4b..50ccf048 100644 --- a/crates/workshop/shell/icons/AGENTS.md +++ b/crates/workshop/shell/icons/AGENTS.md @@ -1,4 +1,4 @@ # Workshop icons -- Keep the Gateway configuration UI and Workshop UI icon copies synchronized with the corresponding master icons in this directory. +- Keep the Gateway app, Gateway configuration UI, and Workshop UI icon copies synchronized with the corresponding master icons in this directory. - `installer-header.png`, `installer-header.bmp`, `installer-sidebar.png`, `installer-sidebar.bmp`, and `dmg-background.png` are hand-crafted installer assets. Do not regenerate, resize, overwrite, or modify them as part of ordinary icon generation. diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 7bc68e29..47491759 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -838,7 +838,7 @@ Components, in dependency order: -### Step 12: Give the gateway app its own icon copies +### Step 12: Give the gateway app its own icon copies [completed] - Component: Shell vocabulary - Piece: icon copies From dc587d91639e9f1d30c3868e6ce9d510a1f9aa21 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 18:26:54 -0700 Subject: [PATCH 13/44] Move the desktop app to crates/workshop/desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocate the Workshop desktop application to a directory named desktop, retiring shell as a name for anything but terminal command shells. Every reference to the old location is corrected in the same commit so the build, release workflows, and sidecar staging keep working. The package and binary names are unchanged, and the moved files are byte-for-byte identical, so nothing observable changes. - `crates/workshop/desktop` — the desktop app directory, moved verbatim from `crates/workshop/shell`; every file is a 100%-similarity rename, and the `workshop` package and `promptforge-workshop` binary names are unchanged. - `tiered_crate_dir` — the build check's fallback directory flips from `shell` to `desktop`, so the tier graph still resolves the Tauri crate. - `crates/workshop/desktop/binaries` — the sidecar staging path, corrected in the nightly and release workflows, the staging tool, its test, and the interruption test so CI still locates the gateway executable. - `crates/workshop/shell` — nothing remains at the old path, and no moved file changes content, so the desktop app behaves exactly as before. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .github/workflows/nightly.yml | 6 +++--- .github/workflows/release-workshop.yml | 8 ++++---- .github/workflows/workshop-installer-smoke.yml | 6 +++--- .gitignore | 6 +++--- AGENTS.md | 2 +- Cargo.toml | 2 +- README.md | 2 +- crates/build-workshop/tests/interruption.rs | 2 +- crates/build-xtask/src/tidy-tests.rs | 2 +- crates/build-xtask/src/tidy.rs | 4 ++-- crates/gateway/app/src/tray/macos.rs | 2 +- crates/gateway/app/src/tray/windows.rs | 2 +- crates/workshop/{shell => desktop}/AGENTS.md | 0 crates/workshop/{shell => desktop}/Cargo.toml | 0 .../workshop/{shell => desktop}/Entitlements.plist | 0 crates/workshop/{shell => desktop}/Info.plist | 0 crates/workshop/{shell => desktop}/app-icon.png | Bin crates/workshop/{shell => desktop}/build.rs | 0 .../workshop/{shell => desktop}/icons/128x128.png | Bin .../{shell => desktop}/icons/128x128@2x.png | Bin crates/workshop/{shell => desktop}/icons/32x32.png | Bin crates/workshop/{shell => desktop}/icons/64x64.png | Bin crates/workshop/{shell => desktop}/icons/AGENTS.md | 0 .../{shell => desktop}/icons/Square107x107Logo.png | Bin .../{shell => desktop}/icons/Square142x142Logo.png | Bin .../{shell => desktop}/icons/Square150x150Logo.png | Bin .../{shell => desktop}/icons/Square284x284Logo.png | Bin .../{shell => desktop}/icons/Square30x30Logo.png | Bin .../{shell => desktop}/icons/Square310x310Logo.png | Bin .../{shell => desktop}/icons/Square44x44Logo.png | Bin .../{shell => desktop}/icons/Square71x71Logo.png | Bin .../{shell => desktop}/icons/Square89x89Logo.png | Bin .../workshop/{shell => desktop}/icons/StoreLogo.png | Bin .../icons/android/mipmap-anydpi-v26/ic_launcher.xml | 0 .../icons/android/mipmap-hdpi/ic_launcher.png | Bin .../android/mipmap-hdpi/ic_launcher_foreground.png | Bin .../icons/android/mipmap-hdpi/ic_launcher_round.png | Bin .../icons/android/mipmap-mdpi/ic_launcher.png | Bin .../android/mipmap-mdpi/ic_launcher_foreground.png | Bin .../icons/android/mipmap-mdpi/ic_launcher_round.png | Bin .../icons/android/mipmap-xhdpi/ic_launcher.png | Bin .../android/mipmap-xhdpi/ic_launcher_foreground.png | Bin .../android/mipmap-xhdpi/ic_launcher_round.png | Bin .../icons/android/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../android/mipmap-xxhdpi/ic_launcher_round.png | Bin .../icons/android/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../android/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../icons/android/values/ic_launcher_background.xml | 0 .../{shell => desktop}/icons/dmg-background.png | Bin crates/workshop/{shell => desktop}/icons/icon.icns | Bin crates/workshop/{shell => desktop}/icons/icon.ico | Bin crates/workshop/{shell => desktop}/icons/icon.png | Bin .../{shell => desktop}/icons/installer-header.bmp | Bin .../{shell => desktop}/icons/installer-header.png | Bin .../{shell => desktop}/icons/installer-sidebar.bmp | Bin .../{shell => desktop}/icons/installer-sidebar.png | Bin .../icons/ios/AppIcon-20x20@1x.png | Bin .../icons/ios/AppIcon-20x20@2x-1.png | Bin .../icons/ios/AppIcon-20x20@2x.png | Bin .../icons/ios/AppIcon-20x20@3x.png | Bin .../icons/ios/AppIcon-29x29@1x.png | Bin .../icons/ios/AppIcon-29x29@2x-1.png | Bin .../icons/ios/AppIcon-29x29@2x.png | Bin .../icons/ios/AppIcon-29x29@3x.png | Bin .../icons/ios/AppIcon-40x40@1x.png | Bin .../icons/ios/AppIcon-40x40@2x-1.png | Bin .../icons/ios/AppIcon-40x40@2x.png | Bin .../icons/ios/AppIcon-40x40@3x.png | Bin .../{shell => desktop}/icons/ios/AppIcon-512@2x.png | Bin .../icons/ios/AppIcon-60x60@2x.png | Bin .../icons/ios/AppIcon-60x60@3x.png | Bin .../icons/ios/AppIcon-76x76@1x.png | Bin .../icons/ios/AppIcon-76x76@2x.png | Bin .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin crates/workshop/{shell => desktop}/installer.nsi | 0 .../autogenerated/desktop_update_supported.toml | 0 .../permissions/autogenerated/quit.toml | 0 crates/workshop/{shell => desktop}/src/bridge.rs | 0 crates/workshop/{shell => desktop}/src/config.rs | 0 crates/workshop/{shell => desktop}/src/drops.rs | 0 crates/workshop/{shell => desktop}/src/gateway.rs | 0 .../workshop/{shell => desktop}/src/gateway/boot.rs | 0 .../{shell => desktop}/src/gateway/identity.rs | 0 .../{shell => desktop}/src/gateway/supervisor.rs | 0 .../{shell => desktop}/src/gateway/tests.rs | 0 .../{shell => desktop}/src/gateway/tests/boot.rs | 0 .../src/gateway/tests/identity.rs | 0 .../src/gateway/tests/recovery.rs | 0 .../src/gateway/tests/shutdown.rs | 0 .../workshop/{shell => desktop}/src/linux_media.rs | 0 crates/workshop/{shell => desktop}/src/main.rs | 0 crates/workshop/{shell => desktop}/src/menu.rs | 0 .../workshop/{shell => desktop}/src/navigation.rs | 0 .../workshop/{shell => desktop}/src/quit-tests.rs | 0 crates/workshop/{shell => desktop}/src/quit.rs | 0 .../{shell => desktop}/src/window_state-tests.rs | 0 .../workshop/{shell => desktop}/src/window_state.rs | 0 crates/workshop/{shell => desktop}/tauri.conf.json | 0 .../{shell => desktop}/tauri.macos.conf.json | 0 .../{shell => desktop}/tauri.nightly.conf.json | 0 tools/stage-gateway-sidecar.mjs | 2 +- tools/stage-gateway-sidecar.test.mjs | 2 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 105 files changed, 25 insertions(+), 25 deletions(-) rename crates/workshop/{shell => desktop}/AGENTS.md (100%) rename crates/workshop/{shell => desktop}/Cargo.toml (100%) rename crates/workshop/{shell => desktop}/Entitlements.plist (100%) rename crates/workshop/{shell => desktop}/Info.plist (100%) rename crates/workshop/{shell => desktop}/app-icon.png (100%) rename crates/workshop/{shell => desktop}/build.rs (100%) rename crates/workshop/{shell => desktop}/icons/128x128.png (100%) rename crates/workshop/{shell => desktop}/icons/128x128@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/32x32.png (100%) rename crates/workshop/{shell => desktop}/icons/64x64.png (100%) rename crates/workshop/{shell => desktop}/icons/AGENTS.md (100%) rename crates/workshop/{shell => desktop}/icons/Square107x107Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square142x142Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square150x150Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square284x284Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square30x30Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square310x310Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square44x44Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square71x71Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/Square89x89Logo.png (100%) rename crates/workshop/{shell => desktop}/icons/StoreLogo.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-anydpi-v26/ic_launcher.xml (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-hdpi/ic_launcher.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-hdpi/ic_launcher_foreground.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-hdpi/ic_launcher_round.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-mdpi/ic_launcher.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-mdpi/ic_launcher_foreground.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-mdpi/ic_launcher_round.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xhdpi/ic_launcher.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xhdpi/ic_launcher_round.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxhdpi/ic_launcher.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxhdpi/ic_launcher_round.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxxhdpi/ic_launcher.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename crates/workshop/{shell => desktop}/icons/android/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename crates/workshop/{shell => desktop}/icons/android/values/ic_launcher_background.xml (100%) rename crates/workshop/{shell => desktop}/icons/dmg-background.png (100%) rename crates/workshop/{shell => desktop}/icons/icon.icns (100%) rename crates/workshop/{shell => desktop}/icons/icon.ico (100%) rename crates/workshop/{shell => desktop}/icons/icon.png (100%) rename crates/workshop/{shell => desktop}/icons/installer-header.bmp (100%) rename crates/workshop/{shell => desktop}/icons/installer-header.png (100%) rename crates/workshop/{shell => desktop}/icons/installer-sidebar.bmp (100%) rename crates/workshop/{shell => desktop}/icons/installer-sidebar.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-20x20@1x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-20x20@2x-1.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-20x20@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-20x20@3x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-29x29@1x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-29x29@2x-1.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-29x29@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-29x29@3x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-40x40@1x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-40x40@2x-1.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-40x40@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-40x40@3x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-512@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-60x60@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-60x60@3x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-76x76@1x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-76x76@2x.png (100%) rename crates/workshop/{shell => desktop}/icons/ios/AppIcon-83.5x83.5@2x.png (100%) rename crates/workshop/{shell => desktop}/installer.nsi (100%) rename crates/workshop/{shell => desktop}/permissions/autogenerated/desktop_update_supported.toml (100%) rename crates/workshop/{shell => desktop}/permissions/autogenerated/quit.toml (100%) rename crates/workshop/{shell => desktop}/src/bridge.rs (100%) rename crates/workshop/{shell => desktop}/src/config.rs (100%) rename crates/workshop/{shell => desktop}/src/drops.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/boot.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/identity.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/supervisor.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/tests.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/tests/boot.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/tests/identity.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/tests/recovery.rs (100%) rename crates/workshop/{shell => desktop}/src/gateway/tests/shutdown.rs (100%) rename crates/workshop/{shell => desktop}/src/linux_media.rs (100%) rename crates/workshop/{shell => desktop}/src/main.rs (100%) rename crates/workshop/{shell => desktop}/src/menu.rs (100%) rename crates/workshop/{shell => desktop}/src/navigation.rs (100%) rename crates/workshop/{shell => desktop}/src/quit-tests.rs (100%) rename crates/workshop/{shell => desktop}/src/quit.rs (100%) rename crates/workshop/{shell => desktop}/src/window_state-tests.rs (100%) rename crates/workshop/{shell => desktop}/src/window_state.rs (100%) rename crates/workshop/{shell => desktop}/tauri.conf.json (100%) rename crates/workshop/{shell => desktop}/tauri.macos.conf.json (100%) rename crates/workshop/{shell => desktop}/tauri.nightly.conf.json (100%) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 84c4e347..5f49c556 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -201,13 +201,13 @@ jobs: src="target/release/promptforge-gateway$ext" ;; esac - mkdir -p crates/workshop/shell/binaries - cp "$src" "crates/workshop/shell/binaries/promptforge-gateway-$triple$ext" + mkdir -p crates/workshop/desktop/binaries + cp "$src" "crates/workshop/desktop/binaries/promptforge-gateway-$triple$ext" - name: Build the app uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 with: - projectPath: crates/workshop/shell + projectPath: crates/workshop/desktop args: ${{ matrix.args }} --config tauri.nightly.conf.json - name: Upload the installers diff --git a/.github/workflows/release-workshop.yml b/.github/workflows/release-workshop.yml index 1ff49f71..1967f249 100644 --- a/.github/workflows/release-workshop.yml +++ b/.github/workflows/release-workshop.yml @@ -164,8 +164,8 @@ jobs: src="target/release/promptforge-gateway$ext" ;; esac - mkdir -p crates/workshop/shell/binaries - staged="crates/workshop/shell/binaries/promptforge-gateway-$triple$ext" + mkdir -p crates/workshop/desktop/binaries + staged="crates/workshop/desktop/binaries/promptforge-gateway-$triple$ext" cp "$src" "$staged" output=$("$staged" --version) version="${{ needs.prepare.outputs.version }}" @@ -180,7 +180,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} with: - projectPath: crates/workshop/shell + projectPath: crates/workshop/desktop args: ${{ matrix.args }} - name: Build the unsigned temporary app @@ -188,7 +188,7 @@ jobs: id: temporary-build uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 with: - projectPath: crates/workshop/shell + projectPath: crates/workshop/desktop args: ${{ matrix.args }} --config tauri.nightly.conf.json - name: The signed build has the workspace version diff --git a/.github/workflows/workshop-installer-smoke.yml b/.github/workflows/workshop-installer-smoke.yml index cff45c62..e308ec97 100644 --- a/.github/workflows/workshop-installer-smoke.yml +++ b/.github/workflows/workshop-installer-smoke.yml @@ -5,8 +5,8 @@ on: paths: - .github/workflows/workshop-installer-smoke.yml - crates/build-workshop/** - - crates/workshop/shell/installer.nsi - - crates/workshop/shell/tauri*.conf.json + - crates/workshop/desktop/installer.nsi + - crates/workshop/desktop/tauri*.conf.json - tools/stage-gateway-sidecar.mjs workflow_dispatch: @@ -40,7 +40,7 @@ jobs: - name: Compile unsigned debug NSIS installer uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0 with: - projectPath: crates/workshop/shell + projectPath: crates/workshop/desktop args: --debug --bundles nsis --config tauri.nightly.conf.json - name: Remove Gateway sidecar diff --git a/.gitignore b/.gitignore index 754cb0a1..ae294761 100644 --- a/.gitignore +++ b/.gitignore @@ -19,8 +19,8 @@ /crates/workshop/ui/dist/ /crates/gateway/config-ui/ui/dist/ # tauri-build's generated ACL schemas, regenerated on every workshop build. -/crates/workshop/shell/gen/ +/crates/workshop/desktop/gen/ # The gateway sidecar staged for bundle.externalBin by CI before -# `tauri build` (crates/workshop/shell/tauri.conf.json); a build artifact. -/crates/workshop/shell/binaries/ +# `tauri build` (crates/workshop/desktop/tauri.conf.json); a build artifact. +/crates/workshop/desktop/binaries/ /plan-dist-manifest.json diff --git a/AGENTS.md b/AGENTS.md index b44cf7ea..f28be4a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that - The four main products are PromptForge, Gateway, Workshop, and Harness - Workshop crates are named workshop-* and must not depend on gateway crates; workshop crates may name the gateway public pair, the promptforge public API, and `harness-api` - Gateway's public surface is two root crates, `gateway-api-types` and `gateway-api-discovery`; everything else lives under crates/gateway/, a manifestless container private to the family - no outside crate may depend into it, and workshop crates may name only the public pair. Gateway crates must not depend on promptforge or workshop crates -- Workshop crates live under crates/workshop/, a manifestless container private to the family - no outside crate may depend into it; the shell is crates/workshop/shell (package `workshop`), and the server and its subsystems sit beside it with short directory names +- Workshop crates live under crates/workshop/, a manifestless container private to the family - no outside crate may depend into it; the shell is crates/workshop/desktop (package `workshop`), and the server and its subsystems sit beside it with short directory names - Harness crates are named harness-*. Their public surface is one root crate, `harness-api`; everything else lives under crates/harness/, a fourth manifestless container private to the family, and `harness-api` is its one public API - the only outside crate permitted to depend into it. harness-* crates may depend on `promptforge`, `gateway-api-types`, `gateway-api-discovery`, and shared-* crates, never on workshop crates or on a private gateway crate; workshop crates may depend on harness-* only through `harness-api`; promptforge-* and gateway-* crates must not depend on harness crates - The composed topology rule: a crate in a family container (crates/promptforge-internal/, crates/gateway/, crates/workshop/, crates/harness/) may depend only on crates at the crates/ root and its own siblings; the root is the public layer. Crates named build-* are meta tooling, exempt from container privacy - PromptForge crates are named `promptforge` and promptforge-* and must not depend on gateway, workshop, or harness crates diff --git a/Cargo.toml b/Cargo.toml index 7dbc4e66..559ab14c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/promptforge-internal/types", "crates/promptforge-internal/engine", "crates/promptforge-internal/lua", "crates/promptforge-internal/parser", "crates/promptforge-internal/store", "crates/promptforge-internal/vfs", "crates/promptforge-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/shell", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/harness/runner", "crates/harness/models", "crates/harness/capabilities", "crates/harness/log", "crates/harness/sessions", "crates/harness/web", "crates/harness/webfetch", "crates/harness/web-search"] +members = ["crates/*", "crates/promptforge-internal/types", "crates/promptforge-internal/engine", "crates/promptforge-internal/lua", "crates/promptforge-internal/parser", "crates/promptforge-internal/store", "crates/promptforge-internal/vfs", "crates/promptforge-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/harness/runner", "crates/harness/models", "crates/harness/capabilities", "crates/harness/log", "crates/harness/sessions", "crates/harness/web", "crates/harness/webfetch", "crates/harness/web-search"] # crates/shared-ui is not a Rust crate: it is the shared TypeScript+CSS # package both esbuild-built UIs consume, so the crates/* glob skips it. # crates/promptforge-internal, crates/gateway, crates/workshop, and diff --git a/README.md b/README.md index 81ae3929..75b3a999 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ npm ci --prefix crates/gateway/config-ui/ui - **macOS**: `xcode-select --install` and `brew install cmake node`, then `cargo workshop`. - **Windows**: install Visual Studio with the "Desktop development with C++" workload and Node.js 22, then `cargo workshop`. -`cargo build -p workshop` is a low-level package build. It requires a real gateway executable to have already been staged at `crates/workshop/shell/binaries/promptforge-gateway-` and does not clean that staging afterward. Bundling with `cargo tauri build` has the same staging requirement; the release workflows under `.github/workflows/` show the exact packaging commands per platform. +`cargo build -p workshop` is a low-level package build. It requires a real gateway executable to have already been staged at `crates/workshop/desktop/binaries/promptforge-gateway-` and does not clean that staging afterward. Bundling with `cargo tauri build` has the same staging requirement; the release workflows under `.github/workflows/` show the exact packaging commands per platform. The first build downloads the tool picker's embedding model (~130MB from Hugging Face, pinned and checksummed). Later builds reuse the cache. diff --git a/crates/build-workshop/tests/interruption.rs b/crates/build-workshop/tests/interruption.rs index b22e0bd7..6a46f255 100644 --- a/crates/build-workshop/tests/interruption.rs +++ b/crates/build-workshop/tests/interruption.rs @@ -49,7 +49,7 @@ fn platform_interrupt_after_staging_kills_child_cleans_and_fails() { let staged = repository .join("crates") .join("workshop") - .join("shell") + .join("desktop") .join("binaries") .join(SIDECAR_NAME); let temp = tempfile::tempdir().expect("temporary test root"); diff --git a/crates/build-xtask/src/tidy-tests.rs b/crates/build-xtask/src/tidy-tests.rs index 6fedb1c4..d7903014 100644 --- a/crates/build-xtask/src/tidy-tests.rs +++ b/crates/build-xtask/src/tidy-tests.rs @@ -220,7 +220,7 @@ fn the_workshop_shell_without_the_marker_passes_and_stays_outside_the_ceiling() let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), - "workshop/shell", + "workshop/desktop", "workshop", UNMARKED, MAX_FILE_LINES + 1, diff --git a/crates/build-xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs index 8812a7c6..ae4e1ccf 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -81,9 +81,9 @@ fn allowed_dependencies(name: &str) -> Option> { } /// The crate directory for a tiered workshop package: the family sits in -/// the `crates/workshop/` container, with the shell at `shell/`. +/// the `crates/workshop/` container, with the desktop app at `desktop/`. fn tiered_crate_dir(root: &Path, name: &str) -> PathBuf { - let short = name.strip_prefix("workshop-").unwrap_or("shell"); + let short = name.strip_prefix("workshop-").unwrap_or("desktop"); root.join("crates").join("workshop").join(short) } diff --git a/crates/gateway/app/src/tray/macos.rs b/crates/gateway/app/src/tray/macos.rs index 278c072a..17d9365d 100644 --- a/crates/gateway/app/src/tray/macos.rs +++ b/crates/gateway/app/src/tray/macos.rs @@ -456,7 +456,7 @@ fn launch_workshop(tray: &Tray) { command } else { // The unbundled dev fallback detaches the way the shell's own - // gateway spawn does (crates/workshop/shell/src/gateway.rs): its own + // gateway spawn does (crates/workshop/desktop/src/gateway.rs): its own // process group, so a terminal Ctrl-C on the gateway does not // SIGINT the workshop. let mut command = std::process::Command::new(exe); diff --git a/crates/gateway/app/src/tray/windows.rs b/crates/gateway/app/src/tray/windows.rs index fc7fdf51..d9bacd9d 100644 --- a/crates/gateway/app/src/tray/windows.rs +++ b/crates/gateway/app/src/tray/windows.rs @@ -592,7 +592,7 @@ fn open_settings(tray: &Tray) { /// through the gateway discovery file and outlives it. fn launch_workshop(tray: &Tray) { // The same detach the shell uses for its own gateway spawn - // (crates/workshop/shell/src/gateway.rs): broken out of any job object whose + // (crates/workshop/desktop/src/gateway.rs): broken out of any job object whose // kill-on-close would reap the workshop with the gateway, no inherited // stdio, and a new process group. const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; diff --git a/crates/workshop/shell/AGENTS.md b/crates/workshop/desktop/AGENTS.md similarity index 100% rename from crates/workshop/shell/AGENTS.md rename to crates/workshop/desktop/AGENTS.md diff --git a/crates/workshop/shell/Cargo.toml b/crates/workshop/desktop/Cargo.toml similarity index 100% rename from crates/workshop/shell/Cargo.toml rename to crates/workshop/desktop/Cargo.toml diff --git a/crates/workshop/shell/Entitlements.plist b/crates/workshop/desktop/Entitlements.plist similarity index 100% rename from crates/workshop/shell/Entitlements.plist rename to crates/workshop/desktop/Entitlements.plist diff --git a/crates/workshop/shell/Info.plist b/crates/workshop/desktop/Info.plist similarity index 100% rename from crates/workshop/shell/Info.plist rename to crates/workshop/desktop/Info.plist diff --git a/crates/workshop/shell/app-icon.png b/crates/workshop/desktop/app-icon.png similarity index 100% rename from crates/workshop/shell/app-icon.png rename to crates/workshop/desktop/app-icon.png diff --git a/crates/workshop/shell/build.rs b/crates/workshop/desktop/build.rs similarity index 100% rename from crates/workshop/shell/build.rs rename to crates/workshop/desktop/build.rs diff --git a/crates/workshop/shell/icons/128x128.png b/crates/workshop/desktop/icons/128x128.png similarity index 100% rename from crates/workshop/shell/icons/128x128.png rename to crates/workshop/desktop/icons/128x128.png diff --git a/crates/workshop/shell/icons/128x128@2x.png b/crates/workshop/desktop/icons/128x128@2x.png similarity index 100% rename from crates/workshop/shell/icons/128x128@2x.png rename to crates/workshop/desktop/icons/128x128@2x.png diff --git a/crates/workshop/shell/icons/32x32.png b/crates/workshop/desktop/icons/32x32.png similarity index 100% rename from crates/workshop/shell/icons/32x32.png rename to crates/workshop/desktop/icons/32x32.png diff --git a/crates/workshop/shell/icons/64x64.png b/crates/workshop/desktop/icons/64x64.png similarity index 100% rename from crates/workshop/shell/icons/64x64.png rename to crates/workshop/desktop/icons/64x64.png diff --git a/crates/workshop/shell/icons/AGENTS.md b/crates/workshop/desktop/icons/AGENTS.md similarity index 100% rename from crates/workshop/shell/icons/AGENTS.md rename to crates/workshop/desktop/icons/AGENTS.md diff --git a/crates/workshop/shell/icons/Square107x107Logo.png b/crates/workshop/desktop/icons/Square107x107Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square107x107Logo.png rename to crates/workshop/desktop/icons/Square107x107Logo.png diff --git a/crates/workshop/shell/icons/Square142x142Logo.png b/crates/workshop/desktop/icons/Square142x142Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square142x142Logo.png rename to crates/workshop/desktop/icons/Square142x142Logo.png diff --git a/crates/workshop/shell/icons/Square150x150Logo.png b/crates/workshop/desktop/icons/Square150x150Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square150x150Logo.png rename to crates/workshop/desktop/icons/Square150x150Logo.png diff --git a/crates/workshop/shell/icons/Square284x284Logo.png b/crates/workshop/desktop/icons/Square284x284Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square284x284Logo.png rename to crates/workshop/desktop/icons/Square284x284Logo.png diff --git a/crates/workshop/shell/icons/Square30x30Logo.png b/crates/workshop/desktop/icons/Square30x30Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square30x30Logo.png rename to crates/workshop/desktop/icons/Square30x30Logo.png diff --git a/crates/workshop/shell/icons/Square310x310Logo.png b/crates/workshop/desktop/icons/Square310x310Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square310x310Logo.png rename to crates/workshop/desktop/icons/Square310x310Logo.png diff --git a/crates/workshop/shell/icons/Square44x44Logo.png b/crates/workshop/desktop/icons/Square44x44Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square44x44Logo.png rename to crates/workshop/desktop/icons/Square44x44Logo.png diff --git a/crates/workshop/shell/icons/Square71x71Logo.png b/crates/workshop/desktop/icons/Square71x71Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square71x71Logo.png rename to crates/workshop/desktop/icons/Square71x71Logo.png diff --git a/crates/workshop/shell/icons/Square89x89Logo.png b/crates/workshop/desktop/icons/Square89x89Logo.png similarity index 100% rename from crates/workshop/shell/icons/Square89x89Logo.png rename to crates/workshop/desktop/icons/Square89x89Logo.png diff --git a/crates/workshop/shell/icons/StoreLogo.png b/crates/workshop/desktop/icons/StoreLogo.png similarity index 100% rename from crates/workshop/shell/icons/StoreLogo.png rename to crates/workshop/desktop/icons/StoreLogo.png diff --git a/crates/workshop/shell/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/crates/workshop/desktop/icons/android/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-anydpi-v26/ic_launcher.xml rename to crates/workshop/desktop/icons/android/mipmap-anydpi-v26/ic_launcher.xml diff --git a/crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher.png rename to crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher.png diff --git a/crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher_foreground.png rename to crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-hdpi/ic_launcher_round.png rename to crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_round.png diff --git a/crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher.png rename to crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher.png diff --git a/crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher_foreground.png rename to crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-mdpi/ic_launcher_round.png rename to crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_round.png diff --git a/crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher.png rename to crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher.png diff --git a/crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher_foreground.png rename to crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xhdpi/ic_launcher_round.png rename to crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher.png rename to crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxhdpi/ic_launcher_round.png rename to crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher.png rename to crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from crates/workshop/shell/icons/android/mipmap-xxxhdpi/ic_launcher_round.png rename to crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/crates/workshop/shell/icons/android/values/ic_launcher_background.xml b/crates/workshop/desktop/icons/android/values/ic_launcher_background.xml similarity index 100% rename from crates/workshop/shell/icons/android/values/ic_launcher_background.xml rename to crates/workshop/desktop/icons/android/values/ic_launcher_background.xml diff --git a/crates/workshop/shell/icons/dmg-background.png b/crates/workshop/desktop/icons/dmg-background.png similarity index 100% rename from crates/workshop/shell/icons/dmg-background.png rename to crates/workshop/desktop/icons/dmg-background.png diff --git a/crates/workshop/shell/icons/icon.icns b/crates/workshop/desktop/icons/icon.icns similarity index 100% rename from crates/workshop/shell/icons/icon.icns rename to crates/workshop/desktop/icons/icon.icns diff --git a/crates/workshop/shell/icons/icon.ico b/crates/workshop/desktop/icons/icon.ico similarity index 100% rename from crates/workshop/shell/icons/icon.ico rename to crates/workshop/desktop/icons/icon.ico diff --git a/crates/workshop/shell/icons/icon.png b/crates/workshop/desktop/icons/icon.png similarity index 100% rename from crates/workshop/shell/icons/icon.png rename to crates/workshop/desktop/icons/icon.png diff --git a/crates/workshop/shell/icons/installer-header.bmp b/crates/workshop/desktop/icons/installer-header.bmp similarity index 100% rename from crates/workshop/shell/icons/installer-header.bmp rename to crates/workshop/desktop/icons/installer-header.bmp diff --git a/crates/workshop/shell/icons/installer-header.png b/crates/workshop/desktop/icons/installer-header.png similarity index 100% rename from crates/workshop/shell/icons/installer-header.png rename to crates/workshop/desktop/icons/installer-header.png diff --git a/crates/workshop/shell/icons/installer-sidebar.bmp b/crates/workshop/desktop/icons/installer-sidebar.bmp similarity index 100% rename from crates/workshop/shell/icons/installer-sidebar.bmp rename to crates/workshop/desktop/icons/installer-sidebar.bmp diff --git a/crates/workshop/shell/icons/installer-sidebar.png b/crates/workshop/desktop/icons/installer-sidebar.png similarity index 100% rename from crates/workshop/shell/icons/installer-sidebar.png rename to crates/workshop/desktop/icons/installer-sidebar.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-20x20@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@1x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-20x20@1x.png rename to crates/workshop/desktop/icons/ios/AppIcon-20x20@1x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-20x20@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-20x20@2x-1.png rename to crates/workshop/desktop/icons/ios/AppIcon-20x20@2x-1.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-20x20@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-20x20@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-20x20@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-20x20@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@3x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-20x20@3x.png rename to crates/workshop/desktop/icons/ios/AppIcon-20x20@3x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-29x29@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@1x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-29x29@1x.png rename to crates/workshop/desktop/icons/ios/AppIcon-29x29@1x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-29x29@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-29x29@2x-1.png rename to crates/workshop/desktop/icons/ios/AppIcon-29x29@2x-1.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-29x29@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-29x29@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-29x29@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-29x29@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@3x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-29x29@3x.png rename to crates/workshop/desktop/icons/ios/AppIcon-29x29@3x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-40x40@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@1x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-40x40@1x.png rename to crates/workshop/desktop/icons/ios/AppIcon-40x40@1x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-40x40@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-40x40@2x-1.png rename to crates/workshop/desktop/icons/ios/AppIcon-40x40@2x-1.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-40x40@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-40x40@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-40x40@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-40x40@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@3x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-40x40@3x.png rename to crates/workshop/desktop/icons/ios/AppIcon-40x40@3x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-512@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-512@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-512@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-512@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-60x60@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-60x60@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-60x60@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-60x60@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-60x60@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-60x60@3x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-60x60@3x.png rename to crates/workshop/desktop/icons/ios/AppIcon-60x60@3x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-76x76@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-76x76@1x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-76x76@1x.png rename to crates/workshop/desktop/icons/ios/AppIcon-76x76@1x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-76x76@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-76x76@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-76x76@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-76x76@2x.png diff --git a/crates/workshop/shell/icons/ios/AppIcon-83.5x83.5@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from crates/workshop/shell/icons/ios/AppIcon-83.5x83.5@2x.png rename to crates/workshop/desktop/icons/ios/AppIcon-83.5x83.5@2x.png diff --git a/crates/workshop/shell/installer.nsi b/crates/workshop/desktop/installer.nsi similarity index 100% rename from crates/workshop/shell/installer.nsi rename to crates/workshop/desktop/installer.nsi diff --git a/crates/workshop/shell/permissions/autogenerated/desktop_update_supported.toml b/crates/workshop/desktop/permissions/autogenerated/desktop_update_supported.toml similarity index 100% rename from crates/workshop/shell/permissions/autogenerated/desktop_update_supported.toml rename to crates/workshop/desktop/permissions/autogenerated/desktop_update_supported.toml diff --git a/crates/workshop/shell/permissions/autogenerated/quit.toml b/crates/workshop/desktop/permissions/autogenerated/quit.toml similarity index 100% rename from crates/workshop/shell/permissions/autogenerated/quit.toml rename to crates/workshop/desktop/permissions/autogenerated/quit.toml diff --git a/crates/workshop/shell/src/bridge.rs b/crates/workshop/desktop/src/bridge.rs similarity index 100% rename from crates/workshop/shell/src/bridge.rs rename to crates/workshop/desktop/src/bridge.rs diff --git a/crates/workshop/shell/src/config.rs b/crates/workshop/desktop/src/config.rs similarity index 100% rename from crates/workshop/shell/src/config.rs rename to crates/workshop/desktop/src/config.rs diff --git a/crates/workshop/shell/src/drops.rs b/crates/workshop/desktop/src/drops.rs similarity index 100% rename from crates/workshop/shell/src/drops.rs rename to crates/workshop/desktop/src/drops.rs diff --git a/crates/workshop/shell/src/gateway.rs b/crates/workshop/desktop/src/gateway.rs similarity index 100% rename from crates/workshop/shell/src/gateway.rs rename to crates/workshop/desktop/src/gateway.rs diff --git a/crates/workshop/shell/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs similarity index 100% rename from crates/workshop/shell/src/gateway/boot.rs rename to crates/workshop/desktop/src/gateway/boot.rs diff --git a/crates/workshop/shell/src/gateway/identity.rs b/crates/workshop/desktop/src/gateway/identity.rs similarity index 100% rename from crates/workshop/shell/src/gateway/identity.rs rename to crates/workshop/desktop/src/gateway/identity.rs diff --git a/crates/workshop/shell/src/gateway/supervisor.rs b/crates/workshop/desktop/src/gateway/supervisor.rs similarity index 100% rename from crates/workshop/shell/src/gateway/supervisor.rs rename to crates/workshop/desktop/src/gateway/supervisor.rs diff --git a/crates/workshop/shell/src/gateway/tests.rs b/crates/workshop/desktop/src/gateway/tests.rs similarity index 100% rename from crates/workshop/shell/src/gateway/tests.rs rename to crates/workshop/desktop/src/gateway/tests.rs diff --git a/crates/workshop/shell/src/gateway/tests/boot.rs b/crates/workshop/desktop/src/gateway/tests/boot.rs similarity index 100% rename from crates/workshop/shell/src/gateway/tests/boot.rs rename to crates/workshop/desktop/src/gateway/tests/boot.rs diff --git a/crates/workshop/shell/src/gateway/tests/identity.rs b/crates/workshop/desktop/src/gateway/tests/identity.rs similarity index 100% rename from crates/workshop/shell/src/gateway/tests/identity.rs rename to crates/workshop/desktop/src/gateway/tests/identity.rs diff --git a/crates/workshop/shell/src/gateway/tests/recovery.rs b/crates/workshop/desktop/src/gateway/tests/recovery.rs similarity index 100% rename from crates/workshop/shell/src/gateway/tests/recovery.rs rename to crates/workshop/desktop/src/gateway/tests/recovery.rs diff --git a/crates/workshop/shell/src/gateway/tests/shutdown.rs b/crates/workshop/desktop/src/gateway/tests/shutdown.rs similarity index 100% rename from crates/workshop/shell/src/gateway/tests/shutdown.rs rename to crates/workshop/desktop/src/gateway/tests/shutdown.rs diff --git a/crates/workshop/shell/src/linux_media.rs b/crates/workshop/desktop/src/linux_media.rs similarity index 100% rename from crates/workshop/shell/src/linux_media.rs rename to crates/workshop/desktop/src/linux_media.rs diff --git a/crates/workshop/shell/src/main.rs b/crates/workshop/desktop/src/main.rs similarity index 100% rename from crates/workshop/shell/src/main.rs rename to crates/workshop/desktop/src/main.rs diff --git a/crates/workshop/shell/src/menu.rs b/crates/workshop/desktop/src/menu.rs similarity index 100% rename from crates/workshop/shell/src/menu.rs rename to crates/workshop/desktop/src/menu.rs diff --git a/crates/workshop/shell/src/navigation.rs b/crates/workshop/desktop/src/navigation.rs similarity index 100% rename from crates/workshop/shell/src/navigation.rs rename to crates/workshop/desktop/src/navigation.rs diff --git a/crates/workshop/shell/src/quit-tests.rs b/crates/workshop/desktop/src/quit-tests.rs similarity index 100% rename from crates/workshop/shell/src/quit-tests.rs rename to crates/workshop/desktop/src/quit-tests.rs diff --git a/crates/workshop/shell/src/quit.rs b/crates/workshop/desktop/src/quit.rs similarity index 100% rename from crates/workshop/shell/src/quit.rs rename to crates/workshop/desktop/src/quit.rs diff --git a/crates/workshop/shell/src/window_state-tests.rs b/crates/workshop/desktop/src/window_state-tests.rs similarity index 100% rename from crates/workshop/shell/src/window_state-tests.rs rename to crates/workshop/desktop/src/window_state-tests.rs diff --git a/crates/workshop/shell/src/window_state.rs b/crates/workshop/desktop/src/window_state.rs similarity index 100% rename from crates/workshop/shell/src/window_state.rs rename to crates/workshop/desktop/src/window_state.rs diff --git a/crates/workshop/shell/tauri.conf.json b/crates/workshop/desktop/tauri.conf.json similarity index 100% rename from crates/workshop/shell/tauri.conf.json rename to crates/workshop/desktop/tauri.conf.json diff --git a/crates/workshop/shell/tauri.macos.conf.json b/crates/workshop/desktop/tauri.macos.conf.json similarity index 100% rename from crates/workshop/shell/tauri.macos.conf.json rename to crates/workshop/desktop/tauri.macos.conf.json diff --git a/crates/workshop/shell/tauri.nightly.conf.json b/crates/workshop/desktop/tauri.nightly.conf.json similarity index 100% rename from crates/workshop/shell/tauri.nightly.conf.json rename to crates/workshop/desktop/tauri.nightly.conf.json diff --git a/tools/stage-gateway-sidecar.mjs b/tools/stage-gateway-sidecar.mjs index 21e92932..66879d0c 100644 --- a/tools/stage-gateway-sidecar.mjs +++ b/tools/stage-gateway-sidecar.mjs @@ -34,7 +34,7 @@ function sidecarPath(root, target) { root, "crates", "workshop", - "shell", + "desktop", "binaries", gatewaySidecarName(target), ); diff --git a/tools/stage-gateway-sidecar.test.mjs b/tools/stage-gateway-sidecar.test.mjs index e2e08e44..0418ae0b 100644 --- a/tools/stage-gateway-sidecar.test.mjs +++ b/tools/stage-gateway-sidecar.test.mjs @@ -113,7 +113,7 @@ test("stages and removes the real source file under Tauri's target name", () => root, "crates", "workshop", - "shell", + "desktop", "binaries", "promptforge-gateway-x86_64-pc-windows-msvc.exe", ), diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 47491759..49a36ec0 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -852,7 +852,7 @@ Components, in dependency order: -### Step 13: Move the desktop app to crates/workshop/desktop +### Step 13: Move the desktop app to crates/workshop/desktop [completed] - Component: Shell vocabulary - Piece: directory move From dc24c93c75b7788ca1334092ad2bc29a5b7738da Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 18:47:37 -0700 Subject: [PATCH 14/44] Retire shell in the Rust crates and workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Workshop vocabulary now reserves shell for terminal command shells, and the Rust sources, build checks, and workflow comments rename every other use. Where shell meant the Tauri application it now reads the desktop app, and where it meant the server or its build-check tier it now reads the server. The change is names and prose only, so no public API, wire shape, or persisted format moves. - `SHELL` — in `crates/build-xtask/src/tidy.rs` the tier constant becomes `SERVER`, and in `crates/build-xtask/src/product.rs` the boundary constant becomes `DESKTOP`, still valued `"workshop"`, so the tier reads "server" and the boundary reads "desktop app". - `shape_for_shell` — the desktop config's listener-shaping function becomes `shape_for_desktop`, and its `SHELL_BIND` constant becomes `DESKTOP_BIND`. - `the_shell_re_adding_workshop_server_is_reported` — renamed to `the_desktop_app_re_adding_workshop_server_is_reported`; the tidy test, the server-api surface test, and the desktop sibling-probe test drop shell from their names the same way. - `tao/wry shell` — becomes `tao/wry runtime`, the windowing-framework sense that names neither the desktop app nor the server. - `thin shell` — becomes `thin entry point` in the server's crate and binary docs, so the server stops describing itself as a shell. - `shell` — no longer names the desktop app or the server in the touched files; the only remaining uses are terminal command shells and third-party names. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .github/workflows/ci.yml | 2 +- .github/workflows/release-workshop.yml | 12 +++---- crates/build-xtask/src/new_crate.rs | 2 +- crates/build-xtask/src/product-tests.rs | 24 ++++++------- crates/build-xtask/src/product.rs | 12 +++---- crates/build-xtask/src/tidy-tests.rs | 8 ++--- crates/build-xtask/src/tidy.rs | 14 ++++---- crates/workshop/desktop/Cargo.toml | 6 ++-- crates/workshop/desktop/installer.nsi | 6 ++-- crates/workshop/desktop/src/bridge.rs | 2 +- crates/workshop/desktop/src/config.rs | 34 +++++++++---------- crates/workshop/desktop/src/gateway.rs | 2 +- crates/workshop/desktop/src/gateway/boot.rs | 6 ++-- .../workshop/desktop/src/gateway/identity.rs | 6 ++-- .../desktop/src/gateway/tests/boot.rs | 2 +- crates/workshop/desktop/src/menu.rs | 6 ++-- crates/workshop/desktop/src/quit.rs | 6 ++-- crates/workshop/desktop/src/window_state.rs | 8 ++--- crates/workshop/gateway/src/gateway.rs | 2 +- .../workshop/gateway/src/gateway_binding.rs | 2 +- crates/workshop/gateway/src/handles.rs | 2 +- crates/workshop/gateway/src/lib.rs | 2 +- crates/workshop/gateway/src/resolve.rs | 2 +- crates/workshop/protocol/src/error.rs | 2 +- crates/workshop/protocol/tests/it/frames.rs | 2 +- crates/workshop/registry/src/registry.rs | 4 +-- crates/workshop/registry/src/traits.rs | 6 ++-- crates/workshop/server-api/Cargo.toml | 6 ++-- crates/workshop/server-api/src/lib-tests.rs | 4 +-- crates/workshop/server-api/src/lib.rs | 14 ++++---- crates/workshop/server/Cargo.toml | 4 +-- crates/workshop/server/src/agents.rs | 26 +++++++------- crates/workshop/server/src/agents/bindings.rs | 8 ++--- crates/workshop/server/src/agents/session.rs | 4 +-- crates/workshop/server/src/agents/socket.rs | 4 +-- crates/workshop/server/src/agents/state.rs | 16 ++++----- crates/workshop/server/src/agents/status.rs | 8 ++--- crates/workshop/server/src/app.rs | 16 ++++----- crates/workshop/server/src/assets.rs | 4 +-- crates/workshop/server/src/cross_site.rs | 10 +++--- crates/workshop/server/src/csp.rs | 8 ++--- crates/workshop/server/src/error.rs | 4 +-- crates/workshop/server/src/fixtures.rs | 2 +- crates/workshop/server/src/lib.rs | 14 ++++---- crates/workshop/server/src/main.rs | 2 +- crates/workshop/server/src/routes/assets.rs | 2 +- crates/workshop/server/src/serve.rs | 2 +- .../server/tests/it/heartbeat_loop.rs | 2 +- crates/workshop/server/tests/it/user_state.rs | 2 +- crates/workshop/support/src/config.rs | 2 +- crates/workshop/user-state/src/handlers.rs | 2 +- crates/workshop/user-state/src/lib.rs | 4 +-- .../workspace/src/handlers-file-tests.rs | 2 +- .../workshop/workspace/src/handlers-file.rs | 4 +-- crates/workshop/workspace/src/handles.rs | 10 +++--- crates/workshop/workspace/src/lib.rs | 2 +- .../workspace/src/workspace-tests-close.rs | 4 +-- .../workspace/src/workspace_file-actor.rs | 2 +- .../workshop/workspace/src/workspace_file.rs | 4 +-- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 60 files changed, 191 insertions(+), 191 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab237cf..864e1a80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,7 +179,7 @@ jobs: - name: Stage Gateway sidecar run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe - # workshop-server-api is the shell's re-export view of workshop-server, + # workshop-server-api is the desktop app's re-export view of workshop-server, # so it runs in this partition with the crates it fronts and stays # out of the workspace-wide jobs like them. - name: Clippy (workshop) diff --git a/.github/workflows/release-workshop.yml b/.github/workflows/release-workshop.yml index 1967f249..50789a2e 100644 --- a/.github/workflows/release-workshop.yml +++ b/.github/workflows/release-workshop.yml @@ -292,7 +292,7 @@ jobs: Select-Object -First 1 if (-not $exe) { throw "promptforge-workshop.exe not found after install" } # The gateway ships as an externalBin sidecar; a default (all - # components checked) silent install must lay it beside the shell. + # components checked) silent install must lay it beside the desktop app. $gateway = @("$env:LOCALAPPDATA", "$env:ProgramFiles", "${env:ProgramFiles(x86)}") | ForEach-Object { Get-ChildItem $_ -Recurse -Filter promptforge-gateway.exe -ErrorAction SilentlyContinue } | Select-Object -First 1 @@ -304,7 +304,7 @@ jobs: $output $configDir = Join-Path $env:USERPROFILE ".promptforge" New-Item -ItemType Directory -Path $configDir -Force | Out-Null - # A default install lays the gateway beside the shell, so boot + # A default install lays the gateway beside the desktop app, so boot # launches it instead of using an explicit endpoint. A minimal # gateway.toml keeps that first boot free of STT model downloads; # the dummy workshop.toml endpoint remains the fallback when no @@ -360,7 +360,7 @@ jobs: cp -R "/Volumes/PromptForge/PromptForge.app" /tmp/PromptForge.app hdiutil detach "/Volumes/PromptForge" -quiet # The bundle holds two executables (the gateway ships via - # externalBin beside the main binary); pick the shell by name. + # externalBin beside the main binary); pick the desktop app by name. bin="/tmp/PromptForge.app/Contents/MacOS/promptforge-workshop" [ -x "$bin" ] || { echo "promptforge-workshop not in the bundle"; exit 1; } version="${{ needs.prepare.outputs.version }}" @@ -368,7 +368,7 @@ jobs: expected="promptforge-workshop $version" [ "$output" = "$expected" ] || { echo "--version printed '$output', expected '$expected'"; exit 1; } mkdir -p "$HOME/.promptforge" - # A default install lays the gateway beside the shell, so boot + # A default install lays the gateway beside the desktop app, so boot # launches it instead of using an explicit endpoint. A minimal # gateway.toml keeps that first boot free of STT model downloads; # the dummy workshop.toml endpoint remains the fallback when no @@ -407,7 +407,7 @@ jobs: package=$(dpkg-deb -f "$deb" Package) # The deb holds two executables (the gateway ships via # externalBin into /usr/bin beside the main binary); pick the - # shell by name. + # desktop app by name. bin=$(dpkg -L "$package" | grep -E '/usr/bin/promptforge-workshop$' | head -1) [ -n "$bin" ] || { echo "no promptforge-workshop in the deb"; exit 1; } version="${{ needs.prepare.outputs.version }}" @@ -421,7 +421,7 @@ jobs: output=$("$appimage" --version) [ "$output" = "$expected" ] || { echo "AppImage --version printed '$output', expected '$expected'"; exit 1; } mkdir -p "$HOME/.promptforge" - # A default install lays the gateway beside the shell, so boot + # A default install lays the gateway beside the desktop app, so boot # launches it instead of using an explicit endpoint. A minimal # gateway.toml keeps that first boot free of STT model downloads; # the dummy workshop.toml endpoint remains the fallback when no diff --git a/crates/build-xtask/src/new_crate.rs b/crates/build-xtask/src/new_crate.rs index 7125b5f3..146dc9b8 100644 --- a/crates/build-xtask/src/new_crate.rs +++ b/crates/build-xtask/src/new_crate.rs @@ -69,7 +69,7 @@ fn lib_rs(name: &str) -> String { //!\n\ //! ## Invariants\n\ //!\n\ - //! - Tier: TODO (vocabulary | services | features | shell); may depend\n\ + //! - Tier: TODO (vocabulary | services | features | server); may depend\n\ //! on: TODO. Read `AGENTS.md` before adding an import.\n\ //! - Every file in this crate stays under 500 lines; split first, then\n\ //! edit.\n" diff --git a/crates/build-xtask/src/product-tests.rs b/crates/build-xtask/src/product-tests.rs index 85203b5a..40cdb46b 100644 --- a/crates/build-xtask/src/product-tests.rs +++ b/crates/build-xtask/src/product-tests.rs @@ -1,5 +1,5 @@ //! Family-matrix fixtures: the dependency rules between product families, -//! the shell boundary, and the classification itself. Container-privacy +//! the desktop-app boundary, and the classification itself. Container-privacy //! fixtures sit in `product-container-tests.rs`. use super::test_support::{workspace_root, write_crate}; @@ -19,25 +19,25 @@ fn workspace_respects_the_product_boundary() { fn workshop_depends_on_workshop_server_api_only() { let walk = workspace_crates(&workspace_root()); assert!(walk.violations.is_empty(), "{:?}", walk.violations); - let shell = walk + let desktop = walk .crates .iter() .find(|krate| krate.package == "workshop") - .expect("the workshop shell crate is a workspace member"); + .expect("the workshop desktop app crate is a workspace member"); assert!( - shell.deps.iter().any(|dep| dep == "workshop-server-api"), - "the shell reaches the server through the api crate: {:?}", - shell.deps + desktop.deps.iter().any(|dep| dep == "workshop-server-api"), + "the desktop app reaches the server through the api crate: {:?}", + desktop.deps ); assert!( - !shell.deps.iter().any(|dep| dep == "workshop-server"), - "the shell never depends on workshop-server directly: {:?}", - shell.deps + !desktop.deps.iter().any(|dep| dep == "workshop-server"), + "the desktop app never depends on workshop-server directly: {:?}", + desktop.deps ); } #[test] -fn the_shell_re_adding_workshop_server_is_reported() { +fn the_desktop_app_re_adding_workshop_server_is_reported() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), @@ -58,7 +58,7 @@ fn the_shell_re_adding_workshop_server_is_reported() { assert!( violations[0].starts_with("workshop depends on workshop-server:") && violations[0].contains("workshop-server-api"), - "the violation names the shell, the forbidden dep, and the facade: {violations:?}" + "the violation names the desktop app, the forbidden dep, and the facade: {violations:?}" ); } @@ -75,7 +75,7 @@ fn other_workshop_crates_may_depend_on_workshop_server() { let violations = product_boundary_violations(root.path()); assert!( violations.is_empty(), - "the rule binds only the shell crate: {violations:?}" + "the rule binds only the desktop app crate: {violations:?}" ); } diff --git a/crates/build-xtask/src/product.rs b/crates/build-xtask/src/product.rs index 5687e025..02215bc9 100644 --- a/crates/build-xtask/src/product.rs +++ b/crates/build-xtask/src/product.rs @@ -27,7 +27,7 @@ //! `crates/gateway/stt/` is a subsystem private to the gateway family, //! with `gateway-stt` as its public member - the one crate inside the //! family outside the subsystem may name. -//! - Shell boundary: the `workshop` shell depends on `workshop-server-api` +//! - Desktop-app boundary: the `workshop` desktop app depends on `workshop-server-api` //! and never on `workshop-server`. use std::fs; @@ -117,9 +117,9 @@ pub(crate) fn product_boundary_violations(root: &Path) -> Vec { violations } -/// The Tauri shell crate, bound by the shell-boundary rule. -const SHELL: &str = "workshop"; -/// The server crate the shell must never name directly. +/// The Tauri desktop app crate, bound by the desktop-app boundary rule. +const DESKTOP: &str = "workshop"; +/// The server crate the desktop app must never name directly. const SERVER: &str = "workshop-server"; /// The promptforge-family crates outside crates may depend on directly. const PUBLIC_PROMPTFORGE: [&str; 1] = ["promptforge"]; @@ -134,9 +134,9 @@ const PUBLIC_HARNESS: &str = "harness-api"; /// The reason a dependency from `package` to `dep` breaches the matrix, /// or `None` when the edge is legal. fn boundary_breach(package: &CrateInfo, dep: &CrateInfo) -> Option { - if package.package == SHELL && dep.package == SERVER { + if package.package == DESKTOP && dep.package == SERVER { return Some( - "the workshop shell depends on workshop-server-api, never on workshop-server" + "the workshop desktop app depends on workshop-server-api, never on workshop-server" .to_owned(), ); } diff --git a/crates/build-xtask/src/tidy-tests.rs b/crates/build-xtask/src/tidy-tests.rs index d7903014..df6fd808 100644 --- a/crates/build-xtask/src/tidy-tests.rs +++ b/crates/build-xtask/src/tidy-tests.rs @@ -53,7 +53,7 @@ fn a_tiered_crate_whose_manifest_is_missing_is_reported_not_skipped() { let root = tempfile::TempDir::new().expect("tempdir"); std::fs::create_dir_all(root.path().join("crates")).expect("the crates directory creates"); let violations = tier_dependency_violations(root.path()); - let tiered = [VOCABULARY, SERVICES, FEATURES, SHELL].concat(); + let tiered = [VOCABULARY, SERVICES, FEATURES, SERVER].concat(); assert_eq!( violations.len(), tiered.len(), @@ -216,7 +216,7 @@ fn the_tidy_checks_and_the_product_checks_enumerate_the_same_crates() { } #[test] -fn the_workshop_shell_without_the_marker_passes_and_stays_outside_the_ceiling() { +fn the_workshop_desktop_app_without_the_marker_passes_and_stays_outside_the_ceiling() { let root = tempfile::TempDir::new().expect("tempdir"); write_crate( root.path(), @@ -227,11 +227,11 @@ fn the_workshop_shell_without_the_marker_passes_and_stays_outside_the_ceiling() ); assert!( marker_violations(root.path()).is_empty(), - "the shell is exempt from the marker" + "the desktop app is exempt from the marker" ); assert!( file_ceiling_violations(root.path()).is_empty(), - "the unmarked shell does not participate in the ceiling" + "the unmarked desktop app does not participate in the ceiling" ); } diff --git a/crates/build-xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs index ae4e1ccf..8e806fc6 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -10,7 +10,7 @@ //! enforces the architecture; `cargo xtask tidy` prints the same report //! on demand. The file ceiling and lint inheritance checks bind every //! `workshop-*` and `harness-*` crate (plus `harness-api`, minus the -//! `workshop` shell) by package name, every other crate whose crate +//! `workshop` desktop app) by package name, every other crate whose crate //! docs have the `## Invariants` marker, and every crate directory whose //! manifest the shared walk could not read, parse, or find a package name //! in - a crate with no readable name cannot be shown exempt. Those read @@ -24,11 +24,11 @@ const VOCABULARY: &[&str] = &["workshop-protocol", "workshop-registry", "worksho /// Tier 1: domain services. Depend on vocabulary crates only. const SERVICES: &[&str] = &["workshop-gateway", "workshop-menu", "workshop-status"]; /// Tier 2: features. Depend on vocabulary and service crates. The -/// sessions subsystem sits inside the shell since Workshop moved onto the +/// sessions subsystem sits inside the server since Workshop moved onto the /// harness, so it has no crate here. const FEATURES: &[&str] = &["workshop-user-state", "workshop-workspace"]; -/// Tier 3: the shell. May depend on every lower tier. -const SHELL: &[&str] = &["workshop-server"]; +/// Tier 3: the server. May depend on every lower tier. +const SERVER: &[&str] = &["workshop-server"]; /// File-line ceiling from the `AGENTS.md` structural rules. const MAX_FILE_LINES: usize = 500; @@ -72,7 +72,7 @@ fn allowed_dependencies(name: &str) -> Option> { VOCABULARY.to_vec() } else if FEATURES.contains(&name) { [VOCABULARY, SERVICES].concat() - } else if SHELL.contains(&name) { + } else if SERVER.contains(&name) { [VOCABULARY, SERVICES, FEATURES].concat() } else { return None; @@ -94,7 +94,7 @@ fn tiered_crate_dir(root: &Path, name: &str) -> PathBuf { #[must_use] pub(crate) fn tier_dependency_violations(root: &Path) -> Vec { let mut violations = Vec::new(); - for name in [VOCABULARY, SERVICES, FEATURES, SHELL].concat() { + for name in [VOCABULARY, SERVICES, FEATURES, SERVER].concat() { let Some(allowed) = allowed_dependencies(name) else { continue; }; @@ -249,7 +249,7 @@ pub(crate) fn marker_violations(root: &Path) -> Vec { /// Whether a package name places the crate in a family that must have the /// marker: `workshop-*` and `harness-*` (which covers `harness-api`). The -/// Tauri shell (the `workshop` package) is exempt. +/// Tauri desktop app (the `workshop` package) is exempt. fn family_requires_marker(name: &str) -> bool { name != "workshop" && (name.starts_with("workshop-") || name.starts_with("harness-")) } diff --git a/crates/workshop/desktop/Cargo.toml b/crates/workshop/desktop/Cargo.toml index 8fce4c57..44706f3f 100644 --- a/crates/workshop/desktop/Cargo.toml +++ b/crates/workshop/desktop/Cargo.toml @@ -15,12 +15,12 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true # The in-process workshop server, seen only through its re-export facade: -# the shell spawns it on an OS-assigned loopback port and points the +# the desktop app spawns it on an OS-assigned loopback port and points the # window at it. Never depend on `workshop-server` directly: the facade is -# the shell's sole view of the server. +# the desktop app's sole view of the server. workshop-server-api.workspace = true # Window geometry lives in the workspace file (src/window_state.rs): the -# shell reads and writes it over the server's loopback HTTP API with the +# desktop app reads and writes it over the server's loopback HTTP API with the # workspace's reqwest feature set (json, rustls), the same set the # gateway builds with, so cargo deny and the static-CRT Windows build see # nothing new. diff --git a/crates/workshop/desktop/installer.nsi b/crates/workshop/desktop/installer.nsi index 1f63cc8e..7bc2a317 100644 --- a/crates/workshop/desktop/installer.nsi +++ b/crates/workshop/desktop/installer.nsi @@ -469,11 +469,11 @@ Function FinishPageShow SetCtlColors $mui.FinishPage.Run "${MUI_TEXTCOLOR}" "${MUI_BGCOLOR}" System::Call 'UXTHEME::SetWindowTheme(p$mui.FinishPage.ShowReadme,w" ",w" ")' SetCtlColors $mui.FinishPage.ShowReadme "${MUI_TEXTCOLOR}" "${MUI_BGCOLOR}" - ; The Run checkbox follows the components: the Workshop shell when + ; The Run checkbox follows the components: the Workshop desktop app when ; installed; on a Gateway-only install it becomes the first-run browser ; handoff to the gateway's Settings page; hidden when neither landed. ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" - ; Default label and target: run the shell. + ; Default label and target: run the desktop app. ${ElseIf} ${FileExists} "$INSTDIR\promptforge-gateway.exe" SendMessage $mui.FinishPage.Run ${WM_SETTEXT} 0 "STR:Open PromptForge Gateway settings in your browser" ${Else} @@ -482,7 +482,7 @@ Function FinishPageShow FunctionEnd Function RunMainBinary - ; The finish-page Run checkbox follows the components: the Workshop shell + ; The finish-page Run checkbox follows the components: the Workshop desktop app ; when installed; on a Gateway-only install it launches the gateway with ; --browser, so the first boot opens the Settings page in the ; default browser. diff --git a/crates/workshop/desktop/src/bridge.rs b/crates/workshop/desktop/src/bridge.rs index 86373798..7293c554 100644 --- a/crates/workshop/desktop/src/bridge.rs +++ b/crates/workshop/desktop/src/bridge.rs @@ -21,7 +21,7 @@ //! //! The same subscription pass installs the `PermissionRequested` handler //! that grants the microphone (and nothing else), replacing wry's -//! `with_permission_handler` from the tao/wry shell. +//! `with_permission_handler` from the tao/wry runtime. use std::path::PathBuf; use std::sync::mpsc; diff --git a/crates/workshop/desktop/src/config.rs b/crates/workshop/desktop/src/config.rs index 16e93b4f..1c490c14 100644 --- a/crates/workshop/desktop/src/config.rs +++ b/crates/workshop/desktop/src/config.rs @@ -1,11 +1,11 @@ -//! The shell's workshop-server configuration: `workshop.toml` discovery +//! The desktop app's workshop-server configuration: `workshop.toml` discovery //! and the forced ephemeral loopback bind. //! -//! The shell hosts the workshop server in-process, so the listener -//! settings are the shell's own: the bind is always `127.0.0.1:0` (an +//! The desktop app hosts the workshop server in-process, so the listener +//! settings are the desktop app's own: the bind is always `127.0.0.1:0` (an //! OS-assigned port - a fixed port is a conflict class the //! single-instance handoff cannot close) and `open_browser` stays off -//! (the shell drives its own window). A discovered `workshop.toml` still +//! (the desktop app drives its own window). A discovered `workshop.toml` still //! owns the `[gateway]` connection settings and the state and //! agent-program paths; the gateway endpoint itself resolves inside the //! server, gateway discovery file first, explicit config second. @@ -18,16 +18,16 @@ use workshop_server_api::Config; /// Canonical file name searched for at each candidate location. const CONFIG_FILE_NAME: &str = "workshop.toml"; -/// The shell's listener bind: loopback on an OS-assigned port, reported +/// The desktop app's listener bind: loopback on an OS-assigned port, reported /// back through the server handle once bound. -const SHELL_BIND: &str = "127.0.0.1:0"; +const DESKTOP_BIND: &str = "127.0.0.1:0"; -/// Loads the shell's workshop-server configuration. +/// Loads the desktop app's workshop-server configuration. /// /// A `workshop.toml` found in the search order - beside the executable, /// then the current directory, then the user profile's `.promptforge` /// directory - supplies the `[gateway]` connection and the path -/// settings; the listener settings are forced to the shell's own. With +/// settings; the listener settings are forced to the desktop app's own. With /// no file, the default config anchors its state in the profile's /// `.promptforge` directory and leaves the gateway to endpoint /// resolution, which attaches through the gateway discovery @@ -58,7 +58,7 @@ fn load_in(exe_dir: &Path, cwd: &Path, home: Option<&Path>) -> anyhow::Result { let mut config = Config::load(&path).with_context(|| format!("load {}", path.display()))?; - shape_for_shell(&mut config); + shape_for_desktop(&mut config); Ok(config) } None => Ok(default_config(home)), @@ -85,10 +85,10 @@ fn profile_dir(home: &Path) -> PathBuf { home.join(".promptforge") } -/// Forces the listener settings the shell owns onto a loaded config: the +/// Forces the listener settings the desktop app owns onto a loaded config: the /// ephemeral loopback bind and no browser opening. -fn shape_for_shell(config: &mut Config) { - config.server.bind = SHELL_BIND.to_string(); +fn shape_for_desktop(config: &mut Config) { + config.server.bind = DESKTOP_BIND.to_string(); config.server.open_browser = false; } @@ -102,7 +102,7 @@ fn default_config(home: Option<&Path>) -> Config { api_key: String::new(), }, server: workshop_server_api::ServerConfig { - bind: SHELL_BIND.to_string(), + bind: DESKTOP_BIND.to_string(), open_browser: false, state_dir: PathBuf::new(), }, @@ -195,12 +195,12 @@ mod tests { let config = load_in(&exe, &cwd, Some(&home)).expect("loads"); assert_eq!(config.gateway.base_url, "http://gateway.lan:9999"); assert_eq!( - config.server.bind, SHELL_BIND, - "the shell owns the listener: an OS-assigned loopback port" + config.server.bind, DESKTOP_BIND, + "the desktop app owns the listener: an OS-assigned loopback port" ); assert!( !config.server.open_browser, - "the shell drives its own window" + "the desktop app drives its own window" ); assert_eq!( config.server.state_dir, @@ -217,7 +217,7 @@ mod tests { config.gateway.base_url, "", "an empty base_url is the not-explicit signal resolution reads" ); - assert_eq!(config.server.bind, SHELL_BIND); + assert_eq!(config.server.bind, DESKTOP_BIND); assert!(!config.server.open_browser); assert_eq!(config.server.state_dir, profile_dir(&home)); assert_eq!(config.agents.path, profile_dir(&home).join("agents")); diff --git a/crates/workshop/desktop/src/gateway.rs b/crates/workshop/desktop/src/gateway.rs index e77db248..e9ac75cf 100644 --- a/crates/workshop/desktop/src/gateway.rs +++ b/crates/workshop/desktop/src/gateway.rs @@ -1,4 +1,4 @@ -//! Attach-or-launch lifecycle for the desktop shell's Gateway sidecar. +//! Attach-or-launch lifecycle for the desktop app's Gateway sidecar. //! //! Boot planning and one-shot launch, validated identity, and continuous //! supervision are private sibling modules with one-way dependencies. diff --git a/crates/workshop/desktop/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs index 65e5a9ff..2601586d 100644 --- a/crates/workshop/desktop/src/gateway/boot.rs +++ b/crates/workshop/desktop/src/gateway/boot.rs @@ -12,10 +12,10 @@ use workshop_server_api::Config; use super::identity::GatewayAttachment; use super::supervisor::{RecoveryCandidate, RecoveryOwnership}; -/// The sibling executable the shell launches, beside its own. +/// The sibling executable the desktop app launches, beside its own. #[cfg(windows)] pub(super) const GATEWAY_EXE_NAME: &str = "promptforge-gateway.exe"; -/// The sibling executable the shell launches, beside its own. +/// The sibling executable the desktop app launches, beside its own. #[cfg(not(windows))] pub(super) const GATEWAY_EXE_NAME: &str = "promptforge-gateway"; @@ -231,7 +231,7 @@ fn detached_command(exe: &Path) -> std::process::Command { command } -/// Spawns the Gateway detached from the shell lifetime. +/// Spawns the Gateway detached from the desktop app's lifetime. pub(super) fn spawn_detached(exe: &Path) -> std::io::Result { #[cfg(windows)] let mut child = spawn_detached_windows_with(|flags| { diff --git a/crates/workshop/desktop/src/gateway/identity.rs b/crates/workshop/desktop/src/gateway/identity.rs index 21f599a3..73b56b73 100644 --- a/crates/workshop/desktop/src/gateway/identity.rs +++ b/crates/workshop/desktop/src/gateway/identity.rs @@ -7,11 +7,11 @@ use super::supervisor::RecoveryCandidate; /// How boot connected the Gateway. #[derive(Debug)] pub(crate) enum GatewayAttachment { - /// A local sidecar Gateway the shell attached to. + /// A local sidecar Gateway the desktop app attached to. Sidecar(ValidatedConnection), /// A child launched by this boot that has not yet entered server state. Launched(RecoveryCandidate), - /// An explicit-config Gateway that the shell does not own. + /// An explicit-config Gateway that the desktop app does not own. Config, } @@ -25,7 +25,7 @@ impl GatewayAttachment { } } - /// Reconciles the shell's candidate with the identity the server actually + /// Reconciles the desktop app's candidate with the identity the server actually /// published, disarming launched-child cleanup only for an exact match. pub(crate) fn reconcile_publication(self, published: Option) -> Self { match (self, published) { diff --git a/crates/workshop/desktop/src/gateway/tests/boot.rs b/crates/workshop/desktop/src/gateway/tests/boot.rs index dad90346..08c9d15f 100644 --- a/crates/workshop/desktop/src/gateway/tests/boot.rs +++ b/crates/workshop/desktop/src/gateway/tests/boot.rs @@ -212,7 +212,7 @@ fn a_resolve_error_still_launches_the_sibling_exe() { } #[test] -fn the_sibling_probe_finds_only_the_gateway_exe_beside_the_shell() { +fn the_sibling_probe_finds_only_the_gateway_exe_beside_the_desktop_app() { let (_dir, with) = exe_dir(true); assert_eq!( sibling_gateway(&with), diff --git a/crates/workshop/desktop/src/menu.rs b/crates/workshop/desktop/src/menu.rs index 7fb02846..3a927fa5 100644 --- a/crates/workshop/desktop/src/menu.rs +++ b/crates/workshop/desktop/src/menu.rs @@ -1,13 +1,13 @@ //! The window menu: the quit-everything affordance. //! -//! The shell's only menu item quits the app through the shared +//! The desktop app's only menu item quits the app through the shared //! shutdown-then-exit path in `quit.rs`, which the SPA's File > Exit row //! (the `quit` command) also runs: when boot attached to or launched a //! local sidecar gateway, the item first posts the gateway's `/shutdown` //! through the server's current validated Gateway snapshot, so one //! gesture stops the window, the in-process server, and the Gateway. //! Attached to a LAN Gateway through explicit config, the snapshot grants -//! no shutdown authority, so the item stops the shell only and says so. +//! no shutdown authority, so the item stops the desktop app only and says so. use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::{AppHandle, Wry}; @@ -16,7 +16,7 @@ use tauri::{AppHandle, Wry}; pub(crate) const QUIT_MENU_ID: &str = "quit-promptforge"; /// Builds and installs the app menu. A local sidecar makes the quit item -/// stop both products; a configured LAN Gateway makes it stop only the shell. +/// stop both products; a configured LAN Gateway makes it stop only the desktop app. /// /// # Errors /// Returns an error when the menu cannot be built or installed. diff --git a/crates/workshop/desktop/src/quit.rs b/crates/workshop/desktop/src/quit.rs index 582a09c1..9c162d51 100644 --- a/crates/workshop/desktop/src/quit.rs +++ b/crates/workshop/desktop/src/quit.rs @@ -5,7 +5,7 @@ //! gateway's `/shutdown` through the server's current validated Gateway //! snapshot, so one gesture stops the window, the in-process server, and //! the Gateway. Attached to a LAN Gateway through explicit config, the -//! snapshot grants no shutdown authority, so the gesture stops the shell +//! snapshot grants no shutdown authority, so the gesture stops the desktop app //! only. use std::sync::PoisonError; @@ -25,13 +25,13 @@ pub(crate) fn request_gateway_shutdown(gateway: Option) { && let Err(error) = gateway.request_shutdown() { eprintln!( - "the gateway did not accept the shutdown request; quitting the shell anyway: {error}" + "the gateway did not accept the shutdown request; quitting the desktop app anyway: {error}" ); } } /// The shared shutdown-then-exit path: request the local Gateway's -/// shutdown, then exit the shell (the `RunEvent::Exit` handler stops the +/// shutdown, then exit the desktop app (the `RunEvent::Exit` handler stops the /// in-process server). pub(crate) fn quit_everything(app: &AppHandle) { let gateway = app.try_state::().and_then(|slot| { diff --git a/crates/workshop/desktop/src/window_state.rs b/crates/workshop/desktop/src/window_state.rs index b262b99c..bf4f93b7 100644 --- a/crates/workshop/desktop/src/window_state.rs +++ b/crates/workshop/desktop/src/window_state.rs @@ -1,4 +1,4 @@ -//! Window geometry through the workspace file. The shell restores the +//! Window geometry through the workspace file. The desktop app restores the //! saved size, position, and maximized flag from //! `GET /workspace/file/current` before the window shows, writes them //! back through `PUT /workspace/file/window-state` - debounced while the @@ -35,7 +35,7 @@ pub(crate) const CLOSE_SAVE_TIMEOUT: Duration = Duration::from_secs(2); /// server: loopback, so generous. const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); /// The Tauri event the SPA emits after switching the workspace file, so -/// the shell can apply the file's geometry to the live window. +/// the desktop app can apply the file's geometry to the live window. const WORKSPACE_OPENED_EVENT: &str = "promptforge:workspace-opened"; /// How far inside the saved top-left corner the monitor probe looks: a /// window whose first title-bar pixels are on a monitor can be grabbed. @@ -58,7 +58,7 @@ pub(crate) struct WindowState { pub(crate) maximized: bool, } -/// The part of the `GET /workspace/file/current` answer the shell reads. +/// The part of the `GET /workspace/file/current` answer the desktop app reads. /// The path, name, and grants belong to the SPA and are ignored here. #[derive(Debug, Deserialize)] pub(crate) struct CurrentResponse { @@ -74,7 +74,7 @@ pub(crate) struct SavedResponse { pub(crate) saved: bool, } -/// The shell's minimal client for the in-process server's workspace-file +/// The desktop app's minimal client for the in-process server's workspace-file /// routes. The server admits it as a native client: no `Origin`, a /// loopback `Host`, and `application/json` on the body it sends. #[derive(Debug, Clone)] diff --git a/crates/workshop/gateway/src/gateway.rs b/crates/workshop/gateway/src/gateway.rs index d32b6bb8..26b8c415 100644 --- a/crates/workshop/gateway/src/gateway.rs +++ b/crates/workshop/gateway/src/gateway.rs @@ -88,7 +88,7 @@ pub enum GatewayError { } impl GatewayError { - /// A transport failure manufactured by a test, for the shell's + /// A transport failure manufactured by a test, for the server's /// error-mapping fixtures. #[cfg(feature = "test-fixtures")] #[must_use] diff --git a/crates/workshop/gateway/src/gateway_binding.rs b/crates/workshop/gateway/src/gateway_binding.rs index fc3504a8..d6c1016f 100644 --- a/crates/workshop/gateway/src/gateway_binding.rs +++ b/crates/workshop/gateway/src/gateway_binding.rs @@ -4,7 +4,7 @@ //! containing the HTTP client, base URL, bearer, and generation. //! A local-sidecar replacement builds the complete next snapshot before one //! atomic store, then notifies long-lived tasks to reconnect. Explicitly -//! configured endpoints never receive an updater from the desktop shell. +//! configured endpoints never receive an updater from the desktop app. mod publication; mod shutdown; diff --git a/crates/workshop/gateway/src/handles.rs b/crates/workshop/gateway/src/handles.rs index 6a663e50..f238d9d6 100644 --- a/crates/workshop/gateway/src/handles.rs +++ b/crates/workshop/gateway/src/handles.rs @@ -51,7 +51,7 @@ pub fn register(registry: &Registry, handles: GatewayHandles) -> Registration { /// Registers the gateway subsystem's background tasks: the /// reachability heartbeat and the gateway progress subscriber, both /// reporting through the registry's push facade. The tasks spawn when -/// the shell starts serving and stop inside the graceful-shutdown +/// the server starts serving and stop inside the graceful-shutdown /// signal. The returned guards keep the registrations alive; the /// composition root holds them for the process lifetime. pub fn register_tasks( diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index a24eec09..9c53690e 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -11,7 +11,7 @@ //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - No axum type appears in this crate's public API: the domain code -//! speaks `reqwest` statuses and raw bodies, and the shell maps them +//! speaks `reqwest` statuses and raw bodies, and the server maps them //! to HTTP responses. //! - A bearer key is never written to logs or `Debug` output. //! - User-visible reporting flows through the registry's push facade, so diff --git a/crates/workshop/gateway/src/resolve.rs b/crates/workshop/gateway/src/resolve.rs index e61729d8..29e434bd 100644 --- a/crates/workshop/gateway/src/resolve.rs +++ b/crates/workshop/gateway/src/resolve.rs @@ -238,7 +238,7 @@ fn validate_resolved( ValidatedConnection::validate(file) } -/// Reports the resolution outcome where the shell surfaces startup state: +/// Reports the resolution outcome where the server surfaces startup state: /// a removed file's reason and the winning source on the status bus, /// the same facts in the log. pub fn report(gateway: &ResolvedGateway, push: &Push) { diff --git a/crates/workshop/protocol/src/error.rs b/crates/workshop/protocol/src/error.rs index b0fd3627..4bdb9aac 100644 --- a/crates/workshop/protocol/src/error.rs +++ b/crates/workshop/protocol/src/error.rs @@ -38,7 +38,7 @@ impl ErrorFrame { /// The opaque wire error envelope every HTTP failure answers with: /// `{"error":{"message":"...","code":"..."}}`. /// -/// The shell maps its per-crate error types onto status codes and renders +/// The server maps its per-crate error types onto status codes and renders /// this envelope; the shape is pinned here so the wire contract sits in /// one place. Failures rendered as plain text (the asset 404) never take /// this shape. diff --git a/crates/workshop/protocol/tests/it/frames.rs b/crates/workshop/protocol/tests/it/frames.rs index e1d3da33..34142dd1 100644 --- a/crates/workshop/protocol/tests/it/frames.rs +++ b/crates/workshop/protocol/tests/it/frames.rs @@ -422,6 +422,6 @@ fn an_error_envelope_serializes_as_message_and_code_under_error() { assert_eq!( serde_json::to_value(&envelope).expect("the envelope serializes"), serde_json::json!({"error": {"message": "file cannot be read", "code": "read_file"}}), - "the wire shape matches the envelope the shell has always answered with" + "the wire shape matches the envelope the server has always answered with" ); } diff --git a/crates/workshop/registry/src/registry.rs b/crates/workshop/registry/src/registry.rs index 360b55a6..5675e827 100644 --- a/crates/workshop/registry/src/registry.rs +++ b/crates/workshop/registry/src/registry.rs @@ -103,7 +103,7 @@ impl Registry { Self::default() } - /// Registers a route contribution; the shell merges every + /// Registers a route contribution; the server merges every /// registrant into its API router in registration order. The /// returned guard keeps the contribution alive: dropping it removes /// the registrant. @@ -118,7 +118,7 @@ impl Registry { }) } - /// Registers a background task; the shell spawns every registrant + /// Registers a background task; the server spawns every registrant /// with serving and stops each through its [`ShutdownHandle`](crate::ShutdownHandle) /// in the graceful-shutdown closure. pub fn register_task(&self, task: Arc) -> Registration { diff --git a/crates/workshop/registry/src/traits.rs b/crates/workshop/registry/src/traits.rs index 846b8eb3..ec207c0b 100644 --- a/crates/workshop/registry/src/traits.rs +++ b/crates/workshop/registry/src/traits.rs @@ -26,7 +26,7 @@ mod sealed { use sealed::Sealed; /// Route registration: a subsystem contributes its HTTP routes, merged -/// into the shell's API router at composition time. +/// into the server's API router at composition time. pub trait RouteRegistrar: Sealed + Send + Sync { /// The subsystem's routes, with their state already applied. fn routes(&self) -> Router; @@ -35,7 +35,7 @@ pub trait RouteRegistrar: Sealed + Send + Sync { /// Background task spawning: a subsystem starts one long-lived task, so /// the composition root holds no `tokio::spawn` calls of its own. pub trait BackgroundTask: Sealed + Send + Sync { - /// Spawns the task; the returned handle is the shell's shutdown + /// Spawns the task; the returned handle is the server's shutdown /// lever. fn spawn(&self) -> ShutdownHandle; } @@ -48,7 +48,7 @@ type Stop = Box StopFuture + Send>; /// The shutdown lever of one spawned background task: a concrete type, /// never a trait with an `async fn` method, which would not be -/// dyn-compatible. Signaling and awaiting are one call, so the shell's +/// dyn-compatible. Signaling and awaiting are one call, so the server's /// graceful-shutdown closure cannot fire a stop it forgets to await. pub struct ShutdownHandle { stop: Option, diff --git a/crates/workshop/server-api/Cargo.toml b/crates/workshop/server-api/Cargo.toml index f227d196..c3723292 100644 --- a/crates/workshop/server-api/Cargo.toml +++ b/crates/workshop/server-api/Cargo.toml @@ -6,12 +6,12 @@ edition.workspace = true license.workspace = true repository.workspace = true -description = "The desktop shell's view of the workshop server: re-exports only, so server internals never resolve in the shell" +description = "The desktop app's view of the workshop server: re-exports only, so server internals never resolve in the desktop app" [features] default = [] -# Forwards the server's integration-test seams (`fixtures`) to the shell's -# tests without the shell depending on `workshop-server` itself. +# Forwards the server's integration-test seams (`fixtures`) to the desktop app's +# tests without the desktop app depending on `workshop-server` itself. test-fixtures = ["workshop-server/test-fixtures"] [dependencies] diff --git a/crates/workshop/server-api/src/lib-tests.rs b/crates/workshop/server-api/src/lib-tests.rs index 46fc13b9..9509656d 100644 --- a/crates/workshop/server-api/src/lib-tests.rs +++ b/crates/workshop/server-api/src/lib-tests.rs @@ -1,4 +1,4 @@ -//! Shell-facing surface tests: every re-export is named and the fixtures feature forwards the seams. +//! Desktop-app-facing surface tests: every re-export is named and the fixtures feature forwards the seams. use super::*; @@ -15,7 +15,7 @@ fn spawn_signature(start: fn(Config) -> Result) { } #[test] -fn the_shell_facing_surface_names_every_re_export() { +fn the_desktop_app_facing_surface_names_every_re_export() { // Configuration. assert_eq!(short_name::(), "AgentsConfig"); assert_eq!(short_name::(), "Config"); diff --git a/crates/workshop/server-api/src/lib.rs b/crates/workshop/server-api/src/lib.rs index fa7755e8..a7735b12 100644 --- a/crates/workshop/server-api/src/lib.rs +++ b/crates/workshop/server-api/src/lib.rs @@ -1,19 +1,19 @@ -//! workshop-server-api - the desktop shell's entire view of the workshop +//! workshop-server-api - the desktop app's entire view of the workshop //! server: re-exports only. //! -//! The shell (`workshop`) depends on this crate and never on -//! `workshop-server`, so server internals do not resolve in the shell at +//! The desktop app (`workshop`) depends on this crate and never on +//! `workshop-server`, so server internals do not resolve in the desktop app at //! all. The surface is the configuration types, the in-process server //! lifecycle, and the Gateway publication seam; the `test-fixtures` //! feature forwards the server's integration-test seams. //! //! ## Invariants //! -//! - Tier: shell boundary; may depend on: `workshop-server` only. Read +//! - Tier: desktop-app boundary; may depend on: `workshop-server` only. Read //! `AGENTS.md` before adding an import. //! - This crate is re-exports only: no types, functions, or logic of its -//! own. Anything the shell needs is a `pub use` of a `workshop-server` -//! item, and the shell's sole view of the server is this crate. +//! own. Anything the desktop app needs is a `pub use` of a `workshop-server` +//! item, and the desktop app's sole view of the server is this crate. //! - Every file in this crate stays under 500 lines; split first, then //! edit. @@ -22,7 +22,7 @@ pub use workshop_server::{ ServerHandle, SpawnError, Termination, spawn, }; -/// The server's integration-test seams, forwarded to the shell's tests. +/// The server's integration-test seams, forwarded to the desktop app's tests. #[cfg(feature = "test-fixtures")] pub use workshop_server::fixtures; diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index 009f2fff..2c9614d4 100644 --- a/crates/workshop/server/Cargo.toml +++ b/crates/workshop/server/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge Workshop HTTP server: serves the workshop API to the desktop shell" +description = "PromptForge Workshop HTTP server: serves the workshop API to the desktop app" [[bin]] name = "workshop-server" @@ -17,7 +17,7 @@ anyhow.workspace = true axum.workspace = true futures-util.workspace = true # The harness public API: agent sessions run in the harness, which the -# composition root constructs, registers, and pushes the shell's gateway +# composition root constructs, registers, and pushes the server's gateway # binding, chat catalog, and host snapshot into as data. harness-api.workspace = true open.workspace = true diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index b28e8986..7376ae42 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -1,22 +1,22 @@ -//! The sessions subsystem of the shell: the `/ws` workbench socket +//! The sessions subsystem of the server: the `/ws` workbench socket //! (`session`), the `/agents/ws` agent-session socket (`socket`), the //! `/v1/models` catalog relay (`relay`), their shared route state -//! (`state`), and [`AgentSessions`], the shell's opener of agent sessions +//! (`state`), and [`AgentSessions`], the server's opener of agent sessions //! in the harness. //! //! Agent sessions run in the harness. The composition root constructs a //! [`Harness`] from `harness-api` and registers it like every other //! subsystem handle; this module reaches it through the registry and opens -//! every session through it. Everything the harness knows about the shell +//! every session through it. Everything the harness knows about the server //! arrives as data pushed through its public API (`bindings`): the //! gateway endpoint and bearer, the chat-capable catalog, and the host //! snapshot (the menu's selection and the workspace's granted roots). -//! Status-bar reporting stays in the shell (`status`): a per-session +//! Status-bar reporting stays in the server (`status`): a per-session //! relay derives it from the session's events, deltas, and error reports. //! //! **Registry carve-out.** Sessions survive socket disconnect and sockets //! attach and detach (`socket`), so the harness keeps the session table -//! the shell's socket rule otherwise forbids. The rule governed +//! the server's socket rule otherwise forbids. The rule governed //! per-request relay work, where every held resource belonged to one //! socket; an agent session is longer-lived than any socket on purpose. @@ -44,12 +44,12 @@ pub(crate) use state::{SessionsState, register, register_tasks}; const HARNESS_STATE_DIR: &str = "harness"; /// The harness every agent session runs in, built for `config` with the -/// shell's current state already pushed through its public API: the +/// server's current state already pushed through its public API: the /// gateway endpoint and bearer, the chat catalog, and the host snapshot, /// each read through `registry` from the subsystems registered before it. /// The composition root registers the returned handle and the forwarder /// task ([`register_tasks`]) that keeps the bindings current from the -/// buses once the shell serves. Nothing touches the filesystem here: the +/// buses once the server serves. Nothing touches the filesystem here: the /// run log opens under the state directory on the first launch. pub(crate) fn harness_for(config: &Config, registry: &Registry) -> Arc { let harness = Arc::new(Harness::new(HarnessConfig { @@ -60,11 +60,11 @@ pub(crate) fn harness_for(config: &Config, registry: &Registry) -> Arc harness } -/// The shell's opener of agent sessions: discovery, launch, and lookup -/// through the registered [`Harness`], plus the shell-side work a launch +/// The server's opener of agent sessions: discovery, launch, and lookup +/// through the registered [`Harness`], plus the server-side work a launch /// wires up - the status relay. /// -/// Typed and construction-phased: the registry and the shell's backoff +/// Typed and construction-phased: the registry and the server's backoff /// are captured when the composition root builds it, and the harness is /// read through the registry at the point of use, so this handle never /// holds another subsystem's handle. @@ -91,7 +91,7 @@ impl fmt::Debug for AgentSessions { } impl AgentSessions { - /// Builds the opener over the subsystem registry and the shell's + /// Builds the opener over the subsystem registry and the server's /// reconnect backoff. Nothing is spawned here; the composition root /// runs outside the runtime. #[must_use] @@ -117,7 +117,7 @@ impl AgentSessions { .map_or_else(Vec::new, |harness| harness.discover()) } - /// Pushes the shell's current gateway, catalog, and host state into + /// Pushes the server's current gateway, catalog, and host state into /// the harness, so the next run the harness prepares reads them. pub(crate) fn sync_bindings(&self) { if let Some(harness) = self.harness() { @@ -130,7 +130,7 @@ impl AgentSessions { /// [`close`](Self::close) ends it; turn-cancel relaunches the program /// over the retained transcript without ending the session. /// - /// The shell's bindings are pushed first, so the launch reads the + /// The server's bindings are pushed first, so the launch reads the /// current selection and roots even when the forwarder task has not /// caught up with the latest replacement. /// diff --git a/crates/workshop/server/src/agents/bindings.rs b/crates/workshop/server/src/agents/bindings.rs index ab64fd9a..d38afcb0 100644 --- a/crates/workshop/server/src/agents/bindings.rs +++ b/crates/workshop/server/src/agents/bindings.rs @@ -1,4 +1,4 @@ -//! The bindings the shell pushes through the harness's public API as +//! The bindings the server pushes through the harness's public API as //! data: the gateway endpoint and bearer, the chat-capable model //! catalog, and the host snapshot a run's `ui()` and model resolution //! read (the menu's selected model and the workspace's granted roots). @@ -19,7 +19,7 @@ use workshop_menu::{CatalogBus, MenuHandles}; use workshop_protocol::WorkbenchSnapshot; use workshop_registry::{Registry, WorkspaceRoots}; -/// Pushes the shell's current host snapshot, chat catalog, and gateway +/// Pushes the server's current host snapshot, chat catalog, and gateway /// binding into `harness`, each read through `registry` at this moment. /// An unregistered subsystem leaves its binding at whatever the harness /// last saw (the host snapshot's absent parts read as `null`). @@ -68,7 +68,7 @@ fn catalog_binding(catalog: &CatalogBus) -> CatalogBinding { } /// The gateway binding for one published generation: its base URL, its -/// bearer, and the generation the shell assigned before publishing it. +/// bearer, and the generation the server assigned before publishing it. fn gateway_binding(snapshot: &GatewaySnapshot) -> GatewayBinding { GatewayBinding { base_url: snapshot.base_url().to_owned(), @@ -80,7 +80,7 @@ fn gateway_binding(snapshot: &GatewaySnapshot) -> GatewayBinding { /// Keeps the harness's bindings current: pushes all three again whenever /// the gateway binding is replaced, the chat-capable catalog changes /// generation, or the menu publishes a snapshot. Runs until every source -/// has closed (the shell's state is gone) or the harness is unregistered. +/// has closed (the server's state is gone) or the harness is unregistered. /// /// A fresh watch receiver treats the current value as seen, so a change /// landing between the composition root's push and these subscriptions diff --git a/crates/workshop/server/src/agents/session.rs b/crates/workshop/server/src/agents/session.rs index 6dc02946..0475f6df 100644 --- a/crates/workshop/server/src/agents/session.rs +++ b/crates/workshop/server/src/agents/session.rs @@ -70,7 +70,7 @@ impl Drop for SessionLog { } /// The 403 refusal every WebSocket upgrade answers a foreign `Origin` -/// with: the same `cross_site` envelope the shell's guard middleware +/// with: the same `cross_site` envelope the server's guard middleware /// renders for plain HTTP requests. pub(crate) fn cross_site_refusal() -> Response { let envelope = ErrorEnvelope::new("cross-site request refused", "cross_site"); @@ -87,7 +87,7 @@ pub(crate) fn cross_site_refusal() -> Response { /// Upgrades a `GET /ws` request to a WebSocket session. A foreign /// `Origin` is refused with 403: WS upgrades bypass Sec-Fetch in older -/// browsers, so the shell's loopback origin policy guards the upgrade +/// browsers, so the server's loopback origin policy guards the upgrade /// itself. pub(crate) async fn upgrade( State(state): State, diff --git a/crates/workshop/server/src/agents/socket.rs b/crates/workshop/server/src/agents/socket.rs index 85e9bd18..9be77e2d 100644 --- a/crates/workshop/server/src/agents/socket.rs +++ b/crates/workshop/server/src/agents/socket.rs @@ -20,7 +20,7 @@ //! answered and the frames that follow are the relaunch's own. //! //! One task owns the socket: a single `select!` loop reads and writes -//! the same handle, per the shell's socket rule; the session table +//! the same handle, per the server's socket rule; the session table //! behind it is the harness's, [`super`]'s documented carve-out. use axum::extract::State; @@ -296,7 +296,7 @@ async fn handle_frame( // Cancellation is a stop reason: no reply frame of any // kind. Pending waits announce their own deaths and the // relaunched run re-asks. The relaunch reads the host - // snapshot, so the shell's current state is pushed first. + // snapshot, so the server's current state is pushed first. if let Some(agents) = state.agents() { agents.sync_bindings(); } diff --git a/crates/workshop/server/src/agents/state.rs b/crates/workshop/server/src/agents/state.rs index 538d48e8..d357b376 100644 --- a/crates/workshop/server/src/agents/state.rs +++ b/crates/workshop/server/src/agents/state.rs @@ -21,15 +21,15 @@ use workshop_support::{RELAY_DEADLINE, with_deadline}; use super::{AgentSessions, bindings, relay, session, socket}; /// The shared state of the sessions subsystem's routes: the subsystem -/// registry every handle is read through, and the shell's WebSocket +/// registry every handle is read through, and the server's WebSocket /// origin policy. The agent-session opener, the gateway endpoint binding /// and reachability flag, and the catalog and menu buses are read /// through the registry's type-keyed state collection at the point of /// use, each an `Option` whose `None` degrades the feature the way the /// status channel's absence always has. /// -/// The origin policy is injected by the shell as a plain function: the -/// cross-site guard is the shell's security boundary (its `cross_site` +/// The origin policy is injected by the server as a plain function: the +/// cross-site guard is the server's security boundary (its `cross_site` /// module), and the subsystem applies it to every upgrade without owning /// the policy. #[derive(Debug, Clone)] @@ -48,7 +48,7 @@ pub(crate) const DEFAULT_RESTART_BOUND: Duration = Duration::from_secs(90); impl SessionsState { /// Builds the route state over the subsystem registry and the - /// shell's origin policy, with the default restart bound. + /// server's origin policy, with the default restart bound. #[must_use] pub(crate) fn new(registry: Registry, origin_allowed: fn(&HeaderMap) -> bool) -> Self { Self { @@ -124,7 +124,7 @@ impl SessionsState { self.registry.push() } - /// The shell's WebSocket origin policy, applied to every upgrade. + /// The server's WebSocket origin policy, applied to every upgrade. pub(crate) fn origin_allowed(&self, headers: &HeaderMap) -> bool { (self.origin_allowed)(headers) } @@ -144,7 +144,7 @@ pub(crate) fn routes(state: SessionsState) -> Router { } /// Registers the sessions subsystem into the registry: its routes, merged -/// into the shell's API router, the harness every agent session runs in, +/// into the server's API router, the harness every agent session runs in, /// and the agent-session opener, both as state handles. The returned /// guards keep the registrations alive; the composition root holds them /// for the process lifetime. @@ -164,9 +164,9 @@ pub(crate) fn register( } /// Registers the sessions subsystem's background task: the bindings -/// forwarder that pushes the shell's gateway binding, chat catalog, and +/// forwarder that pushes the server's gateway binding, chat catalog, and /// host snapshot into the registered harness again on every replacement. -/// The task spawns when the shell starts serving and stops inside the +/// The task spawns when the server starts serving and stops inside the /// graceful-shutdown signal. The returned guard keeps the registration /// alive; the composition root holds it for the process lifetime. pub(crate) fn register_tasks(registry: &Registry) -> Registration { diff --git a/crates/workshop/server/src/agents/status.rs b/crates/workshop/server/src/agents/status.rs index d94346b5..67427218 100644 --- a/crates/workshop/server/src/agents/status.rs +++ b/crates/workshop/server/src/agents/status.rs @@ -1,5 +1,5 @@ -//! The shell's status relay for one agent session: the status-bar frames -//! and the backoff reset, derived in the shell from the session's live +//! The server's status relay for one agent session: the status-bar frames +//! and the backoff reset, derived in the server from the session's live //! events, deltas, and error reports. //! //! One relay task per session, spawned at launch. It holds only the @@ -73,7 +73,7 @@ fn on_delta(delta: &Delta, push: &Push) { push.push_activity("Streaming response...", "an agent response chunk", activity); } -/// The side effects the shell wires to a completed reply: the backoff +/// The side effects the server wires to a completed reply: the backoff /// reset (an agent reply is useful gateway work) and the idle status /// that releases the turn-dispatch Thinking push. fn on_event(event: &SessionEvent, push: &Push, backoff: &ReconnectBackoff) { @@ -95,7 +95,7 @@ const RUN_FAILED_LABEL: &str = "Agent failed"; /// The operator-facing failure status for one of the session's failure /// reports. The session reports the kind - a failed model turn or tool /// call the program survived, a run that ended in error, or the synthetic -/// terminal of an interrupt - and the shell labels it; the report's +/// terminal of an interrupt - and the server labels it; the report's /// message passes through as the description, the same text the socket's /// error frame reports. Each kind is terminal for its turn and never /// reaches a reply, so this status is the one frame that releases the diff --git a/crates/workshop/server/src/app.rs b/crates/workshop/server/src/app.rs index 87602171..fe1ec405 100644 --- a/crates/workshop/server/src/app.rs +++ b/crates/workshop/server/src/app.rs @@ -5,7 +5,7 @@ //! extracted subsystem owns its state behind a narrow handle registered //! there, and consumers fetch the handles through the registry's //! type-keyed state collection. The harness every agent session runs in -//! is registered the same way. What remains here is the shell's own +//! is registered the same way. What remains here is the server's own //! runtime infrastructure - the shared reconnect backoff - plus the //! registration guards keeping every self-registration alive. @@ -43,7 +43,7 @@ use crate::routes; /// Address the server binds to when no override is given. pub use workshop_support::DEFAULT_ADDR; -/// Shared handler state: the subsystem registry, the shell's runtime +/// Shared handler state: the subsystem registry, the server's runtime /// infrastructure, and the registration guards. Subsystem handles - the /// gateway binding and health flag, the status, catalog, and menu buses, /// the agent-session registry - are fetched through the registry's state @@ -366,7 +366,7 @@ fn compose( gateway_handles.clone(), )); } - // The background tasks register beside the state handles; the shell + // The background tasks register beside the state handles; the server // spawns them from the registry's task vector when it starts // serving. let (heartbeat, subscriber) = @@ -396,7 +396,7 @@ fn compose( registrations.hold(state); // Agent sessions run in the harness, the engine's production host, // built here like every other subsystem and reached through the - // registry; `agents` pushes the shell's state through its public API. + // registry; `agents` pushes the server's state through its public API. let harness = agents::harness_for(config, ®istry); let agents = AgentSessions::new(registry.clone(), backoff.clone()); let mut sessions = SessionsState::new(registry.clone(), crate::cross_site::origin_allowed); @@ -455,13 +455,13 @@ pub enum StateError { } /// Returns the workshop server router with every route mounted: the -/// shell's own feature routers from `crate::routes`, plus the extracted +/// server's own feature routers from `crate::routes`, plus the extracted /// subsystems' routers merged from the registry's route vector in /// registration order - an empty vector is a graceful no-op. The API /// routes sit behind the /// `crate::cross_site` guard; `/health` and the UI assets stay outside it -/// so the shell probe, heartbeat, and initial navigation keep working. -/// Every response includes the `crate::csp` policy: the shell's webview +/// so the desktop app probe, heartbeat, and initial navigation keep working. +/// Every response includes the `crate::csp` policy: the desktop app's webview /// loads the UI as an External origin, so the server sets the page's /// Content-Security-Policy. Each subsystem applies its own deadline tier: /// the default on the workspace routes, the relay tier on `/v1/models`, @@ -482,7 +482,7 @@ pub fn router(state: AppState) -> Router { .merge(with_deadline(routes::health::routes(), DEFAULT_DEADLINE)) .merge(api) // The outermost layer on the server's own routes: every response - // is stamped with the CSP, error envelopes included, so the shell's + // is stamped with the CSP, error envelopes included, so the desktop app's // External-origin webview runs under the policy no matter which // route answered. .layer(axum::middleware::from_fn(crate::csp::header)) diff --git a/crates/workshop/server/src/assets.rs b/crates/workshop/server/src/assets.rs index 9df080c0..fa097dc4 100644 --- a/crates/workshop/server/src/assets.rs +++ b/crates/workshop/server/src/assets.rs @@ -1,5 +1,5 @@ //! The embedded workshop UI assets, the narrow [`AssetServer`] interface -//! the shell wires into the asset routes, and the file-serving helper; the +//! the server wires into the asset routes, and the file-serving helper; the //! routes that expose them sit in [`crate::routes::assets`]. use axum::http::header; @@ -65,7 +65,7 @@ impl AssetManifest { } /// The narrow asset-serving interface of the server's webview asset -/// layer. The shell wires one implementation into the asset routes: +/// layer. The server wires one implementation into the asset routes: /// [`EmbeddedAssets`] in a normal build, [`NoopAssets`] under the /// `headless` feature, which drops the UI build so server-only /// integration tests run without the webview bundle. diff --git a/crates/workshop/server/src/cross_site.rs b/crates/workshop/server/src/cross_site.rs index 6c6ebb3e..3ff79d5e 100644 --- a/crates/workshop/server/src/cross_site.rs +++ b/crates/workshop/server/src/cross_site.rs @@ -13,11 +13,11 @@ //! passes. WebSocket upgrades bypass Sec-Fetch in older browsers, so both //! upgrade handlers additionally check //! [`origin_allowed`]: an `Origin` header, when present, must be a -//! loopback http(s) origin - which admits both the shell webview (it loads +//! loopback http(s) origin - which admits both the desktop app webview (it loads //! the workshop's own loopback URL) and a browser tab on the workshop's //! address, and refuses every foreign site. A request with no `Origin` is //! a native client, not a browser, and passes. `/health` and the UI -//! assets stay outside the guard so the shell probe and heartbeat keep +//! assets stay outside the guard so the desktop app probe and heartbeat keep //! working. use axum::extract::Request; @@ -84,7 +84,7 @@ fn declares_json(headers: &HeaderMap) -> bool { } /// Whether a WebSocket upgrade's `Origin` is acceptable: absent (a native -/// client), or a loopback http(s) origin - the shell webview and the +/// client), or a loopback http(s) origin - the desktop app webview and the /// workshop's own browser-tab origin are both loopback. pub fn origin_allowed(headers: &HeaderMap) -> bool { let Some(origin) = headers.get(header::ORIGIN) else { @@ -209,7 +209,7 @@ mod tests { assert_eq!( response.status(), StatusCode::OK, - "/health stays exempt for the shell probe and heartbeat" + "/health stays exempt for the desktop app probe and heartbeat" ); } @@ -320,7 +320,7 @@ mod tests { .expect("a native client with no Origin upgrades"); ws_connect(&url, path, Some(&url)) .await - .expect("the workshop's own loopback origin (the shell webview) upgrades"); + .expect("the workshop's own loopback origin (the desktop app webview) upgrades"); let error = ws_connect(&url, path, Some("https://evil.example")) .await .expect_err("a cross-site origin must be refused"); diff --git a/crates/workshop/server/src/csp.rs b/crates/workshop/server/src/csp.rs index ed3b375e..ddc20641 100644 --- a/crates/workshop/server/src/csp.rs +++ b/crates/workshop/server/src/csp.rs @@ -1,6 +1,6 @@ //! The Content-Security-Policy stamped on every server response. //! -//! The desktop shell loads the UI as an External-origin Tauri webview, so +//! The desktop app loads the UI as an External-origin Tauri webview, so //! the page's policy is the server's to set: there is no `tauri.conf.json` //! CSP for a remote document. The policy keeps the SPA self-contained - //! scripts and workers from this origin only - while `connect-src` admits @@ -9,7 +9,7 @@ //! and the loopback WebSocket spellings. WebKit does not treat //! `connect-src 'self'` as covering WebSockets, so the `ws://` sources //! are spelled out for WebKitGTK and WKWebView; the port wildcard covers -//! the shell's OS-assigned bind. +//! the desktop app's OS-assigned bind. use axum::extract::Request; use axum::http::{HeaderValue, header}; @@ -36,7 +36,7 @@ const POLICY: &str = "default-src 'self'; script-src 'self'; \ /// only: the Gateway Config panel iframes `/gateway/config/` from the /// workshop window, and `frame-ancestors 'none'` makes Chromium refuse /// the frame outright ("refused to connect"). `'self'` admits the -/// same-origin shell and still forbids every foreign framer. +/// same-origin desktop app and still forbids every foreign framer. const POLICY_FRAMEABLE: &str = "default-src 'self'; script-src 'self'; \ style-src 'self' 'unsafe-inline'; \ connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:* \ @@ -123,7 +123,7 @@ mod tests { .expect("the policy header is present") .to_str() .expect("the policy is ASCII"); - // The break this pins: drop the IPC sources and the shell webview's + // The break this pins: drop the IPC sources and the desktop app webview's // Tauri calls fail closed from the External origin. assert!( policy.contains("connect-src 'self' ipc: http://ipc.localhost"), diff --git a/crates/workshop/server/src/error.rs b/crates/workshop/server/src/error.rs index b740cf9a..f49a3dd9 100644 --- a/crates/workshop/server/src/error.rs +++ b/crates/workshop/server/src/error.rs @@ -1,6 +1,6 @@ //! The opaque wire error every HTTP failure answers with. //! -//! [`AppError`] is the boundary between the shell's failures and the HTTP +//! [`AppError`] is the boundary between the server's failures and the HTTP //! response: one variant per wire failure that exists today, each mapped //! to one status code by the central [`IntoResponse`] impl, so the same //! failure is built in one place no matter which handler hits it. @@ -8,7 +8,7 @@ //! no `#[from]` derive exists on this side of the boundary. The extracted //! feature crates map their own error types at their own route boundaries //! (`workshop_workspace::WorkspaceError`, the sessions relay's gateway -//! envelope); this shell type covers the shell's own routes. +//! envelope); this server type covers the server's own routes. //! Internal failure detail (the source chain) reaches the response body in //! debug builds only; production bodies stay at each variant's own message, //! close to the status text. Rich construction-time errors sit elsewhere diff --git a/crates/workshop/server/src/fixtures.rs b/crates/workshop/server/src/fixtures.rs index 0bc48672..875d6eb0 100644 --- a/crates/workshop/server/src/fixtures.rs +++ b/crates/workshop/server/src/fixtures.rs @@ -41,7 +41,7 @@ pub fn replace_gateway( /// Starts the sessions subsystem's bindings forwarder over fixture state: /// the registered background task that pushes every gateway, catalog, -/// and menu replacement through the harness's public API. The shell +/// and menu replacement through the harness's public API. The server /// spawns it with serving; a test that binds the router directly has no /// serving loop, so it spawns the forwarder here. The task ends with the /// state. diff --git a/crates/workshop/server/src/lib.rs b/crates/workshop/server/src/lib.rs index 4243602d..46b90e43 100644 --- a/crates/workshop/server/src/lib.rs +++ b/crates/workshop/server/src/lib.rs @@ -1,7 +1,7 @@ //! PromptForge Workshop HTTP server. //! //! Holds the `workshop.toml` configuration, the PromptForge gateway client, -//! and the axum router so `src/main.rs` stays a thin shell. Start at +//! and the axum router so `src/main.rs` stays a thin entry point. Start at //! [`Config::load`] for configuration, [`AgentSessions`] for the //! agent-session opener behind `/agents/ws` (every session runs in the //! harness, reached through `harness-api`), and [`router`] for the HTTP @@ -21,7 +21,7 @@ //! //! ## Invariants //! -//! - Tier: shell; may depend on: the vocabulary crates +//! - Tier: server; may depend on: the vocabulary crates //! (`workshop-protocol`, `workshop-registry`, `workshop-support`), //! the service crates (`workshop-gateway`, `workshop-menu`, //! `workshop-status`), the feature crates (`workshop-user-state`, @@ -34,15 +34,15 @@ //! frames and writes every outbound frame itself - no outbox channel, //! no writer task. Agent sessions are the documented carve-out: they //! outlive sockets on purpose, and the harness keeps their table. -//! - The harness reads the shell's state as data pushed through its +//! - The harness reads the server's state as data pushed through its //! public API (the gateway binding, the chat catalog, the host -//! snapshot); the shell never hands it a bus, a registry, or a +//! snapshot); the server never hands it a bus, a registry, or a //! callback into itself. Status-bar reporting for a session is derived -//! in the shell from the session's events, deltas, and error reports. +//! in the server from the session's events, deltas, and error reports. //! - The workspace's granted roots are read through the registry's //! `WorkspaceRoots` slot, never by naming the workspace crate's //! internals: subsystems meet through the registry. -//! - The shell's WebSocket origin policy is applied to every upgrade; +//! - The server's WebSocket origin policy is applied to every upgrade; //! the cross-site guard stays the security boundary. //! - A dying input wait is an outcome, never silence: the harness's wait //! registry pushes a cancelled frame for every unresolved wait it @@ -58,7 +58,7 @@ mod routes; mod serve; // The extracted subsystem crates, aliased at their pre-decomposition -// module paths so the shell's internals read as they did before the +// module paths so the server's internals read as they did before the // split. The tier graph is enforced by `cargo test -p build-xtask`. pub use workshop_gateway::{gateway, gateway_binding, gateway_progress, heartbeat, resolve}; pub use workshop_menu::{catalog, menu}; diff --git a/crates/workshop/server/src/main.rs b/crates/workshop/server/src/main.rs index 4b1049e5..30737d66 100644 --- a/crates/workshop/server/src/main.rs +++ b/crates/workshop/server/src/main.rs @@ -1,7 +1,7 @@ //! The `workshop-server` binary: loads `workshop.toml` and serves the //! workshop HTTP API. //! -//! Thin shell around [`workshop_server`]: load the config, spawn the +//! Thin entry point around [`workshop_server`]: load the config, spawn the //! server in-process, optionally open the system browser at its address (the //! browser-tab frame, for when no desktop window is driving), and wait. diff --git a/crates/workshop/server/src/routes/assets.rs b/crates/workshop/server/src/routes/assets.rs index e6d3b5ce..850836a6 100644 --- a/crates/workshop/server/src/routes/assets.rs +++ b/crates/workshop/server/src/routes/assets.rs @@ -8,7 +8,7 @@ use axum::routing::get; use crate::assets::{self, AssetServer, CachePolicy}; use crate::error::AppError; -/// The asset layer the shell wires into these routes: the embedded UI +/// The asset layer the server wires into these routes: the embedded UI /// bundle, or the no-op implementation under the `headless` feature, /// which drops the UI build so server-only integration tests run without /// the webview assets. diff --git a/crates/workshop/server/src/serve.rs b/crates/workshop/server/src/serve.rs index c3f9534e..b7034e1b 100644 --- a/crates/workshop/server/src/serve.rs +++ b/crates/workshop/server/src/serve.rs @@ -2,7 +2,7 @@ //! //! [`spawn`] builds the shared state, binds the listener, and serves on its //! own thread with its own tokio runtime, so an embedding binary (the -//! desktop shell, or the server binary itself) keeps its main thread. The +//! desktop app, or the server binary itself) keeps its main thread. The //! call blocks until the listener is bound - that bind is the readiness //! signal - and the returned [`ServerHandle`] holds the base URL and a //! graceful-shutdown switch. The stop side is bounded: a watchdog gives diff --git a/crates/workshop/server/tests/it/heartbeat_loop.rs b/crates/workshop/server/tests/it/heartbeat_loop.rs index c37cdd2c..1c6bc13e 100644 --- a/crates/workshop/server/tests/it/heartbeat_loop.rs +++ b/crates/workshop/server/tests/it/heartbeat_loop.rs @@ -3,7 +3,7 @@ //! convergence, and the backoff's anti-flap rule. These tests compose //! `workshop-gateway`'s heartbeat with `workshop-status` and //! `workshop-menu`'s buses through the registry's push facade - the -//! composition only the shell can make, so they sit in its integration +//! composition only the server can make, so they sit in its integration //! binary rather than in any one subsystem crate. // clippy.toml's allow-expect-in-tests covers #[test] functions and diff --git a/crates/workshop/server/tests/it/user_state.rs b/crates/workshop/server/tests/it/user_state.rs index 7f36aae4..1acc29d4 100644 --- a/crates/workshop/server/tests/it/user_state.rs +++ b/crates/workshop/server/tests/it/user_state.rs @@ -113,6 +113,6 @@ async fn a_refused_put_answers_the_envelope_through_the_full_router() { let json: serde_json::Value = response.json().await.expect("the body is JSON"); assert_eq!( json["error"]["code"], "user_state_key", - "the crate's own envelope reaches the wire through the shell's router" + "the crate's own envelope reaches the wire through the server's router" ); } diff --git a/crates/workshop/support/src/config.rs b/crates/workshop/support/src/config.rs index 7edcc69d..c4141889 100644 --- a/crates/workshop/support/src/config.rs +++ b/crates/workshop/support/src/config.rs @@ -138,7 +138,7 @@ pub struct ServerConfig { /// Address the workshop server binds to. pub bind: String, /// When true, the server binary opens the system browser at its address - /// once it is serving. The desktop shell sets up its own window and + /// once it is serving. The desktop app sets up its own window and /// ignores this flag; it exists for the browser-tab frame. pub open_browser: bool, /// Directory holding the server's persistent state: agent session diff --git a/crates/workshop/user-state/src/handlers.rs b/crates/workshop/user-state/src/handlers.rs index 03b70561..161dba01 100644 --- a/crates/workshop/user-state/src/handlers.rs +++ b/crates/workshop/user-state/src/handlers.rs @@ -28,7 +28,7 @@ use crate::store::{UserStateStore, check_text_cap, user_state_key}; /// The user-state routes, narrowed to the [`UserStateStore`] - the only /// state their handlers use - under the default deadline tier. The -/// subsystem registers this constructor into the registry; the shell +/// subsystem registers this constructor into the registry; the server /// merges its result into the API router. pub fn routes(store: Arc) -> axum::Router { with_deadline( diff --git a/crates/workshop/user-state/src/lib.rs b/crates/workshop/user-state/src/lib.rs index 9c49984d..2697ca0c 100644 --- a/crates/workshop/user-state/src/lib.rs +++ b/crates/workshop/user-state/src/lib.rs @@ -22,7 +22,7 @@ //! logged and tolerated; a refused put is a value returned to the //! caller and writes nothing. //! - The crate maps its own [`UserStateError`] to the wire envelope at -//! its route boundary; no shell error type appears here. +//! its route boundary; no server error type appears here. mod error; mod handlers; @@ -37,7 +37,7 @@ pub use handlers::routes; pub use store::{USER_STATE_KEYS, USER_STATE_VALUE_CAP, UserStateStore}; /// Registers the user-state subsystem into the registry: its -/// `/user/state` routes, merged into the shell's API router, and the +/// `/user/state` routes, merged into the server's API router, and the /// store as the subsystem's state handle, so the composition root /// fetches it by slot instead of holding it by name. The returned guards /// keep the registrations alive; the composition root holds them for the diff --git a/crates/workshop/workspace/src/handlers-file-tests.rs b/crates/workshop/workspace/src/handlers-file-tests.rs index 8ba6a937..bc88127a 100644 --- a/crates/workshop/workspace/src/handlers-file-tests.rs +++ b/crates/workshop/workspace/src/handlers-file-tests.rs @@ -58,7 +58,7 @@ fn simplified(path: &Path) -> PathBuf { dunce::simplified(&path.canonicalize().expect("canonical")).to_path_buf() } -/// A window geometry distinguished by `width`, as the shell would send it. +/// A window geometry distinguished by `width`, as the desktop app would send it. fn window_body(width: u32) -> String { serde_json::json!({ "width": width, diff --git a/crates/workshop/workspace/src/handlers-file.rs b/crates/workshop/workspace/src/handlers-file.rs index 85b2fe07..ba4d8d46 100644 --- a/crates/workshop/workspace/src/handlers-file.rs +++ b/crates/workshop/workspace/src/handlers-file.rs @@ -1,6 +1,6 @@ //! The `/workspace/file/*` route handlers: the workspace as a document. //! What is open, opening another file, saving as, duplicating, and the -//! window geometry the shell keeps in it. Every mutation answers with +//! window geometry the desktop app keeps in it. Every mutation answers with //! the workspace as it stands afterwards, so the client never needs a //! second round trip to learn what it switched to. //! @@ -115,7 +115,7 @@ pub(crate) async fn duplicate_file( after_switch(&workspace, result).await } -/// Saves the shell's window geometry into the open workspace file. An +/// Saves the desktop app's window geometry into the open workspace file. An /// ephemeral workspace answers success with `saved: false` and writes /// nothing. pub(crate) async fn put_window_state( diff --git a/crates/workshop/workspace/src/handles.rs b/crates/workshop/workspace/src/handles.rs index 651bb2c0..73a4b992 100644 --- a/crates/workshop/workspace/src/handles.rs +++ b/crates/workshop/workspace/src/handles.rs @@ -1,8 +1,8 @@ //! The workspace subsystem's registration: its `/workspace/*` routes, -//! merged into the shell's API router, the workspace itself as its +//! merged into the server's API router, the workspace itself as its //! state handle set, its granted-roots view, which same-tier //! subsystems read instead of naming this crate, and the shutdown lever -//! that closes the workspace file inside the shell's graceful stop. +//! that closes the workspace file inside the server's graceful stop. use std::sync::Arc; @@ -16,7 +16,7 @@ use crate::workspace::Workspace; /// Registers the workspace subsystem into the registry: its /// `/workspace/*` routes (the confined filesystem and the -/// `/workspace/file/*` document routes), merged into the shell's API +/// `/workspace/file/*` document routes), merged into the server's API /// router, the workspace itself as its state handle set, and its granted-roots /// view, which same-tier subsystems read instead of naming this crate. /// The returned guards keep the registrations alive; the composition @@ -42,10 +42,10 @@ pub fn register( /// that closes the workspace file. The workspace-file actor already /// runs from the moment a file is opened, so the task's `spawn` spawns /// nothing; the adapter exists only to hand the registry a -/// [`ShutdownHandle`] the shell awaits inside its graceful-shutdown +/// [`ShutdownHandle`] the server awaits inside its graceful-shutdown /// closure, where [`Workspace::close_backing`] folds the WAL into the /// file and removes the sidecar before the runtime tears down. The -/// shell's grace window bounds the whole drain and is the close's only +/// server's grace window bounds the whole drain and is the close's only /// timeout. The returned guard keeps the registration alive; the /// composition root holds it for the process lifetime. pub fn register_tasks(registry: &Registry, workspace: &Workspace) -> Registration { diff --git a/crates/workshop/workspace/src/lib.rs b/crates/workshop/workspace/src/lib.rs index 5b1722e3..597bbd45 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -25,7 +25,7 @@ //! sessions and is never consulted on a request path. A persist that //! fails is logged degradation; the in-memory state stands. //! - The crate maps its own [`WorkspaceError`] to the wire envelope at -//! its route boundary; no shell error type appears here. +//! its route boundary; no server error type appears here. mod blocking; mod error; diff --git a/crates/workshop/workspace/src/workspace-tests-close.rs b/crates/workshop/workspace/src/workspace-tests-close.rs index d7322e39..1198d7f4 100644 --- a/crates/workshop/workspace/src/workspace-tests-close.rs +++ b/crates/workshop/workspace/src/workspace-tests-close.rs @@ -2,7 +2,7 @@ //! to close and says so quietly, a file-backed one folds its WAL into //! the file and drops the sidecar, leaving exactly one file behind with //! every grant in it while the in-memory grants stand, and the -//! registered background task hands the shell that close as its +//! registered background task hands the server that close as its //! shutdown lever. use super::*; @@ -111,7 +111,7 @@ async fn the_registered_task_closes_the_backing_on_shutdown() { let tasks = registry.tasks(); assert_eq!(tasks.len(), 1, "the subsystem registers one shutdown lever"); - // The shell's sequence: spawn with serving, stop inside the graceful + // The server's sequence: spawn with serving, stop inside the graceful // shutdown. The spawn starts nothing (the actor already runs); the // stop is what closes the file. let handle = tasks[0].spawn(); diff --git a/crates/workshop/workspace/src/workspace_file-actor.rs b/crates/workshop/workspace/src/workspace_file-actor.rs index 568525e2..1947bf6f 100644 --- a/crates/workshop/workspace/src/workspace_file-actor.rs +++ b/crates/workshop/workspace/src/workspace_file-actor.rs @@ -414,7 +414,7 @@ async fn read_grants(conn: &turso::Connection) -> Result, Workspac } /// Reads the saved window geometry; `None` when never saved. A value -/// that no longer parses is treated as absent: the shell falls back to +/// that no longer parses is treated as absent: the desktop app falls back to /// its default geometry rather than refusing the whole file. async fn read_window(conn: &turso::Connection) -> Result, WorkspaceFileError> { let mut rows = conn diff --git a/crates/workshop/workspace/src/workspace_file.rs b/crates/workshop/workspace/src/workspace_file.rs index b0059c36..87f96e14 100644 --- a/crates/workshop/workspace/src/workspace_file.rs +++ b/crates/workshop/workspace/src/workspace_file.rs @@ -49,7 +49,7 @@ pub(crate) const META_VERSION: &str = "version"; pub(crate) const META_NAME: &str = "name"; /// Meta key holding the RFC 3339 creation time. pub(crate) const META_CREATED_AT: &str = "created_at"; -/// The kv key holding the shell's saved geometry as JSON; the other kv +/// The kv key holding the desktop app's saved geometry as JSON; the other kv /// keys are the opaque ui-state values in [`UI_STATE_KEYS`]. pub(crate) const KV_WINDOW: &str = "window"; @@ -144,7 +144,7 @@ pub(crate) struct GrantRow { pub(crate) added_at: String, } -/// The kv 'window' value: the shell's saved geometry. +/// The kv 'window' value: the desktop app's saved geometry. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowState { /// Logical width. diff --git a/vibe/2026-09-24-2-workshop-crates-cleanup.md b/vibe/2026-09-24-2-workshop-crates-cleanup.md index 49a36ec0..77b96966 100644 --- a/vibe/2026-09-24-2-workshop-crates-cleanup.md +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -874,7 +874,7 @@ Components, in dependency order: -### Step 14: Retire "shell" in the Rust crates, the build check, and the workflows +### Step 14: Retire "shell" in the Rust crates, the build check, and the workflows [completed] - Component: Shell vocabulary - Piece: Rust and workflow names From 66d53f482ac40cbd8dc7d7fba296e51e7e881c89 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Thu, 24 Sep 2026 19:08:54 -0700 Subject: [PATCH 15/44] Retire shell in the shared status bar and the workshop UI Retire the word "shell" across the shared status bar and the workshop UI so that it names only a terminal command shell. The shared status bar component becomes the status bar view, the workshop frame becomes the desk, the lazy panel stand-in becomes the placeholder, and the boot shell becomes the entry bundle. The Tauri application is now called the desktop app throughout. No behavior changes. - `createStatusBarView`: the shared status bar export `createStatusBarShell` and its `StatusBarShell` type become `createStatusBarView` and `StatusBarView`, and each consumer's local `shell` variable becomes `view`. - `ws-desk`: the workshop frame's `.ws-shell` class becomes `.ws-desk` in the layout stylesheet, the entry markup, and the layout test. - `placeholder`: the lazy panel stand-in's "shell" wording and its `shell` local become `placeholder` in the panel type, the zones stylesheet, and the sizing regression test. Plan: vibe/2026-09-24-2-workshop-crates-cleanup.md --- .../ui/src/components/status-bar.test.mjs | 2 +- .../config-ui/ui/src/components/status-bar.ts | 22 ++--- .../config-ui/ui/src/styles/layout.css | 2 +- crates/shared-ui/package.json | 2 +- crates/shared-ui/status-bar.css | 2 +- crates/shared-ui/status-bar.ts | 14 ++-- crates/workshop/ui/build.mjs | 2 +- crates/workshop/ui/index.html | 2 +- crates/workshop/ui/src/main.ts | 6 +- .../src/parts/chrome/chrome.contribution.ts | 4 +- .../ui/src/parts/chrome/window-chrome.css | 4 +- .../ui/src/parts/chrome/window-chrome.ts | 2 +- .../ui/src/parts/layout/panel-types.ts | 6 +- crates/workshop/ui/src/parts/layout/zones.css | 6 +- .../ui/src/parts/status/status-bar.ts | 34 ++++---- .../workspace-files.contribution.ts | 12 +-- .../ui/src/parts/workspace/workspace-drops.ts | 2 +- crates/workshop/ui/src/services/ui-storage.ts | 2 +- .../ui/src/services/workspace-file-client.ts | 4 +- crates/workshop/ui/style.css | 2 +- crates/workshop/ui/test/boot-ui-storage.mjs | 4 +- .../ui/test/helpers/tauri-event-stub.mjs | 2 +- crates/workshop/ui/test/lazy-panel-sizing.mjs | 38 ++++----- crates/workshop/ui/test/no-local-storage.mjs | 2 +- crates/workshop/ui/test/shared-status-bar.mjs | 80 +++++++++---------- crates/workshop/ui/test/titlebar-macos.mjs | 2 +- crates/workshop/ui/test/workshop-layout.mjs | 4 +- crates/workshop/ui/test/workspace-drops.mjs | 2 +- crates/workshop/ui/test/workspace-files.mjs | 2 +- vibe/2026-09-24-2-workshop-crates-cleanup.md | 2 +- 30 files changed, 135 insertions(+), 135 deletions(-) diff --git a/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs b/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs index 536ee7a2..18965a44 100644 --- a/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs +++ b/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs @@ -1,6 +1,6 @@ // Pins the bottom status bar: the idle LED strip maps each endpoint's // ready/provisioning flags to its LED state beside the model/VRAM -// summary; a busy Progress snapshot shows the shared shell's barberpole +// summary; a busy Progress snapshot shows the shared view's barberpole // beside the still-visible LEDs with the activity text in the text // region; an active queue command adds the pending count with per-entry // cancel buttons and a cancel button that calls POST /admin/queue/cancel; diff --git a/crates/gateway/config-ui/ui/src/components/status-bar.ts b/crates/gateway/config-ui/ui/src/components/status-bar.ts index ffce4593..924273a1 100644 --- a/crates/gateway/config-ui/ui/src/components/status-bar.ts +++ b/crates/gateway/config-ui/ui/src/components/status-bar.ts @@ -1,5 +1,5 @@ -// The fixed bottom status bar [VS Code], built on the shared shell -// (shared-ui/status-bar): the shell owns the bar, the text region, and +// The fixed bottom status bar [VS Code], built on the shared view +// (shared-ui/status-bar): the view owns the bar, the text region, and // the busy barberpole beside the indicators; this component populates // them from the extended GET /admin/status response. The endpoint LED // strip (green ready, amber provisioning, gray unconfigured) stands in @@ -13,11 +13,11 @@ // that keeps page content clear of the fixed strip. import { X, createElement as lucideElement } from "lucide"; -import { createStatusBarShell } from "shared-ui/status-bar"; +import { createStatusBarView } from "shared-ui/status-bar"; import type { EndpointStatus, GatewayApi, GatewayStatus } from "../services/gateway-api"; -/** The status poll cadence; the bar is the shell's only live status consumer. */ +/** The status poll cadence; the bar is the view's only live status consumer. */ const STATUS_POLL_MS = 2000; /** Construction dependencies for the status bar. */ @@ -58,14 +58,14 @@ function summaryText(models: number, vramGb: number): string { /** Creates the status bar. */ export function createStatusBar(options: StatusBarOptions): StatusBar { - const shell = createStatusBarShell(); - const element = shell.element; + const view = createStatusBarView(); + const element = view.element; - // Idle state: the endpoint LED strip fills the shell's indicators + // Idle state: the endpoint LED strip fills the view's indicators // group; the model/VRAM summary sits in the extras region. const leds = document.createElement("div"); leds.className = "status-leds"; - shell.indicators.append(leds); + view.indicators.append(leds); const summary = document.createElement("span"); summary.className = "status-bar-summary"; @@ -85,7 +85,7 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { cancel.className = "button button-xs button-outline status-bar-cancel"; cancel.textContent = "Cancel"; queueGroup.append(pendingNote, pendingList, cancel); - shell.extras.append(summary, queueGroup); + view.extras.append(summary, queueGroup); let timer: ReturnType | null = null; @@ -134,8 +134,8 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { update(status: GatewayStatus): void { // The Progress snapshot is the busy signal and the text; the queue // readout only adds the cancel controls while a command runs. - shell.setBusy(status.progress.busy); - shell.setText(status.progress.busy ? status.progress.text : ""); + view.setBusy(status.progress.busy); + view.setText(status.progress.busy ? status.progress.text : ""); const active = status.queue.active; if (active !== null) { summary.hidden = true; diff --git a/crates/gateway/config-ui/ui/src/styles/layout.css b/crates/gateway/config-ui/ui/src/styles/layout.css index 365b084d..31b0a0b0 100644 --- a/crates/gateway/config-ui/ui/src/styles/layout.css +++ b/crates/gateway/config-ui/ui/src/styles/layout.css @@ -1420,7 +1420,7 @@ } } -/* Bottom status bar [VS Code]: the shared shell (shared-ui/status-bar) +/* Bottom status bar [VS Code]: the shared view (shared-ui/status-bar) provides the bar, the text, the slot's progress/indicators swap, and the extras region; this file pins it to the viewport bottom and adds the gateway's own content - the endpoint LED strip in the indicators diff --git a/crates/shared-ui/package.json b/crates/shared-ui/package.json index 13dba384..1b209e6f 100644 --- a/crates/shared-ui/package.json +++ b/crates/shared-ui/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "PromptForge shared UI primitives: the Cursor Dark token sheet and the behavioral components (modal, dropdown, toast stack, status bar shell, progress bar, button/input bases) consumed by both esbuild-built UIs (gateway-config-ui and workshop-server).", + "description": "PromptForge shared UI primitives: the Cursor Dark token sheet and the behavioral components (modal, dropdown, toast stack, status bar view, progress bar, button/input bases) consumed by both esbuild-built UIs (gateway-config-ui and workshop-server).", "exports": { "./tokens.css": "./tokens.css", "./controls.css": "./controls.css", diff --git a/crates/shared-ui/status-bar.css b/crates/shared-ui/status-bar.css index 5a5fd2da..ff7a4836 100644 --- a/crates/shared-ui/status-bar.css +++ b/crates/shared-ui/status-bar.css @@ -6,7 +6,7 @@ live in the components layer so a consumer's own rules (the gateway's fixed positioning) override these. */ -/* The status bar: a permanent full-width footer below the shell. The +/* The status bar: a permanent full-width footer below the desk. The left text shows the current label; the extras region holds consumer controls; the right group holds the barberpole and then the slot with the consumer's LED indicators group. The barberpole hides while idle diff --git a/crates/shared-ui/status-bar.ts b/crates/shared-ui/status-bar.ts index d575dab9..5ddc6ac6 100644 --- a/crates/shared-ui/status-bar.ts +++ b/crates/shared-ui/status-bar.ts @@ -1,4 +1,4 @@ -// The status bar shell shared by both UIs: a permanent full-width footer +// The status bar view shared by both UIs: a permanent full-width footer // with a text region on the left and, on the right, a barberpole beside // the indicators group. The barberpole is an indeterminate busy signal: // it shows while work is in flight and hides otherwise, and it never @@ -6,13 +6,13 @@ // populates the indicators group with its own LEDs (the workshop: // recording + activity; the gateway: per-endpoint capability) and the // extras region with its own controls (the gateway: the model summary, -// the pending-queue count, and the cancel buttons). The shell owns no +// the pending-queue count, and the cancel buttons). The view owns no // timers, listeners, or polling; the consumer drives it through setText // and setBusy and owns every lifecycle. import "./status-bar.css"; -/** Options for {@link StatusBarShell.setText}. */ +/** Options for {@link StatusBarView.setText}. */ export interface StatusBarText { /** Paint the text in the error color. */ readonly error?: boolean; @@ -20,8 +20,8 @@ export interface StatusBarText { readonly tooltip?: string; } -/** The mounted shell and its regions. */ -export interface StatusBarShell { +/** The mounted view and its regions. */ +export interface StatusBarView { /** The `