diff --git a/.cursor/rules/workshop-architecture.mdc b/.cursor/rules/workshop-architecture.mdc index d962c2908..018f1a53a 100644 --- a/.cursor/rules/workshop-architecture.mdc +++ b/.cursor/rules/workshop-architecture.mdc @@ -8,18 +8,18 @@ alwaysApply: false ## One-way dependency graph -- Dependencies flow one way: shell -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. -- Tiers: vocabulary (`workshop-protocol`, `workshop-support`, `workshop-registry`) <- services (`workshop-gateway`, `workshop-status`, `workshop-menu`) <- features (`workshop-sessions`, `workshop-workspace`) <- shell (`workshop-server`). +- Dependencies flow one way: server -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. +- Tiers: vocabulary (`workshop-protocol`, `workshop-support`, `workshop-registry`) <- services (`workshop-gateway`, `workshop-status`, `workshop-menu`) <- features (`workshop-sessions`, `workshop-workspace`) <- server (`workshop-server`). - Crates in the same tier never depend on each other. They meet through `workshop-protocol` wire types and `workshop-registry` proxy slots. - Every `workshop-*` crate's `lib.rs` opens with a `//!` doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. Read it before adding an import. - `lib.rs` is a facade only: crate docs, crate-level attributes, `mod` declarations, and `pub use` re-exports. No logic. ## Registry pattern -- Subsystems self-register into `workshop-registry` proxy slots: routes, state handles, background tasks, push channels, and shutdown handles. The shell composes the registry; it does not wire subsystems by name. +- Subsystems self-register into `workshop-registry` proxy slots: routes, state handles, background tasks, push channels, and shutdown handles. The server composes the registry; it does not wire subsystems by name. - Unregistered slots are graceful no-ops. Never add a hard dependency on an optional subsystem. - Registration handles are `#[must_use]`; registry traits are sealed so only workshop crates implement them. ## File ceiling -- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the ceiling, the tier graph, and lint inheritance over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. +- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the ceiling, the tier graph, and lint inheritance over the Rust files in the workshop crates carrying the marker; the desktop app (the `workshop` crate) is exempt until the headless agent mode plan. diff --git a/.cursor/rules/workshop-spa.mdc b/.cursor/rules/workshop-spa.mdc index 353a56ded..429685fa9 100644 --- a/.cursor/rules/workshop-spa.mdc +++ b/.cursor/rules/workshop-spa.mdc @@ -11,7 +11,7 @@ alwaysApply: false - The package at `crates/workshop/ui/` is a sibling of the `crates/workshop/server/` crate that builds and serves it. `src/` has three layers: `base/` (lifecycle, events, paths, the `WorkshopPart` base class), `services/` (DOM-free registries and services), and `parts/` (feature directories; every panel extends `base/workshop-part.ts`). - Feature-based directories under `parts/` (`parts/agent/`, `parts/editor/`, `parts/layout/`, `parts/menu/`, `parts/take/`, `parts/stt/`, `parts/chrome/`, `parts/status/`, `parts/workspace/`, `parts/gateway/`). Shared code lives in `services/` or `base/`; shared UI assets (boot-loaded icons) live in `parts/shared/`; design tokens live in `tokens/`. - Barrel exports: every directory has an `index.ts`. Lazy directories export `register()` installing commands, menu items, panel factories, and socket subscriptions. -- The boot shell (`main.ts`, `services/`, `base/`) loads immediately. Feature directories load via dynamic `import()` on first activation. Lazy-loaded panels never import the boot shell. +- The entry bundle (`main.ts`, `services/`, `base/`) loads immediately. Feature directories load via dynamic `import()` on first activation. Lazy-loaded panels never import the entry bundle. - Registration, not central wiring: panel types, menu items, services, socket handlers, keyboard shortcuts, and lifecycle disposal self-register through the panel, menu/command, and service registries. ## CSS colocation and tokens diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab237cfd..2a672cc50 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) @@ -199,6 +199,11 @@ jobs: - name: Doctests (workshop) run: cargo test --doc -p workshop -p workshop-server -p workshop-server-api + - name: Docs (workshop-server, private items) + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --no-deps -p workshop-server --document-private-items + - name: Test Gateway process ownership races shell: bash run: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 84c4e347d..5f49c5568 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 1ff49f71d..50789a2ef 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 @@ -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/.github/workflows/workshop-installer-smoke.yml b/.github/workflows/workshop-installer-smoke.yml index cff45c62e..e308ec972 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 754cb0a1e..ae294761e 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 b44cf7ea5..d37b78e72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,17 +19,30 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that - Gateway is an independent service that proxies local and remote inference through one OpenAI-compatible HTTP and WebSocket endpoint - Harness is the engine's only production host: it owns the tokio runtime, the performers that execute the engine's effects, agent sessions, and the run log; Workshop and other clients drive runs through it +## Vocabulary + +- **shell**: a command shell in a terminal, and nothing else. +- **desktop app**: the Tauri crate (package `workshop`), under `crates/workshop/desktop/`. +- **server**: the build check's tier that holds only `workshop-server`. +- **desk**: a UI's main frame. +- **workbench**: the VS Code-style UI architecture, and the Model menu snapshot frame on the `/ws` socket. +- **workshop socket**: the `/ws` socket, served by the server's `workshop_socket` module. +- **page**: a routed screen behind a tab. +- **view**: a DOM component. +- **placeholder**: what a lazy panel shows while its code chunk loads. +- **entry bundle**: the eagerly loaded composition that lazy panels must never import. + ## Structure - 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 desktop app 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 - PromptForge has one public crate, `promptforge` at crates/promptforge/: a facade of single-item re-exports grouped into documented role modules. Crates outside the family may depend only on `promptforge`, never on a promptforge-* crate. Everything else lives under crates/promptforge-internal/, a manifestless container private to the family that holds the engine (`promptforge-engine`), the types crate (`promptforge-types`), the virtual filesystem (`promptforge-vfs`), and the lua, parser, store, and model-client crates; `promptforge` is the only outside crate permitted to depend into it -- The Workshop shell (the `workshop` crate) depends on `workshop-server-api` and never on `workshop-server`; the facade is the shell's entire view of the server +- The desktop app (the `workshop` crate) depends on `workshop-server-api` and never on `workshop-server`; the facade is the desktop app's entire view of the server - Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates. PromptForge's own public surface is the `promptforge` facade, and its types crate (`promptforge-types`) has left shared-* for the private container; Gateway's is gateway-api-types and gateway-api-discovery, named gateway-* now that both have left shared-*; the types crate contains the wire vocabulary only, never code - Crates named build-* are for building specific outputs - Dependency rules bind all kinds: normal, dev, build, and target-specific dependencies. One exception: a crate under crates/promptforge-internal/ may list `promptforge` in `[dev-dependencies]` only so its doc examples compile against the facade paths hosts see. No unit test, integration test, or bench imports it. This edge is exempt from the one-way flow rule under Structural Rules. @@ -58,9 +71,9 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that ## Structural Rules -- Dependencies flow one way: shell -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded panels never import the boot shell; shared code lives in services/ or base/. -- Every workshop-* and harness-* crate's lib.rs (including harness-api) opens with a //! doc containing a `## Invariants` marker that lists what the crate may depend on and what it may not. The marker is mandatory for those families by package name; a family crate without it fails `cargo test -p build-xtask`. The Tauri shell (the `workshop` crate) is exempt. Read the marker before adding an import. -- No file in a crate with the marker exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the tier graph, the mandatory marker, the lint inheritance, the ceiling over the Rust files in every workshop-* and harness-* crate plus any other crate with the marker, and the product-boundary matrix above (including the single-public-crate rules for promptforge and harness and the container privacy rules for crates/promptforge-internal/, crates/gateway/, crates/workshop/, crates/harness/, and the nested crates/gateway/stt/ subsystem, whose only family-visible crate is gateway-stt) across every workspace manifest; the Tauri shell (the `workshop` crate) is exempt from the marker and the ceiling until the headless agent mode plan. +- Dependencies flow one way: server -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded panels never import the entry bundle; shared code lives in services/ or base/. +- Every workshop-* and harness-* crate's lib.rs (including harness-api) opens with a //! doc containing a `## Invariants` marker that lists what the crate may depend on and what it may not. The marker is mandatory for those families by package name; a family crate without it fails `cargo test -p build-xtask`. The desktop app (the `workshop` crate) is exempt. Read the marker before adding an import. +- No file in a crate with the marker exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the tier graph, the mandatory marker, the lint inheritance, the ceiling over the Rust files in every workshop-* and harness-* crate plus any other crate with the marker, and the product-boundary matrix above (including the single-public-crate rules for promptforge and harness and the container privacy rules for crates/promptforge-internal/, crates/gateway/, crates/workshop/, crates/harness/, and the nested crates/gateway/stt/ subsystem, whose only family-visible crate is gateway-stt) across every workspace manifest; the desktop app (the `workshop` crate) is exempt from the marker and the ceiling until the headless agent mode plan. - Source directories are flat by default. A subdirectory of source files must contain at least three files; one or two files belong beside the parent module as `foo-bar.rs` (parent stem, dash, kebab label), wired with an explicit path attribute so the module name stays clean: `#[path = "foo-bar.rs"] mod bar;`. The two forms are convertible in both directions: when a `foo-*.rs` sibling group grows to three files, rehydrate it into a `foo/` subdirectory in standard module layout (`foo/bar.rs` beside `foo.rs`) and drop the path attributes; when a subdirectory shrinks below three files, flatten it back to kebab siblings. Apply whichever conversion applies when you touch files in a group on the wrong side of the line. Top-level `tests/` and `benches/` trees are exempt; they follow Cargo target conventions. ## SPA and CSS Rules diff --git a/Cargo.lock b/Cargo.lock index 8a1d9a5ca..19a2e9ae3 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", @@ -8626,7 +8625,9 @@ name = "workshop-support" version = "0.0.0" dependencies = [ "axum", + "reqwest", "serde", + "serde_json", "tempfile", "thiserror 2.0.19", "tokio", @@ -8662,7 +8663,6 @@ dependencies = [ "dunce", "humantime", "percent-encoding", - "promptforge", "serde", "serde_json", "shared-error-source", diff --git a/Cargo.toml b/Cargo.toml index 7dbc4e666..559ab14cf 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 81ae39296..75b3a9999 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-user-guide/src/main.rs b/crates/build-user-guide/src/main.rs index e9020911f..4f67b1098 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/build-workshop/tests/interruption.rs b/crates/build-workshop/tests/interruption.rs index b22e0bd77..6a46f255a 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/new_crate.rs b/crates/build-xtask/src/new_crate.rs index 7125b5f37..a851ffe57 100644 --- a/crates/build-xtask/src/new_crate.rs +++ b/crates/build-xtask/src/new_crate.rs @@ -69,8 +69,8 @@ fn lib_rs(name: &str) -> String { //!\n\ //! ## Invariants\n\ //!\n\ - //! - Tier: TODO (vocabulary | services | features | shell); may depend\n\ - //! on: TODO. Read `AGENTS.md` before adding an import.\n\ + //! - Tier: TODO (vocabulary | services | features | server); may depend\n\ + //! on: TODO. Read the repository-root `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 85203b5af..40cdb46bf 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 5687e0255..02215bc91 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 6fedb1c41..df6fd8081 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,22 +216,22 @@ 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(), - "workshop/shell", + "workshop/desktop", "workshop", UNMARKED, MAX_FILE_LINES + 1, ); 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 8812a7c6e..8e806fc6c 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; @@ -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) } @@ -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/gateway/app/Cargo.toml b/crates/gateway/app/Cargo.toml index 44d4e7c94..fe2cd0080 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/workshop/shell/icons/32x32.png b/crates/gateway/app/assets/32x32.png similarity index 100% rename from crates/workshop/shell/icons/32x32.png rename to crates/gateway/app/assets/32x32.png diff --git a/crates/workshop/shell/icons/64x64.png b/crates/gateway/app/assets/64x64.png similarity index 100% rename from crates/workshop/shell/icons/64x64.png rename to crates/gateway/app/assets/64x64.png diff --git a/crates/workshop/shell/icons/icon.ico b/crates/gateway/app/assets/icon.ico similarity index 100% rename from crates/workshop/shell/icons/icon.ico rename to crates/gateway/app/assets/icon.ico diff --git a/crates/gateway/app/build.rs b/crates/gateway/app/build.rs index 9d395746b..c2f58abcf 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 455c88e3a..2ce043855 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/logic.rs b/crates/gateway/app/src/tray/logic.rs index 409880748..865c2cbe6 100644 --- a/crates/gateway/app/src/tray/logic.rs +++ b/crates/gateway/app/src/tray/logic.rs @@ -421,6 +421,8 @@ pub(crate) mod linux { /// The grayed variant of an RGBA icon: each pixel's luma with the alpha /// untouched, for the Starting phase. +/// Gated on its callers, the Windows and Linux backends, plus the tests. +#[cfg(any(target_os = "windows", target_os = "linux", test))] #[expect( clippy::cast_possible_truncation, reason = "the fixed-point luma sums to at most 255" @@ -435,11 +437,14 @@ pub(crate) fn grayed(rgba: &[u8]) -> Vec { /// The error variant of an RGBA icon: red-dominant with the alpha /// untouched, for the Error phase. `r / 2 + 128` cannot overflow. +/// Gated on its callers, the Windows and Linux backends, plus the tests. +#[cfg(any(target_os = "windows", target_os = "linux", test))] pub(crate) fn error_tint(rgba: &[u8]) -> Vec { tint(rgba, |r, g, b| (r / 2 + 128, g / 3, b / 3)) } /// Maps every pixel's RGB channels through `f`, preserving alpha. +#[cfg(any(target_os = "windows", target_os = "linux", test))] fn tint(rgba: &[u8], f: impl Fn(u8, u8, u8) -> (u8, u8, u8)) -> Vec { debug_assert!( rgba.len().is_multiple_of(4), diff --git a/crates/gateway/app/src/tray/macos.rs b/crates/gateway/app/src/tray/macos.rs index e33157724..17d9365d6 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. @@ -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 69a136c0b..d9bacd9d8 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. @@ -593,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/gateway/app/tests/it/icon.rs b/crates/gateway/app/tests/it/icon.rs index 691d7b959..6b14345a2 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/gateway/config-ui/ui/src/components/apply-overlay.test.mjs b/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs index f35c2d1bf..9cd3c814d 100644 --- a/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs +++ b/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs @@ -27,7 +27,7 @@ function snapshot(busy, text) { } /** - * Boots a dirty shell whose progress stream is pushable and whose + * Boots a dirty desk whose progress stream is pushable and whose * config-apply reply waits until the test settles it, so the overlay * stays open while events and clicks arrive. `cancelReply`, when given, * answers `POST /admin/queue/cancel` instead of the stub. diff --git a/crates/gateway/config-ui/ui/src/components/key-prompt.test.mjs b/crates/gateway/config-ui/ui/src/components/key-prompt.test.mjs index dcc37223f..0e66dc170 100644 --- a/crates/gateway/config-ui/ui/src/components/key-prompt.test.mjs +++ b/crates/gateway/config-ui/ui/src/components/key-prompt.test.mjs @@ -1,5 +1,5 @@ // Pins the key prompt flow: a verified key lands in sessionStorage and -// mounts the shell, a rejected key shows the inline error without +// mounts the desk, a rejected key shows the inline error without // storing anything, and a 401 from any later API call clears the key // and returns to the prompt. import assert from "node:assert/strict"; @@ -23,7 +23,7 @@ function submitKey(dom, root, value) { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); } -test("a verified key is stored in sessionStorage and the shell mounts", async () => { +test("a verified key is stored in sessionStorage and the desk mounts", async () => { const app = await loadApp(); const stub = gatewayStub({ key: "sesame" }); const { dom, root } = await bootApp({ stub }); @@ -39,18 +39,18 @@ test("a verified key is stored in sessionStorage and the shell mounts", async () "sesame", "the verified key is stored for the session", ); - assert.ok(root.querySelector("header.tab-bar"), "the shell mounted after verification"); + assert.ok(root.querySelector("header.tab-bar"), "the desk mounted after verification"); assert.equal(root.querySelector("#gateway-api-key"), null, "the prompt is gone"); }); -test("an ambient handoff cookie mounts the shell without a stored key", async () => { +test("an ambient handoff cookie mounts the desk without a stored key", async () => { // No key in the stub: the gateway's /auth cookie authenticates every // call, so boot's ambient probe answers 200 and the prompt never shows. const app = await loadApp(); const stub = gatewayStub(); const { dom, root } = await bootApp({ stub }); - assert.ok(root.querySelector("header.tab-bar"), "the shell mounted on the ambient cookie"); + assert.ok(root.querySelector("header.tab-bar"), "the desk mounted on the ambient cookie"); assert.equal(root.querySelector("#gateway-api-key"), null, "no key prompt is shown"); assert.equal(dom.window.sessionStorage.getItem(app.API_KEY_STORAGE_KEY), null, "no key is stored"); }); @@ -70,7 +70,7 @@ test("a rejected key shows the inline invalid-key error and stores nothing", asy assert.equal(input.getAttribute("aria-invalid"), "true"); assert.equal(input.getAttribute("aria-describedby"), "gateway-api-key-error"); assert.equal(dom.window.sessionStorage.getItem(app.API_KEY_STORAGE_KEY), null); - assert.equal(root.querySelector("header.tab-bar"), null, "the shell did not mount"); + assert.equal(root.querySelector("header.tab-bar"), null, "the desk did not mount"); }); test("a 401 from any later API call clears the key and returns to the prompt", async () => { @@ -84,7 +84,7 @@ test("a 401 from any later API call clears the key and returns to the prompt", a : Promise.resolve(jsonResponse({ error: "unauthorized" }, 401)), }; const { dom, root } = await bootApp({ key: "k", stub }); - assert.ok(root.querySelector("header.tab-bar"), "the shell mounted with the stored key"); + assert.ok(root.querySelector("header.tab-bar"), "the desk mounted with the stored key"); authorized = false; root.querySelector(".profile-switcher button").click(); @@ -95,7 +95,7 @@ test("a 401 from any later API call clears the key and returns to the prompt", a await settle(); assert.ok(root.querySelector("#gateway-api-key"), "the key prompt is back"); - assert.equal(root.querySelector("header.tab-bar"), null, "the shell is gone"); + assert.equal(root.querySelector("header.tab-bar"), null, "the desk is gone"); assert.equal( dom.window.sessionStorage.getItem(app.API_KEY_STORAGE_KEY), null, @@ -138,7 +138,7 @@ test("a 401 remount cycle tears down the old router and progress stream", async dom.window.document.body.append(root); app.boot(root, { win, fetchFn }); await settle(); - assert.ok(root.querySelector("header.tab-bar"), "the shell mounted with the stored key"); + assert.ok(root.querySelector("header.tab-bar"), "the desk mounted with the stored key"); authorized = false; root.querySelector(".profile-switcher button").click(); @@ -156,13 +156,13 @@ test("a 401 remount cycle tears down the old router and progress stream", async .querySelector("form") .dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); await settle(); - assert.ok(root.querySelector("header.tab-bar"), "the shell remounted after re-auth"); + assert.ok(root.querySelector("header.tab-bar"), "the desk remounted after re-auth"); assert.equal(added - removed, 1, "exactly one live hashchange listener remains"); const progressCalls = gateway.calls.filter((call) => call.url.endsWith("/admin/progress")); - assert.equal(progressCalls.length, 2, "each shell mount opened one progress stream"); + assert.equal(progressCalls.length, 2, "each desk mount opened one progress stream"); assert.ok( progressCalls[0].init.signal.aborted, - "the first shell's progress stream was aborted on teardown", + "the first desk's progress stream was aborted on teardown", ); }); diff --git a/crates/gateway/config-ui/ui/src/components/key-prompt.ts b/crates/gateway/config-ui/ui/src/components/key-prompt.ts index a04872437..a59bfe487 100644 --- a/crates/gateway/config-ui/ui/src/components/key-prompt.ts +++ b/crates/gateway/config-ui/ui/src/components/key-prompt.ts @@ -1,7 +1,7 @@ // The first-load API key screen [Adapted: Unsloth] (standalone only): // a centered card with the cold medallion, the product title, and a // labeled password input. A verified key lands in sessionStorage and -// the shell mounts; a rejected key shows the inline error. +// the desk mounts; a rejected key shows the inline error. import type { GatewayApi } from "../services/gateway-api"; import { programIcon } from "./program-icon"; @@ -10,7 +10,7 @@ import { programIcon } from "./program-icon"; export interface KeyPromptDeps { /** The admin API client; `verifyKey` probes and stores the key. */ api: GatewayApi; - /** Called once a key verifies; the caller mounts the shell. */ + /** Called once a key verifies; the caller mounts the desk. */ onSuccess: () => void; } diff --git a/crates/gateway/config-ui/ui/src/components/profile-switcher.ts b/crates/gateway/config-ui/ui/src/components/profile-switcher.ts index 02d508e66..123661d6b 100644 --- a/crates/gateway/config-ui/ui/src/components/profile-switcher.ts +++ b/crates/gateway/config-ui/ui/src/components/profile-switcher.ts @@ -212,7 +212,7 @@ export function createProfileSwitcher(deps: ProfileSwitcherDeps): ProfileSwitche deps.store.subscribe(() => { // An empty name means "no profile runs" only once status has loaded; - // before that the shell's own status probe owns the value. + // before that the desk's own status probe owns the value. if (deps.store.loaded && deps.store.loadError === null) { running = deps.store.activeProfile; } diff --git a/crates/gateway/config-ui/ui/src/components/review-diff.ts b/crates/gateway/config-ui/ui/src/components/review-diff.ts index 33b7264de..70cee43c5 100644 --- a/crates/gateway/config-ui/ui/src/components/review-diff.ts +++ b/crates/gateway/config-ui/ui/src/components/review-diff.ts @@ -38,7 +38,7 @@ export function openReviewDiff(host: HTMLElement, rows: DiffRow[]): void { // leave no visible row (secrets arrive redacted on both sides), so // an empty table must not claim the views match. const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "No visible value changes."; card.append(empty); } else { 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 536ee7a24..18965a447 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 ffce45939..924273a14 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/components/tab-bar.ts b/crates/gateway/config-ui/ui/src/components/tab-bar.ts index 96229a557..2ff19651f 100644 --- a/crates/gateway/config-ui/ui/src/components/tab-bar.ts +++ b/crates/gateway/config-ui/ui/src/components/tab-bar.ts @@ -16,7 +16,7 @@ import { } from "lucide"; import type { IconNode } from "lucide"; -import type { ViewId } from "../router"; +import type { PageId } from "../router"; import { programIcon } from "./program-icon"; // The crate version, substituted by the esbuild define in build.mjs; a @@ -26,7 +26,7 @@ declare const __APP_VERSION__: string | undefined; const APP_VERSION = typeof __APP_VERSION__ === "string" ? __APP_VERSION__ : "dev"; /** One tab: destination view, label, lucide icon, and hash target. */ -const TABS: ReadonlyArray = [ +const TABS: ReadonlyArray = [ ["settings", "Settings", Settings, "#/settings"], ["discover", "Discover", Search, "#/discover"], ["local", "Local", Cpu, "#/local"], @@ -53,7 +53,7 @@ export interface TabBar { /** The `
` element. */ element: HTMLElement; /** Moves `aria-current` (and the accent underline) to `view`. */ - setActiveView(view: ViewId | null): void; + setActivePage(view: PageId | null): void; /** Recolors the connection dot from the latest API outcome. */ setConnected(ok: boolean): void; /** @@ -80,7 +80,7 @@ export function createTabBar(options: TabBarOptions): TabBar { const nav = document.createElement("nav"); nav.setAttribute("aria-label", "Primary"); nav.className = "tab-list"; - const tabByView = new Map(); + const tabByPage = new Map(); for (const [view, label, icon, href] of TABS) { const tab = document.createElement("a"); tab.className = "tab"; @@ -90,7 +90,7 @@ export function createTabBar(options: TabBarOptions): TabBar { text.textContent = label; tab.append(svg, text); nav.append(tab); - tabByView.set(view, tab); + tabByPage.set(view, tab); } element.append(nav); @@ -131,8 +131,8 @@ export function createTabBar(options: TabBarOptions): TabBar { revert.addEventListener("click", () => options.onRevertAll?.()); pending.replaceChildren(apply, revert); }, - setActiveView(view: ViewId | null): void { - for (const [tabView, tab] of tabByView) { + setActivePage(view: PageId | null): void { + for (const [tabView, tab] of tabByPage) { if (tabView === view) { tab.setAttribute("aria-current", "page"); } else { diff --git a/crates/gateway/config-ui/ui/src/harness.mjs b/crates/gateway/config-ui/ui/src/harness.mjs index 809f22f59..195ecd142 100644 --- a/crates/gateway/config-ui/ui/src/harness.mjs +++ b/crates/gateway/config-ui/ui/src/harness.mjs @@ -1,4 +1,4 @@ -// Shared jsdom harness for the live-shell tests. The bundle is imported +// Shared jsdom harness for the live-desk tests. The bundle is imported // once per test process (node --test runs each file in its own // process); it reads the DOM globals at call time, so every test swaps // in a fresh jsdom window and calls the exported `boot` with injected diff --git a/crates/gateway/config-ui/ui/src/main.test.mjs b/crates/gateway/config-ui/ui/src/main.test.mjs index 77dcdf39e..96cdc4dbc 100644 --- a/crates/gateway/config-ui/ui/src/main.test.mjs +++ b/crates/gateway/config-ui/ui/src/main.test.mjs @@ -1,5 +1,5 @@ // Boots the bundled config UI (dist/index.html + dist/app.js) in jsdom -// and asserts the live shell's first paint: standalone with no stored +// and asserts the live desk's first paint: standalone with no stored // key, the auto-boot on #app must land on the key prompt - medallion, // title, labeled password input, and submit button - without touching // the network. Run after `npm run build` (a debug `cargo build` also @@ -49,6 +49,6 @@ test("booting the bundle without a stored key renders the key prompt", async () assert.equal( doc.querySelector("#app header.tab-bar"), null, - "the shell stays unmounted until a key verifies", + "the desk stays unmounted until a key verifies", ); }); diff --git a/crates/gateway/config-ui/ui/src/main.ts b/crates/gateway/config-ui/ui/src/main.ts index 2c88069a0..3741bd916 100644 --- a/crates/gateway/config-ui/ui/src/main.ts +++ b/crates/gateway/config-ui/ui/src/main.ts @@ -1,11 +1,11 @@ // Composition root for the gateway config SPA. Boot detects the mode: -// the workshop panel (`?mode=panel`) mounts the shell without medallion +// the workshop panel (`?mode=panel`) mounts the desk without medallion // or key prompt - its API access goes through the postMessage bridge to the // workshop, which forwards calls with the bearer key attached, so the // key never enters this frame - while standalone mounts the key prompt -// first (when no key is stored) and then the live shell: tab bar, +// first (when no key is stored) and then the live desk: tab bar, // profile switcher, hash router, and the progress subscription. In -// panel mode the workshop owns all progress display, so the shell never +// panel mode the workshop owns all progress display, so the desk never // subscribes to the progress stream and instead announces apply and // revert actions to the parent. @@ -32,12 +32,12 @@ import type { FetchLike } from "./services/gateway-api"; import { HfApi } from "./services/hf-api"; import { PanelBridge, parseBridgeOrigin, type BridgeWindow } from "./services/panel-bridge"; import { SheetStore } from "./services/sheet-store"; -import { createDiscoverView } from "./views/discover-view"; -import { createCloudModelsView } from "./views/cloud-models-view"; -import { createModelsView } from "./views/models-view"; -import { createProfilesView } from "./views/profiles-view"; -import { createSecretsView } from "./views/secrets-view"; -import { createSettingsView } from "./views/settings-view"; +import { createDiscoverPage } from "./pages/discover-page"; +import { createCloudModelsPage } from "./pages/cloud-models-page"; +import { createModelsPage } from "./pages/models-page"; +import { createProfilesPage } from "./pages/profiles-page"; +import { createSecretsPage } from "./pages/secrets-page"; +import { createSettingsPage } from "./pages/settings-page"; export { API_KEY_STORAGE_KEY, GatewayApi, GatewayHttpError } from "./services/gateway-api"; export { SheetStore } from "./services/sheet-store"; @@ -62,7 +62,7 @@ export interface BootWindow { sessionStorage: Storage; /** Event registration for `hashchange`. */ addEventListener(type: string, listener: () => void): void; - /** Event removal, so a torn-down shell leaves no listener behind. */ + /** Event removal, so a torn-down desk leaves no listener behind. */ removeEventListener(type: string, listener: () => void): void; } @@ -92,29 +92,29 @@ export function boot(root: HTMLElement, options: BootOptions = {}): void { } const api = new GatewayApi({ fetchFn, storage: win.sessionStorage }); - // Each remount (401 -> prompt -> shell) first tears the old screen's + // Each remount (401 -> prompt -> desk) first tears the old screen's // router and progress subscription down, so cycles never stack them. let dispose: () => void = () => undefined; const showPrompt = () => { dispose(); dispose = () => undefined; - mountKeyPrompt(root, { api, onSuccess: showShell }); + mountKeyPrompt(root, { api, onSuccess: showDesk }); }; - const showShell = () => { + const showDesk = () => { dispose(); - dispose = mountLiveShell(root, win, api, null, options); + dispose = mountLiveDesk(root, win, api, null, options); }; // Any 401 clears the stored key and returns to the prompt. api.onUnauthorized = showPrompt; if (api.hasKey()) { - showShell(); + showDesk(); } else { // The `/auth` handoff lands here with an HttpOnly cookie and no - // stored key: probe once, mounting the shell when the cookie + // stored key: probe once, mounting the desk when the cookie // authenticates and the key prompt otherwise. void api.hasAmbientAuth().then((authenticated) => { if (authenticated) { - showShell(); + showDesk(); } else { showPrompt(); } @@ -124,10 +124,10 @@ export function boot(root: HTMLElement, options: BootOptions = {}): void { /** * Boots panel mode. Without a usable `bridge` origin parameter the - * shell stays inert (the bridge-pending banner, no network calls at + * desk stays inert (the bridge-pending banner, no network calls at * all). With one, the bridge announces itself to the pinned workshop * origin, waits for the context message, and then mounts the live - * shell whose transport is the bridge - no sessionStorage key and no + * desk whose transport is the bridge - no sessionStorage key and no * direct gateway fetch exist in this frame. */ function mountPanelMode( @@ -148,13 +148,13 @@ function mountPanelMode( timeoutMs: options.bridgeTimeoutMs, }); // Whether the iframe URL itself included a route, read before the - // pending shell's router normalizes an empty hash to #/local: an + // pending desk's router normalizes an empty hash to #/local: an // explicit hash outranks the workshop's initial-route context. const hadInitialHash = win.location.hash !== ""; const disposePending = mountPanelPending(root, win); let mounted = false; bridge.onContext = (context) => { - // Theme context: the shell's CSS keys off the attribute, and any + // Theme context: the desk's CSS keys off the attribute, and any // later context message keeps it fresh without remounting. root.setAttribute("data-theme", context.theme); if (mounted) { @@ -166,7 +166,7 @@ function mountPanelMode( win.location.hash = context.route; } const api = new GatewayApi({ fetchFn: bridge.fetchLike, storage: memoryStorage(), base: "" }); - mountLiveShell(root, win, api, bridge, options); + mountLiveDesk(root, win, api, bridge, options); }; bridge.start(); } @@ -194,14 +194,14 @@ function memoryStorage(): Storage { } /** - * Mounts the live shell and starts its data flows, in either mode: + * Mounts the live desk and starts its data flows, in either mode: * standalone (`bridge` null - medallion, progress subscription, the * bottom status bar) or workshop panel (`bridge` set - no medallion, no * progress subscription, no status bar because the workshop owns * progress and status display, and apply/revert are announced to the * parent). Returns the teardown that stops the router and subscriptions. */ -function mountLiveShell( +function mountLiveDesk( root: HTMLElement, win: BootWindow, api: GatewayApi, @@ -222,7 +222,7 @@ function mountLiveShell( }, }); const store = new ConfigStore(api); - // The cloud sheet loads on shell mount; the Cloud tab (and any other + // The cloud sheet loads on desk mount; the Cloud tab (and any other // subscriber) re-renders in place when it lands. const sheets = new SheetStore(api, options.sheetPollMs); sheets.start(); @@ -339,7 +339,7 @@ function mountLiveShell( }); // Pending-changes banner [INVENTED]: raised only when shadows already - // exist when the shell loads (a previous session's saves), cleared + // exist when the desk loads (a previous session's saves), cleared // once they are applied or reverted. const banner = document.createElement("div"); banner.className = "banner banner-pending"; @@ -400,18 +400,18 @@ function mountLiveShell( statusBar?.start(); api.onHealth = (ok) => tabBar.setConnected(ok); - const localView = createModelsView({ store, api, toasts, scope: "local" }); - const remoteView = createModelsView({ store, api, toasts, scope: "remote" }); - const settingsView = createSettingsView({ store, api, toasts }); - const discoverView = createDiscoverView({ + const localView = createModelsPage({ store, api, toasts, scope: "local" }); + const remoteView = createModelsPage({ store, api, toasts, scope: "remote" }); + const settingsView = createSettingsPage({ store, api, toasts }); + const discoverView = createDiscoverPage({ api, hf: new HfApi(api), store, toasts, }); - const secretsView = createSecretsView({ store, api, toasts, sheets }); - const cloudView = createCloudModelsView({ store, sheets, api, toasts }); - const profilesView = createProfilesView({ + const secretsView = createSecretsPage({ store, api, toasts, sheets }); + const cloudView = createCloudModelsPage({ store, sheets, api, toasts }); + const profilesView = createProfilesPage({ store, toasts, onRestartRequired: raiseRestartBanner, @@ -419,7 +419,7 @@ function mountLiveShell( const stopRouter = startRouter({ win, main, - onRoute: (view) => tabBar.setActiveView(view), + onRoute: (view) => tabBar.setActivePage(view), views: { local: (target, match) => localView.mount(target, match.detail), remote: (target, match) => remoteView.mount(target, match.detail), @@ -447,7 +447,7 @@ function mountLiveShell( // suffices because the boot load runs once per process and an Apply // queues behind it, so during an Apply the text the overlay shows is // the Apply's own (or the boot load it waits behind). Subscribing at - // boot keeps the shell an independent subscriber whether or not the + // boot keeps the desk an independent subscriber whether or not the // workshop is connected. Panel mode never subscribes: the workshop // already consumes the same stream and owns all progress display. const stopProgress = @@ -472,12 +472,12 @@ function mountLiveShell( } /** - * Mounts the inert panel-mode shell: the same chrome minus the + * Mounts the inert panel-mode desk: the same chrome minus the * medallion and key prompt, with no network calls at all. It shows * until the workshop's context message arrives (and for good when the * iframe URL's bridge origin is missing or unusable); the profile * switcher is an inert placeholder and a banner says so. Returns the - * teardown that stops its router, so the live shell can replace it + * teardown that stops its router, so the live desk can replace it * cleanly. */ function mountPanelPending(root: HTMLElement, win: BootWindow): () => void { @@ -494,7 +494,7 @@ function mountPanelPending(root: HTMLElement, win: BootWindow): () => void { note.textContent = "Workshop bridge pending: gateway data is unavailable in panel mode."; const main = mountChrome(root, tabBar.element, [], note); - return startRouter({ win, main, onRoute: (view) => tabBar.setActiveView(view) }); + return startRouter({ win, main, onRoute: (view) => tabBar.setActivePage(view) }); } /** @@ -510,7 +510,7 @@ function mountChrome( ): HTMLElement { const main = document.createElement("main"); main.id = "main"; - main.className = "shell"; + main.className = "desk"; // Focusable only programmatically, as the skip link's landing spot. main.tabIndex = -1; diff --git a/crates/gateway/config-ui/ui/src/mode.test.mjs b/crates/gateway/config-ui/ui/src/mode.test.mjs index cc02f50b9..ddc664808 100644 --- a/crates/gateway/config-ui/ui/src/mode.test.mjs +++ b/crates/gateway/config-ui/ui/src/mode.test.mjs @@ -1,7 +1,7 @@ -// Pins mode detection at boot: ?mode=panel mounts the shell without +// Pins mode detection at boot: ?mode=panel mounts the desk without // the key prompt or the medallion (and calls no gateway API until the // workshop bridge exists), while standalone shows the medallion and the -// skip link once a stored key admits the shell. +// skip link once a stored key admits the desk. import assert from "node:assert/strict"; import test from "node:test"; @@ -25,7 +25,7 @@ test("?mode=panel skips the key prompt and the medallion", async () => { assert.match( root.querySelector(".banner")?.textContent ?? "", /bridge pending/i, - "the shell notes the workshop bridge is pending", + "the desk notes the workshop bridge is pending", ); assert.equal(root.querySelector("main h1.view-title")?.textContent, "Local"); assert.equal(stub.calls.length, 0, "panel mode calls no gateway API before the bridge"); diff --git a/crates/gateway/config-ui/ui/src/views/apply-revert.test.mjs b/crates/gateway/config-ui/ui/src/pages/apply-revert.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/apply-revert.test.mjs rename to crates/gateway/config-ui/ui/src/pages/apply-revert.test.mjs diff --git a/crates/gateway/config-ui/ui/src/views/cloud-models-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/cloud-models-page.test.mjs similarity index 99% rename from crates/gateway/config-ui/ui/src/views/cloud-models-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/cloud-models-page.test.mjs index 88c6abc17..4f3930717 100644 --- a/crates/gateway/config-ui/ui/src/views/cloud-models-view.test.mjs +++ b/crates/gateway/config-ui/ui/src/pages/cloud-models-page.test.mjs @@ -17,7 +17,7 @@ import { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -/** Boots the shell with the sheet stubbed and lands on the Cloud tab. */ +/** Boots the desk with the sheet stubbed and lands on the Cloud tab. */ async function openCloud(stubOptions = {}) { const stub = gatewayStub({ key: "k", config: modelsFixture(), ...stubOptions }); const { dom, root } = await bootApp({ key: "k", stub, options: { sheetPollMs: 10 } }); diff --git a/crates/gateway/config-ui/ui/src/views/cloud-models-view.ts b/crates/gateway/config-ui/ui/src/pages/cloud-models-page.ts similarity index 97% rename from crates/gateway/config-ui/ui/src/views/cloud-models-view.ts rename to crates/gateway/config-ui/ui/src/pages/cloud-models-page.ts index b8d66d045..54423dc37 100644 --- a/crates/gateway/config-ui/ui/src/views/cloud-models-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/cloud-models-page.ts @@ -38,7 +38,7 @@ const KINDS: ReadonlyArray = [ ]; /** Construction dependencies for the Cloud view. */ -export interface CloudModelsViewDeps { +export interface CloudModelsPageDeps { /** The config store: the payload base and the staging write path. */ store: ConfigStore; /** The cloud sheet store driving the loading/loaded/error states. */ @@ -50,13 +50,13 @@ export interface CloudModelsViewDeps { } /** The mounted view. */ -export interface CloudModelsView { +export interface CloudModelsPage { /** Renders the view into `main`; returns the unmount cleanup. */ mount(main: HTMLElement): () => void; } /** Builds the Cloud view. */ -export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsView { +export function createCloudModelsPage(deps: CloudModelsPageDeps): CloudModelsPage { const { store, sheets, toasts } = deps; let kind = "chat"; @@ -65,7 +65,7 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie /** Canonical entry ids whose snapshot rows are expanded. */ const expanded = new Set(); let main: HTMLElement | null = null; - let viewRoot: HTMLElement | null = null; + let pageRoot: HTMLElement | null = null; /** The currently selected provider option, when one is selected. */ const selectedProvider = (): CloudProviderOption | null => { @@ -96,11 +96,11 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie const root = document.createElement("div"); root.className = "cloud-view"; - viewRoot = root; + pageRoot = root; if (sheets.status === "error" && sheets.sheet === null) { const failed = document.createElement("p"); - failed.className = "view-empty"; + failed.className = "page-empty"; failed.textContent = sheets.error ?? "The cloud model sheet is unreachable."; const retry = document.createElement("button"); retry.type = "button"; @@ -113,7 +113,7 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie } if (sheets.sheet === null) { const loading = document.createElement("p"); - loading.className = "view-empty"; + loading.className = "page-empty"; loading.textContent = "Loading the cloud model sheet…"; root.append(loading); main.replaceChildren(title, root); @@ -367,7 +367,7 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie wrap.append(table); if (!selected || tbody.childElementCount === 0) { const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "No models of this kind."; wrap.append(empty); } @@ -511,7 +511,7 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie mount(target: HTMLElement): () => void { main = target; const unsubscribe = sheets.subscribe(() => { - if (main?.isConnected && viewRoot?.isConnected) { + if (main?.isConnected && pageRoot?.isConnected) { render(); } }); @@ -519,7 +519,7 @@ export function createCloudModelsView(deps: CloudModelsViewDeps): CloudModelsVie return () => { unsubscribe(); main = null; - viewRoot = null; + pageRoot = null; }; }, }; diff --git a/crates/gateway/config-ui/ui/src/views/discover-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/discover-page.test.mjs similarity index 99% rename from crates/gateway/config-ui/ui/src/views/discover-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/discover-page.test.mjs index 7d02bdc70..e52bf157e 100644 --- a/crates/gateway/config-ui/ui/src/views/discover-view.test.mjs +++ b/crates/gateway/config-ui/ui/src/pages/discover-page.test.mjs @@ -291,7 +291,7 @@ test("a hub 401 (no HF_TOKEN) shows the Secrets banner instead of the key prompt assert.equal(banner.querySelector("a")?.getAttribute("href"), "#/secrets"); assert.ok( root.querySelector(".tab-bar"), - "the shell stays mounted: a hub 401 must not clear the gateway key", + "the desk stays mounted: a hub 401 must not clear the gateway key", ); }); diff --git a/crates/gateway/config-ui/ui/src/views/discover-view.ts b/crates/gateway/config-ui/ui/src/pages/discover-page.ts similarity index 98% rename from crates/gateway/config-ui/ui/src/views/discover-view.ts rename to crates/gateway/config-ui/ui/src/pages/discover-page.ts index fd1952164..7e3c92cff 100644 --- a/crates/gateway/config-ui/ui/src/views/discover-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/discover-page.ts @@ -60,7 +60,7 @@ const SORTS: ReadonlyArray = [ ]; /** Construction dependencies for the view. */ -export interface DiscoverViewDeps { +export interface DiscoverPageDeps { /** The admin API, for the system snapshot. */ api: GatewayApi; /** The typed HF proxy client. */ @@ -72,7 +72,7 @@ export interface DiscoverViewDeps { } /** The mounted view handle the router calls. */ -export interface DiscoverView { +export interface DiscoverPage { /** Renders the view into `main`. */ mount(main: HTMLElement): () => void; } @@ -121,7 +121,7 @@ function recommendedQuant(quants: HfQuant[], system: SystemSnapshot): string | n } /** Builds the Discover view (state survives route re-mounts). */ -export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { +export function createDiscoverPage(deps: DiscoverPageDeps): DiscoverPage { const { api, hf, store, toasts } = deps; let query = ""; @@ -142,7 +142,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { const staging = new Set(); let main: HTMLElement | null = null; - let viewRoot: HTMLElement | null = null; + let pageRoot: HTMLElement | null = null; let listBox: HTMLElement | null = null; let detailBox: HTMLElement | null = null; let searchTimer: ReturnType | null = null; @@ -280,7 +280,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { }; const render = (): void => { - if (!main || (viewRoot !== null && !viewRoot.isConnected)) { + if (!main || (pageRoot !== null && !pageRoot.isConnected)) { return; } const title = document.createElement("h1"); @@ -303,7 +303,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { renderList(); renderDetail(); - viewRoot = split; + pageRoot = split; main.replaceChildren(...parts); }; @@ -334,7 +334,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { parts.push(errorBanner(searchError)); } else if (rows.length === 0) { const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = tokenMissing ? "Hugging Face search is unavailable without a token." : "No models match the search."; @@ -533,14 +533,14 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { } if (detailError !== null) { const failed = document.createElement("p"); - failed.className = "view-empty"; + failed.className = "page-empty"; failed.textContent = `Could not load the model: ${detailError}`; detailBox.replaceChildren(failed); return; } if (detail === null) { const hint = document.createElement("p"); - hint.className = "view-empty"; + hint.className = "page-empty"; hint.textContent = "Select a model to see its details."; detailBox.replaceChildren(hint); return; @@ -613,7 +613,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { wrap.className = "quant-picker"; if (model.quants.length === 0) { const none = document.createElement("p"); - none.className = "view-empty"; + none.className = "page-empty"; none.textContent = "This repository has no GGUF files."; wrap.append(none); return wrap; @@ -790,7 +790,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { section.append(heading); if (readmeHtml === null) { const none = document.createElement("p"); - none.className = "view-empty"; + none.className = "page-empty"; none.textContent = "No README available."; section.append(none); return section; @@ -805,7 +805,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { return { mount(target: HTMLElement): () => void { main = target; - viewRoot = null; + pageRoot = null; render(); if (system === null) { systemController?.abort(); @@ -844,7 +844,7 @@ export function createDiscoverView(deps: DiscoverViewDeps): DiscoverView { searching = false; detailLoading = false; main = null; - viewRoot = null; + pageRoot = null; listBox = null; detailBox = null; }; diff --git a/crates/gateway/config-ui/ui/src/views/model-detail.test.mjs b/crates/gateway/config-ui/ui/src/pages/model-detail.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/model-detail.test.mjs rename to crates/gateway/config-ui/ui/src/pages/model-detail.test.mjs diff --git a/crates/gateway/config-ui/ui/src/views/models-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/models-page.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/models-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/models-page.test.mjs diff --git a/crates/gateway/config-ui/ui/src/views/models-view.ts b/crates/gateway/config-ui/ui/src/pages/models-page.ts similarity index 99% rename from crates/gateway/config-ui/ui/src/views/models-view.ts rename to crates/gateway/config-ui/ui/src/pages/models-page.ts index acc3d68c5..635a18ecb 100644 --- a/crates/gateway/config-ui/ui/src/views/models-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/models-page.ts @@ -49,7 +49,7 @@ type Filter = (typeof FILTERS)[number]; type Sort = "name" | "size" | "kind"; /** Construction dependencies for the view. */ -export interface ModelsViewDeps { +export interface ModelsPageDeps { /** The config store: catalog, edits, save path. */ store: ConfigStore; /** The admin API, for model-info, reveal, and cache deletes. */ @@ -61,13 +61,13 @@ export interface ModelsViewDeps { } /** The mounted view handle the router calls. */ -export interface ModelsView { +export interface ModelsPage { /** Renders the view into `main`, selecting `selected` when given. */ mount(main: HTMLElement, selected?: string): void; } /** Builds the Models view (state survives route re-mounts). */ -export function createModelsView(deps: ModelsViewDeps): ModelsView { +export function createModelsPage(deps: ModelsPageDeps): ModelsPage { const { store, api, toasts } = deps; let search = ""; @@ -81,7 +81,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { const customTemplateModes = new Set(); let main: HTMLElement | null = null; /** The last-rendered split root; a re-render is legal only while it owns `main`. */ - let viewRoot: HTMLElement | null = null; + let pageRoot: HTMLElement | null = null; let selected: string | undefined; let listBox: HTMLElement | null = null; let detailBox: HTMLElement | null = null; @@ -91,7 +91,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { // Guard on this view's own root, not just `main`: `main` is shared // with every other view, so a store notification arriving while // another view owns it must not let this one repaint the pane. - if (main?.isConnected && viewRoot?.isConnected) { + if (main?.isConnected && pageRoot?.isConnected) { render(); } }); @@ -124,7 +124,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { renderList(); renderDetail(); - viewRoot = split; + pageRoot = split; main.replaceChildren(title, split); }; @@ -422,7 +422,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { const emptyState = (): HTMLElement => { const empty = document.createElement("div"); - empty.className = "view-empty empty-state"; + empty.className = "page-empty empty-state"; const message = document.createElement("p"); message.textContent = deps.scope === "local" ? "No local models configured" : "No remote models configured"; @@ -521,7 +521,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { } if (!selected) { const hint = document.createElement("p"); - hint.className = "view-empty"; + hint.className = "page-empty"; hint.textContent = "Select a model to edit its settings."; detailBox.replaceChildren(hint); return; @@ -529,7 +529,7 @@ export function createModelsView(deps: ModelsViewDeps): ModelsView { const entry = store.findByName(selected); if (!entry) { const missing = document.createElement("p"); - missing.className = "view-empty"; + missing.className = "page-empty"; missing.textContent = `No model named ${selected}.`; detailBox.replaceChildren(missing); return; diff --git a/crates/gateway/config-ui/ui/src/views/profiles-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/profiles-page.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/profiles-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/profiles-page.test.mjs diff --git a/crates/gateway/config-ui/ui/src/views/profiles-view.ts b/crates/gateway/config-ui/ui/src/pages/profiles-page.ts similarity index 98% rename from crates/gateway/config-ui/ui/src/views/profiles-view.ts rename to crates/gateway/config-ui/ui/src/pages/profiles-page.ts index a8cb7fab1..ff8996d86 100644 --- a/crates/gateway/config-ui/ui/src/views/profiles-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/profiles-page.ts @@ -29,7 +29,7 @@ import type { export const VRAM_WARN_FRACTION = 0.8; /** Construction dependencies for the profile checklist view. */ -export interface ProfilesViewDeps { +export interface ProfilesPageDeps { /** Pending catalog and profile state. */ store: ConfigStore; /** Save and validation outcomes. */ @@ -42,7 +42,7 @@ export interface ProfilesViewDeps { const NO_PROFILE_LABEL = "No profile"; /** The mounted Profiles view. */ -export interface ProfilesView { +export interface ProfilesPage { /** Renders the profile editor into `main`. */ mount(main: HTMLElement): () => void; } @@ -64,10 +64,10 @@ export function profileNameError(name: string): string | null { } /** Builds the Profiles view. */ -export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { +export function createProfilesPage(deps: ProfilesPageDeps): ProfilesPage { const { store, toasts, onRestartRequired } = deps; let main: HTMLElement | null = null; - let viewRoot: HTMLElement | null = null; + let pageRoot: HTMLElement | null = null; let selectedProfile = ""; let availableQuery = ""; let chosenQuery = ""; @@ -103,7 +103,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { }; store.subscribe(() => { - if (main?.isConnected && viewRoot?.isConnected) { + if (main?.isConnected && pageRoot?.isConnected) { render(); } }); @@ -277,7 +277,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { root.append(split); } root.append(live); - viewRoot = root; + pageRoot = root; main.replaceChildren(root); }; @@ -388,7 +388,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { const profile = currentProfile(); if (!profile) { const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "Create a profile to choose models."; pane.append(empty); return pane; @@ -479,7 +479,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { entries.forEach((entry, index) => list.append(option(entry, pane, index, entries))); if (entries.length === 0) { const empty = document.createElement("li"); - empty.className = "view-empty shuttle-empty"; + empty.className = "page-empty shuttle-empty"; empty.textContent = "No matching models."; list.append(empty); } @@ -807,7 +807,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { return { mount(target: HTMLElement): () => void { main = target; - viewRoot = null; + pageRoot = null; render(); return () => { for (const pane of ["available", "chosen"] as const) { @@ -818,7 +818,7 @@ export function createProfilesView(deps: ProfilesViewDeps): ProfilesView { } } main = null; - viewRoot = null; + pageRoot = null; }; }, }; diff --git a/crates/gateway/config-ui/ui/src/views/secrets-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/secrets-page.test.mjs similarity index 99% rename from crates/gateway/config-ui/ui/src/views/secrets-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/secrets-page.test.mjs index 710fadef4..6ec0a42e7 100644 --- a/crates/gateway/config-ui/ui/src/views/secrets-view.test.mjs +++ b/crates/gateway/config-ui/ui/src/pages/secrets-page.test.mjs @@ -46,7 +46,7 @@ function secretsSheetFixture() { return sheet; } -/** Boots the shell with the env and sheet stubbed and lands on Secrets. */ +/** Boots the desk with the env and sheet stubbed and lands on Secrets. */ async function openSecrets(stubOptions = {}) { const stub = gatewayStub({ key: "k", diff --git a/crates/gateway/config-ui/ui/src/views/secrets-view.ts b/crates/gateway/config-ui/ui/src/pages/secrets-page.ts similarity index 98% rename from crates/gateway/config-ui/ui/src/views/secrets-view.ts rename to crates/gateway/config-ui/ui/src/pages/secrets-page.ts index ac257e1a3..818997d1a 100644 --- a/crates/gateway/config-ui/ui/src/views/secrets-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/secrets-page.ts @@ -30,7 +30,7 @@ import type { SheetStore } from "../services/sheet-store"; import type { ToastStack } from "shared-ui/toast"; /** Construction dependencies for the Secrets view. */ -export interface SecretsViewDeps { +export interface SecretsPageDeps { /** The config store, for the dirty refresh after a save. */ store: ConfigStore; /** The admin API: env read/stage and the HF connectivity probe. */ @@ -42,7 +42,7 @@ export interface SecretsViewDeps { } /** The mounted view. */ -export interface SecretsView { +export interface SecretsPage { /** Renders the view into `main`. */ mount(main: HTMLElement): () => void; } @@ -60,7 +60,7 @@ const HF_KEY = "HF_TOKEN"; const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; /** Builds the Secrets view (fetches fresh env state on every mount). */ -export function createSecretsView(deps: SecretsViewDeps): SecretsView { +export function createSecretsPage(deps: SecretsPageDeps): SecretsPage { const { store, api, sheets, toasts } = deps; /** Working rows per scope, rebuilt from the gateway on each mount. */ @@ -314,7 +314,7 @@ export function createSecretsView(deps: SecretsViewDeps): SecretsView { } if (listed.length === 0) { const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "No variables."; body.append(empty); } @@ -469,7 +469,7 @@ export function createSecretsView(deps: SecretsViewDeps): SecretsView { section.replaceChildren(heading, sectionBody("global", render)); } else { const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "No global environment file is configured."; section.replaceChildren(heading, empty); } @@ -509,7 +509,7 @@ export function createSecretsView(deps: SecretsViewDeps): SecretsView { title.className = "view-title"; title.textContent = "Secrets"; const loading = document.createElement("p"); - loading.className = "view-empty"; + loading.className = "page-empty"; loading.textContent = "Loading\u2026"; target.replaceChildren(title, loading); void load(controller.signal) @@ -525,7 +525,7 @@ export function createSecretsView(deps: SecretsViewDeps): SecretsView { return; } const failed = document.createElement("p"); - failed.className = "view-empty"; + failed.className = "page-empty"; failed.textContent = error instanceof Error ? error.message : "The env files could not be read."; target.replaceChildren(title, failed); diff --git a/crates/gateway/config-ui/ui/src/views/settings-view.test.mjs b/crates/gateway/config-ui/ui/src/pages/settings-page.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/settings-view.test.mjs rename to crates/gateway/config-ui/ui/src/pages/settings-page.test.mjs diff --git a/crates/gateway/config-ui/ui/src/views/settings-view.ts b/crates/gateway/config-ui/ui/src/pages/settings-page.ts similarity index 99% rename from crates/gateway/config-ui/ui/src/views/settings-view.ts rename to crates/gateway/config-ui/ui/src/pages/settings-page.ts index 6a0114c68..1f489cb8e 100644 --- a/crates/gateway/config-ui/ui/src/views/settings-view.ts +++ b/crates/gateway/config-ui/ui/src/pages/settings-page.ts @@ -63,7 +63,7 @@ const VENDORS: ReadonlyArray void; } @@ -189,12 +189,12 @@ function webSearchDefaults(): EntryData { } /** Builds the Settings view (state survives route re-mounts). */ -export function createSettingsView(deps: SettingsViewDeps): SettingsView { +export function createSettingsPage(deps: SettingsPageDeps): SettingsPage { const { store, api, toasts } = deps; let main: HTMLElement | null = null; /** The last-rendered panel root; a re-render is legal only while it owns `main`. */ - let viewRoot: HTMLElement | null = null; + let pageRoot: HTMLElement | null = null; let section: SectionId = "system"; /** Unsaved edits: card key -> field path -> value. */ const edits = new Map>(); @@ -231,7 +231,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { // Guard on this view's own root, not just `main`: `main` is shared // with every other view, so a store notification arriving while // another view owns it must not let this one repaint the pane. - if (main?.isConnected && viewRoot?.isConnected) { + if (main?.isConnected && pageRoot?.isConnected) { render(); } }); @@ -292,7 +292,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { const split = document.createElement("div"); split.className = "settings-split"; split.append(buildNav(), buildPanel()); - viewRoot = split; + pageRoot = split; main.replaceChildren(title, split); }; @@ -375,7 +375,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { return { mount(target: HTMLElement, sectionId?: string): () => void { main = target; - viewRoot = null; + pageRoot = null; section = (SECTIONS.some((item) => item.id === sectionId) ? sectionId : "system") as SectionId; @@ -388,7 +388,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { return () => { stopPolling(); main = null; - viewRoot = null; + pageRoot = null; liveBox = null; }; }, @@ -980,7 +980,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { if ((pending === null || pending === undefined) && !draft) { const { card, body } = settingsCard("Speech"); const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "Speech pipeline tuning is optional. Model files and roles remain in the global STT model catalog."; const enable = document.createElement("button"); @@ -1507,7 +1507,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { if ((webSearch === null || webSearch === undefined) && !draft) { const { card, body } = settingsCard("Web Search"); const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "Web search not configured."; const enable = document.createElement("button"); enable.type = "button"; diff --git a/crates/gateway/config-ui/ui/src/views/settings-sections.test.mjs b/crates/gateway/config-ui/ui/src/pages/settings-sections.test.mjs similarity index 100% rename from crates/gateway/config-ui/ui/src/views/settings-sections.test.mjs rename to crates/gateway/config-ui/ui/src/pages/settings-sections.test.mjs diff --git a/crates/gateway/config-ui/ui/src/panel-mode.test.mjs b/crates/gateway/config-ui/ui/src/panel-mode.test.mjs index e8da6c33a..2bdb8c42b 100644 --- a/crates/gateway/config-ui/ui/src/panel-mode.test.mjs +++ b/crates/gateway/config-ui/ui/src/panel-mode.test.mjs @@ -1,6 +1,6 @@ // Pins the workshop-panel bridge client: with a pinned bridge origin in // the URL, boot announces itself, waits for the workshop's context -// message, and mounts the live shell whose every gateway call goes through +// message, and mounts the live desk whose every gateway call goes through // postMessage - no direct gateway fetch, no sessionStorage key, and no // progress SSE subscription exist in the frame (the workshop owns // progress display). Apply and Revert All are announced to the parent; @@ -194,7 +194,7 @@ test("a context message from a foreign origin is ignored", async () => { assert.match( root.querySelector(".banner")?.textContent ?? "", /bridge pending/i, - "a foreign context never mounts the live shell", + "a foreign context never mounts the live desk", ); }); @@ -218,7 +218,7 @@ test("a non-loopback bridge origin stays inert and posts nothing", async () => { assert.match( root.querySelector(".banner")?.textContent ?? "", /bridge pending/i, - "a non-loopback bridge origin never mounts the live shell", + "a non-loopback bridge origin never mounts the live desk", ); }); diff --git a/crates/gateway/config-ui/ui/src/router.ts b/crates/gateway/config-ui/ui/src/router.ts index 6f1f8ddc9..1cf93e475 100644 --- a/crates/gateway/config-ui/ui/src/router.ts +++ b/crates/gateway/config-ui/ui/src/router.ts @@ -1,7 +1,7 @@ -// Hash router [Adapted: llama.cpp] for both shell modes. +// Hash router [Adapted: llama.cpp] for both desk modes. /** The seven top-level destinations. */ -export type ViewId = +export type PageId = | "settings" | "discover" | "local" @@ -13,13 +13,13 @@ export type ViewId = /** A parsed route: the view plus its optional detail segment. */ export interface RouteMatch { /** The destination view. */ - view: ViewId; + view: PageId; /** The model name or settings section, when the route includes one. */ detail?: string; } /** Display titles for the stub views. */ -const VIEW_TITLES: Readonly> = { +const VIEW_TITLES: Readonly> = { settings: "Settings", discover: "Discover", local: "Local", @@ -99,18 +99,18 @@ export interface RouterOptions { /** The `
` region the views mount into. */ main: HTMLElement; /** Fired after every render so the tab bar can follow the route. */ - onRoute: (view: ViewId) => void; + onRoute: (view: PageId) => void; /** Real view mounts by destination; unlisted views render the stub. */ - views?: Partial>; + views?: Partial>; } /** * Renders the current route now and again on every hash change. * Returns the stop function that detaches the hashchange listener, so - * a shell remount never stacks routers. + * a desk remount never stacks routers. */ export function startRouter(options: RouterOptions): () => void { - let disposeView: () => void = () => undefined; + let disposePage: () => void = () => undefined; let currentRoute = ""; const render = () => { let match = matchRoute(options.win.location.hash); @@ -125,13 +125,13 @@ export function startRouter(options: RouterOptions): () => void { return; } currentRoute = routeKey; - disposeView(); - disposeView = () => undefined; + disposePage(); + disposePage = () => undefined; const mount = options.views?.[match.view]; if (mount) { const cleanup = mount(options.main, match); if (cleanup) { - disposeView = cleanup; + disposePage = cleanup; } } else { mountStubView(options.main, match); @@ -141,7 +141,7 @@ export function startRouter(options: RouterOptions): () => void { options.win.addEventListener("hashchange", render); render(); return () => { - disposeView(); + disposePage(); options.win.removeEventListener("hashchange", render); }; } @@ -152,7 +152,7 @@ function mountStubView(main: HTMLElement, match: RouteMatch): void { title.className = "view-title"; title.textContent = VIEW_TITLES[match.view]; const empty = document.createElement("p"); - empty.className = "view-empty"; + empty.className = "page-empty"; empty.textContent = "Nothing to show here yet."; main.replaceChildren(title, empty); } diff --git a/crates/gateway/config-ui/ui/src/services/config-store.ts b/crates/gateway/config-ui/ui/src/services/config-store.ts index bc6164cae..f39e5009b 100644 --- a/crates/gateway/config-ui/ui/src/services/config-store.ts +++ b/crates/gateway/config-ui/ui/src/services/config-store.ts @@ -132,7 +132,7 @@ export interface DiffRow { } /** - * The store. Constructed once per shell mount; views subscribe and read, + * The store. Constructed once per desk mount; views subscribe and read, * the composition root drives load/apply/revert. */ export class ConfigStore { diff --git a/crates/gateway/config-ui/ui/src/services/gateway-api.test.mjs b/crates/gateway/config-ui/ui/src/services/gateway-api.test.mjs index e7de75062..7f4461287 100644 --- a/crates/gateway/config-ui/ui/src/services/gateway-api.test.mjs +++ b/crates/gateway/config-ui/ui/src/services/gateway-api.test.mjs @@ -1,7 +1,7 @@ // Pins the refusal threading every route shares: a refusal from a // route other than config-apply throws a GatewayHttpError that includes the // envelope's `error.code` alongside its message, so callers can branch -// on the code (the way the shell words the apply_cancelled toast). +// on the code (the way the desk words the apply_cancelled toast). import assert from "node:assert/strict"; import test from "node:test"; diff --git a/crates/gateway/config-ui/ui/src/services/gateway-api.ts b/crates/gateway/config-ui/ui/src/services/gateway-api.ts index 10809041c..996a983d4 100644 --- a/crates/gateway/config-ui/ui/src/services/gateway-api.ts +++ b/crates/gateway/config-ui/ui/src/services/gateway-api.ts @@ -196,7 +196,7 @@ export interface SwitchOutcome { } /** The `GET /admin/config-pending` envelope this UI consumes. */ -export interface PendingView { +export interface PendingPage { /** The shadow-preferred global config, secrets redacted. */ config: Record; /** @@ -371,7 +371,7 @@ export interface GatewayApiOptions { base?: string; } -/** Typed client for the admin endpoints the shell uses. */ +/** Typed client for the admin endpoints the desk uses. */ export class GatewayApi { /** Fired after any 401: the stored key is gone and auth must restart. */ onUnauthorized: (() => void) | null = null; @@ -528,7 +528,7 @@ export class GatewayApi { * it is split out here because it is not a configuration key and must * never be sent back in a `PUT /admin/config` body. */ - async getConfigPending(): Promise { + async getConfigPending(): Promise { const data = requireRecord(await this.getJson("/admin/config-pending"), "pending config"); if (data["profile"] === undefined) { return { config: {}, activeProfile: null }; @@ -575,7 +575,7 @@ export class GatewayApi { const response = await this.send("/admin/config-apply", { method: "POST" }); if (!response.ok) { // The code distinguishes a cancelled apply (pending changes still - // staged) from a failed one, so the shell can word its toast. + // staged) from a failed one, so the desk can word its toast. throw await refusalError(response); } const data = requireRecord(await response.json(), "apply outcome"); diff --git a/crates/gateway/config-ui/ui/src/services/panel-bridge.ts b/crates/gateway/config-ui/ui/src/services/panel-bridge.ts index 6cf907131..736b3d9c7 100644 --- a/crates/gateway/config-ui/ui/src/services/panel-bridge.ts +++ b/crates/gateway/config-ui/ui/src/services/panel-bridge.ts @@ -17,7 +17,7 @@ export interface PanelContext { route: string; } -/** The actions the shell announces to the workshop's status bar. */ +/** The actions the desk announces to the workshop's status bar. */ export type PanelAction = "apply" | "revert"; /** The window surface the bridge needs; tests hand in a jsdom window. */ @@ -55,7 +55,7 @@ interface PendingCall { /** * Parses the iframe URL's `bridge` parameter into a pinned http(s) * origin, or null when it is absent, malformed, or not a loopback host - - * the shell then stays in its inert bridge-pending state. The bridge + * the desk then stays in its inert bridge-pending state. The bridge * origin selects the postMessage targetOrigin for every gateway call, so * it must be the loopback workshop and never a foreign origin: a crafted * `?bridge=https://evil.example` in a framed copy of this loopback-served @@ -122,7 +122,7 @@ export class PanelBridge { this.post({ type: "pf-bridge-ready" }); } - /** Announces one shell action for the workshop's status bar. */ + /** Announces one desk action for the workshop's status bar. */ notifyAction(action: PanelAction): void { this.post({ type: "pf-action", action }); } diff --git a/crates/gateway/config-ui/ui/src/services/sheet-store.ts b/crates/gateway/config-ui/ui/src/services/sheet-store.ts index 937e8e402..f3fed8faf 100644 --- a/crates/gateway/config-ui/ui/src/services/sheet-store.ts +++ b/crates/gateway/config-ui/ui/src/services/sheet-store.ts @@ -17,7 +17,7 @@ export type SheetStatus = "loading" | "loaded" | "error"; /** The default poll interval while the gateway downloads the sheet. */ const DEFAULT_POLL_MS = 1_000; -/** The subscribable cloud sheet store; one per shell mount. */ +/** The subscribable cloud sheet store; one per desk mount. */ export class SheetStore { /** The current lifecycle state. */ status: SheetStatus = "loading"; @@ -87,7 +87,7 @@ export class SheetStore { } } - /** Stops polling; the shell's teardown calls this. */ + /** Stops polling; the desk's teardown calls this. */ dispose(): void { this.generation += 1; if (this.timer !== null) { diff --git a/crates/gateway/config-ui/ui/src/styles/layout.css b/crates/gateway/config-ui/ui/src/styles/layout.css index 365b084d9..2fadf6af1 100644 --- a/crates/gateway/config-ui/ui/src/styles/layout.css +++ b/crates/gateway/config-ui/ui/src/styles/layout.css @@ -1,9 +1,9 @@ -/* Shell layout: tab bar, master-detail split, banners, toasts, +/* Desk layout: tab bar, master-detail split, banners, toasts, modal scaffolding, and metric tiles. Written mobile-first; min-width queries widen the split and the tile grid. */ @layer components { - /* Skip link: the shell's first focusable element, parked above the + /* Skip link: the desk's first focusable element, parked above the viewport until keyboard focus reveals it as a pill over the tab bar. Near-black label on the accent, same 5.2:1 pair as .button-primary. */ @@ -68,7 +68,7 @@ } /* View content below the tab bar. */ - .shell { + .desk { padding: 1rem; } @@ -200,7 +200,7 @@ } } -/* Live-shell chrome: the key prompt screen, tab bar cluster details, +/* Live-desk chrome: the key prompt screen, tab bar cluster details, the profile switcher's anchored menu, and the apply overlay's stage list. */ @layer components { @@ -234,7 +234,7 @@ } /* Inline field error: the lightened danger companion passes AA on - every shell surface. */ + every desk surface. */ .field-error { color: var(--danger-text); } @@ -335,7 +335,7 @@ } /* Stub view empty state. */ - .view-empty { + .page-empty { margin-block-start: 0.5rem; color: var(--text-secondary); } @@ -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 @@ -1433,13 +1433,13 @@ inset-inline: 0; z-index: 40; } - /* The fixed strip overlays the shell's tail; the component toggles + /* The fixed strip overlays the desk's tail; the component toggles this body class so the last content line stays clear of it. */ - .has-status-bar .shell { + .has-status-bar .desk { padding-block-end: calc(var(--status-bar-height) + 1rem); } - /* Endpoint LEDs in the shell's indicators group: green ready, amber + /* Endpoint LEDs in the desk's indicators group: green ready, amber provisioning, gray unconfigured. The fills match the connection dot's 3:1 floor on the bar surface. */ .status-leds { diff --git a/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs b/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs index cc2dc4073..cb09d63c3 100644 --- a/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs +++ b/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs @@ -40,7 +40,7 @@ test("the bundled stylesheet defines the design tokens and layer order", async ( ); }); -test("the shell page links the bundled stylesheet", async () => { +test("the desk page links the bundled stylesheet", async () => { const html = await readFile(path.join(distDir, "index.html"), "utf8"); assert.match( html, diff --git a/crates/harness/models/Cargo.toml b/crates/harness/models/Cargo.toml index 3512c1795..c3815aa0b 100644 --- a/crates/harness/models/Cargo.toml +++ b/crates/harness/models/Cargo.toml @@ -28,9 +28,9 @@ reqwest.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -# The delta channel: `sync` for the sender a chat performer streams to. -# Nothing here spawns; the runner does. -tokio = { workspace = true, features = ["sync"] } +# The delta channel: `sync` for the sender a chat performer streams to; +# `time` for the per-receive timeout. Nothing here spawns; the runner does. +tokio = { workspace = true, features = ["sync", "time"] } url.workspace = true workspace-hack.workspace = true diff --git a/crates/harness/models/src/transport.rs b/crates/harness/models/src/transport.rs index 2eb5f7983..d2665db6f 100644 --- a/crates/harness/models/src/transport.rs +++ b/crates/harness/models/src/transport.rs @@ -4,8 +4,8 @@ //! The request body, the stream reassembly, and the read loop that applies //! the byte cap and measures the timing are the engine's shared protocol //! seams (`promptforge::transport`); this file owns only what touches the -//! wire: sending, the request timeout, the response as a chunk source, and -//! the clock the read loop is handed. +//! wire: sending, the per-receive timeout, the response as a chunk source, +//! and the clock the read loop is handed. use std::fmt; use std::num::NonZeroU64; @@ -34,13 +34,15 @@ pub struct GatewayClient { base_url: String, /// The bearer presented on every request, or `None` to present nothing. key: Option, - /// Wall-clock cap applied to each completion request. + /// Longest wait for the response headers, and then for each next body + /// chunk; a stream that keeps arriving is never cut off. request_timeout: Duration, /// Byte ceiling enforced on a response body before it is decoded. max_response_bytes: u64, } -/// Default per-request timeout, matching the executor's run limits. +/// Default longest wait for the next receive, matching the executor's run +/// limits. pub(crate) const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); /// Default response-body ceiling, matching the executor's run limits. const DEFAULT_MAX_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; @@ -71,16 +73,27 @@ pub(crate) fn transport_source(error: reqwest::Error) -> Box Error { + Error::Http(Box::new(ClientTimeout(Box::new(error)))) +} + +/// A [`reqwest::Response`] body as the reassembly's chunk source, with each +/// receive bounded by the client's timeout. +struct ResponseChunks { + response: reqwest::Response, + timeout: Duration, +} impl ChunkSource for ResponseChunks { type Chunk = bytes::Bytes; async fn next_chunk(&mut self) -> Result, CompletionError> { - self.0 - .chunk() + tokio::time::timeout(self.timeout, self.response.chunk()) .await + .map_err(|error| CompletionError::from(elapsed(error)))? .map_err(|error| CompletionError::from(http(error))) } } @@ -206,9 +219,12 @@ impl GatewayClient { /// Applies the run's HTTP limits to this client. /// - /// Each completion request is bounded by `request_timeout`, and the response - /// body is refused once it would exceed `max_response_bytes` before any - /// UTF-8 or JSON decoding runs. + /// `request_timeout` is the longest a completion request waits for its + /// response headers, and then for each next body chunk; every receive + /// restarts it, so a long stream that keeps arriving completes while + /// one that stalls fails as a timeout. The response body is refused + /// once it would exceed `max_response_bytes` before any UTF-8 or JSON + /// decoding runs. /// /// # Examples /// @@ -287,8 +303,9 @@ impl GatewayClient { /// Returns a [`CompletionError`] whose [`kind`](CompletionError::kind) is /// (F11 - the full reachable set): /// - `Disabled` when this client was built with [`GatewayClient::disabled`]; - /// - `Transport` on a transport-layer failure (connection, timeout) or - /// when the stream contains a mid-flight error envelope; + /// - `Transport` on a transport-layer failure (connection, or no headers + /// or next chunk within the timeout) or when the stream contains a + /// mid-flight error envelope; /// - `Backend` when the gateway responds with a non-success status; /// - `MalformedResponse` when the stream exceeds the size cap, a chunk's /// shape is unusable (the JSON decode failure is retained as a private @@ -310,21 +327,27 @@ impl GatewayClient { let request_body = build_request_body(messages, tools, options); let started = Instant::now(); + // No reqwest `.timeout`: it caps the whole request including the + // body, which would cut off a long stream that is still arriving. + // The timeout bounds the headers here and each receive in + // `ResponseChunks`. let mut request = http .post(format!("{}/chat/completions", self.base_url)) - // reqwest's whole-request timeout covers the body read, so the - // run's wall-clock cap bounds the entire stream, not just the - // connection. - .timeout(self.request_timeout) .json(&request_body); if let Some(key) = &self.key { request = request.bearer_auth(key.expose()); } - let response = request.send().await.map_err(self::http)?; + let response = tokio::time::timeout(self.request_timeout, request.send()) + .await + .map_err(elapsed)? + .map_err(self::http)?; let status = response.status(); let content_length = response.content_length(); - let mut chunks = ResponseChunks(response); + let mut chunks = ResponseChunks { + response, + timeout: self.request_timeout, + }; if !status.is_success() { let raw_body = read_body_capped(&mut chunks, content_length, self.max_response_bytes).await?; diff --git a/crates/harness/models/src/transport/tests/limits.rs b/crates/harness/models/src/transport/tests/limits.rs index aeada09ab..371d267bc 100644 --- a/crates/harness/models/src/transport/tests/limits.rs +++ b/crates/harness/models/src/transport/tests/limits.rs @@ -1,10 +1,10 @@ //! The bounds and refusals: the disabled sentinel, the byte caps on both -//! paths, the request timeout, and the malformed or cut-off stream. +//! paths, the per-receive timeout, and the malformed or cut-off stream. use std::num::NonZeroU64; use std::time::Duration; -use promptforge::model::Message; +use promptforge::model::{CompletionResult, Message}; use super::*; use crate::CompletionErrorKind; @@ -105,7 +105,7 @@ async fn a_request_past_the_timeout_is_a_timeout_transport_failure() { use axum::Router; use axum::routing::post; - // The run's wall-clock cap bounds the whole request; a gateway that + // The timeout bounds the wait for the response headers; a gateway that // never answers within it fails as Transport, and the timeout survives // the type erasure so `is_timeout` holds. async fn stall() -> (axum::http::StatusCode, String) { @@ -128,6 +128,138 @@ async fn a_request_past_the_timeout_is_a_timeout_transport_failure() { assert!(err.is_retryable()); } +/// Reads one request through its body, so answering and closing never +/// resets a connection that still holds unread bytes. +async fn read_request(sock: &mut tokio::net::TcpStream) { + use tokio::io::AsyncReadExt; + + let mut request = Vec::new(); + let mut buf = [0u8; 1024]; + while let Ok(read @ 1..) = sock.read(&mut buf).await { + request.extend_from_slice(&buf[..read]); + let text = String::from_utf8_lossy(&request); + let Some(end) = text.find("\r\n\r\n") else { + continue; + }; + let length = text[..end] + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + if request.len() >= end + 4 + length { + return; + } + } +} + +/// Serves one completion as a chunked SSE response written piece by piece, +/// each after its pause, and returns the `/v1` base. With `stall` the body +/// is never terminated and the socket stays open. +async fn spawn_paced_gateway(pieces: Vec<(Duration, String)>, stall: bool) -> String { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + spawn_tagged(mock_tag(), async move { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + // Small paced writes must leave at once, not wait on Nagle. + let _ = sock.set_nodelay(true); + read_request(&mut sock).await; + let head = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\ + Transfer-Encoding: chunked\r\n\r\n"; + if sock.write_all(head.as_bytes()).await.is_err() { + return; + } + for (pause, piece) in pieces { + tokio::time::sleep(pause).await; + let frame = format!("{:x}\r\n{piece}\r\n", piece.len()); + if sock.write_all(frame.as_bytes()).await.is_err() { + return; + } + } + if stall { + std::future::pending::<()>().await; + } + let _ = sock.write_all(b"0\r\n\r\n").await; + }); + format!("http://{addr}/v1") +} + +/// The stream's closing `stop` chunk and `[DONE]` sentinel. +fn stream_close() -> String { + sse_body(&[serde_json::json!({ + "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] + })]) +} + +/// A keyed client for `base` whose timeout is `budget`. +fn budgeted_client(base: &str, budget: Duration) -> GatewayClient { + keyed_client(base).with_request_limits(budget, NonZeroU64::new(1024 * 1024).expect("non-zero")) +} + +#[tokio::test] +async fn a_steady_stream_longer_than_the_timeout_completes() { + let gap = Duration::from_millis(50); + let mut pieces: Vec<(Duration, String)> = (0..5) + .map(|_| (gap, format!("data: {}\n\n", content_chunk("tick")))) + .collect(); + pieces.push((gap, stream_close())); + let base = spawn_paced_gateway(pieces, false).await; + let completion = budgeted_client(&base, Duration::from_millis(100)) + .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) + .await + .expect("a stream that keeps arriving is not timed out"); + match completion.result() { + CompletionResult::Text(text) => assert_eq!(text, "tick".repeat(5).as_str()), + other => panic!("expected text, got {other:?}"), + } +} + +#[tokio::test] +async fn a_single_event_trickled_past_the_timeout_completes() { + let event = format!("data: {}\n\n", content_chunk("trickled")); + let gap = Duration::from_millis(30); + let step = event.len().div_ceil(10); + let mut pieces: Vec<(Duration, String)> = event + .as_bytes() + .chunks(step) + .map(|piece| (gap, String::from_utf8(piece.to_vec()).expect("ASCII event"))) + .collect(); + assert!(pieces.len() >= 9, "the event arrives in about ten pieces"); + pieces.push((gap, stream_close())); + let base = spawn_paced_gateway(pieces, false).await; + let completion = budgeted_client(&base, Duration::from_millis(100)) + .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) + .await + .expect("every received piece restarts the timeout"); + match completion.result() { + CompletionResult::Text(text) => assert_eq!(text, "trickled"), + other => panic!("expected text, got {other:?}"), + } +} + +#[tokio::test] +async fn a_stream_that_stalls_after_the_headers_is_a_timeout_transport_failure() { + let pieces = vec![( + Duration::ZERO, + format!("data: {}\n\n", content_chunk("half")), + )]; + let base = spawn_paced_gateway(pieces, true).await; + let err = budgeted_client(&base, Duration::from_millis(100)) + .complete(&[Message::user("hi")], None, &openai_options(), |_| {}) + .await + .expect_err("a stream that stops arriving must time out"); + assert_eq!(err.kind(), CompletionErrorKind::Transport); + assert!( + err.is_timeout(), + "the timeout must be recognizable: {err:?}" + ); + assert!(err.is_retryable()); +} + #[tokio::test] async fn a_body_read_timeout_keeps_its_marker_under_backend_body_read() { use tokio::io::{AsyncReadExt, AsyncWriteExt}; diff --git a/crates/promptforge-internal/engine/src/execute/config-limits.rs b/crates/promptforge-internal/engine/src/execute/config-limits.rs index 302ba4baf..5f8986aef 100644 --- a/crates/promptforge-internal/engine/src/execute/config-limits.rs +++ b/crates/promptforge-internal/engine/src/execute/config-limits.rs @@ -24,7 +24,7 @@ nz!(nz_usize, NonZeroUsize, usize); /// Resource ceilings a run honors at its bounded sites: per-section tool /// iterations, fanout concurrency, model response size, Lua memory, Lua log -/// volume, and the request timeout. +/// volume, and the model receive timeout. /// /// The defaults are safe, non-environment values that a clean build can use /// as they are. Frontmatter `max_tool_iterations`, when present, still @@ -54,7 +54,8 @@ pub struct RunLimits { impl RunLimits { /// Builds the default limits (24 tool iterations, 8-way fanout, 16 MiB - /// response cap, 64 MiB Lua memory, 1024 Lua log events, 120 s timeout). + /// response cap, 64 MiB Lua memory, 1024 Lua log events, and a 120 s + /// longest wait for the next model receive). /// /// # Examples /// ``` @@ -109,7 +110,9 @@ impl RunLimits { self } - /// Sets the per-request model HTTP timeout. + /// Sets the longest a model request waits for its next receive: the + /// response headers, then each body chunk. Every receive restarts the + /// wait, so a long stream that keeps arriving is never cut off. #[must_use] pub fn request_timeout(mut self, value: Duration) -> RunLimits { self.request_timeout = value; @@ -146,7 +149,7 @@ impl RunLimits { self.lua_log_events } - /// Returns the per-request model HTTP timeout. + /// Returns the longest a model request waits for its next receive. #[must_use] pub fn timeout(&self) -> Duration { self.request_timeout diff --git a/crates/promptforge-internal/engine/src/execute/fill.rs b/crates/promptforge-internal/engine/src/execute/fill.rs index 236f230c1..da92a0821 100644 --- a/crates/promptforge-internal/engine/src/execute/fill.rs +++ b/crates/promptforge-internal/engine/src/execute/fill.rs @@ -88,7 +88,7 @@ pub(super) fn fill_model_bindings( ModelKeyword::Thinking if model.thinking() == ThinkingMode::Never => { Some("thinking") } - ModelKeyword::NoThinking if model.thinking() != ThinkingMode::Never => { + ModelKeyword::NoThinking if model.thinking() == ThinkingMode::Always => { Some("no-thinking") } _ => None, diff --git a/crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs b/crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs index cb4ab1827..4f007c842 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs @@ -6,16 +6,25 @@ use super::*; #[tokio::test] async fn models_use_forwards_binding_completion_options_to_the_gateway() { // models.use -> completion_options -> GatewayClient::complete must set - // the binding's model and the hard-keyword thinking switch on the chat - // body. (v1 roles declare no sampling fields; the thinking switch is the - // one invocation parameter with a frontmatter source.) - let gateway = ScriptedGateway::start(vec![resp_text("hello from the mock")]).await; + // the binding's model, the hard-keyword thinking switch, and the + // section's `models.use` sampling options on the chat body. Roles + // declare no sampling fields, so a section on the prompt-wide default + // sends none. + let gateway = ScriptedGateway::start(vec![ + resp_text("hello from the mock"), + resp_text("hello again"), + ]) + .await; let addr = gateway.addr(); let md = "---\nname: t\ndescription: d\npromptforge: 0\nmodels:\n analyst:\n keywords: [no-thinking]\n---\n\n\ # T\n\n\ +```lua\nmodels.default('analyst')\n```\n\n\ ## Only\n\n\ -```lua\nmodels.use('analyst')\n```\n\n\ +```lua\nmodels.use('analyst', { temperature = 0, max_tokens = 256 })\n```\n\n\ Ask the model.\n\n\ +```lua\nmodels.infer(prose)\n```\n\n\ +## Next\n\n\ +Ask again.\n\n\ ```lua\nreturn models.infer(prose)\n```\n"; let prompt = Prompt::parse(md, EXECUTION).0.expect("fixture must parse"); let mut ctx = test_context(EXECUTION); @@ -33,13 +42,22 @@ Ask the model.\n\n\ RunResult::Ok(out) => out, other => panic!("the run must succeed: {other:?}"), }; - assert_eq!(out, "hello from the mock"); + assert_eq!(out, "hello again"); - let body = gateway - .last_request() - .expect("complete must reach the gateway"); - assert_eq!(body["model"], "analyst"); - assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); + let requests = gateway.requests(); + assert_eq!(requests.len(), 2, "one round per section: {requests:?}"); + let selected = &requests[0]; + assert_eq!(selected["model"], "analyst"); + assert_eq!(selected["chat_template_kwargs"]["enable_thinking"], false); + assert_eq!(selected["temperature"], 0.0); + assert_eq!(selected["max_tokens"], 256); + let defaulted = &requests[1]; + assert_eq!(defaulted["model"], "analyst"); + assert_eq!(defaulted["chat_template_kwargs"]["enable_thinking"], false); + assert!( + defaulted.get("temperature").is_none() && defaulted.get("max_tokens").is_none(), + "a section on the prompt-wide default sends neither option: {defaulted}" + ); } #[tokio::test] diff --git a/crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs b/crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs index d4b2c85d0..0640e4f7c 100644 --- a/crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs +++ b/crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs @@ -7,11 +7,12 @@ use std::num::NonZeroU32; use crate::parser::Prompt; -use crate::test_support::{RunHost, run_with_host}; +use crate::test_support::{RunHost, run_host, run_with_host}; use crate::{Environment, RunErrorKind, RunResult}; use promptforge_types::models::{ModelDescriptor, ModelId, ThinkingMode}; use promptforge_vfs::Origin; +use super::super::{ScriptedGateway, gateway_client, resp_text}; use super::support::context; /// A prompt declaring no capabilities at all. @@ -147,6 +148,75 @@ async fn env_run_prepares_implicitly_and_runs_a_satisfiable_prompt() { assert_eq!(text, "done"); } +/// A prompt declaring one `no-thinking` role whose section asks it one +/// question. +const DECLARES_NO_THINKING: &str = concat!( + "---\n", + "name: declares-no-thinking\n", + "description: d\n", + "promptforge: 0\n", + "models:\n", + " writer:\n", + " keywords: [no-thinking]\n", + "---\n\n", + "# Title\n\n", + "## Only\n\n", + "```lua\n", + "models.use('writer')\n", + "return models.infer('ping')\n", + "```\n", +); + +#[tokio::test] +async fn a_no_thinking_role_on_a_switchable_model_prepares_and_asks_for_thinking_off() { + let prompt = parse(DECLARES_NO_THINKING, "declares-no-thinking"); + let gateway = ScriptedGateway::start(vec![resp_text("pong")]).await; + let (ctx, requirements) = Environment::new().prepare( + &prompt, + context("switchable").model(current_model(32_000, ThinkingMode::Switchable)), + ); + assert!( + requirements.is_satisfied(), + "a switchable model can turn thinking off: {requirements:?}" + ); + let host = RunHost::new().client(gateway_client(gateway.addr())); + let result = run_host(&prompt, "", ctx, host).await; + let RunResult::Ok(text) = result else { + panic!("the prepared prompt runs: {result:?}"); + }; + assert_eq!(text, "pong"); + let body = gateway + .last_request() + .expect("the round reaches the gateway"); + assert_eq!(body["model"], "current"); + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], false); +} + +#[tokio::test] +async fn a_no_thinking_role_on_an_always_thinking_model_is_refused() { + let prompt = parse(DECLARES_NO_THINKING, "declares-no-thinking"); + let result = run_with_host( + &Environment::new(), + &prompt, + "", + context("always").model(current_model(32_000, ThinkingMode::Always)), + RunHost::new(), + ) + .await; + let RunResult::Failure(error) = result else { + panic!("a model that always thinks cannot satisfy no-thinking: {result:?}"); + }; + assert_eq!(error.kind(), RunErrorKind::RequirementsUnmet); + let notice = error.to_string(); + assert!( + notice.contains( + "role 'writer': requires 'no-thinking'; \ + the current model's thinking capability is Always" + ), + "the notice gives required versus actual keywords: {notice}" + ); +} + /// A prompt declaring one tool slot whose capability is not declared at /// all. const DECLARES_ORPHAN_SLOT: &str = concat!( diff --git a/crates/promptforge-internal/lua/src/models-tests.rs b/crates/promptforge-internal/lua/src/models-tests.rs index 545675ee0..f7e858b74 100644 --- a/crates/promptforge-internal/lua/src/models-tests.rs +++ b/crates/promptforge-internal/lua/src/models-tests.rs @@ -1,23 +1,28 @@ //! Tests for the `models` namespace: `use`, `default`, `get`, and the model runtime selection. -use super::{ModelRuntime, install_models}; +use super::{ModelRuntime, UseOptions, install_models}; use mlua::Lua; use promptforge_model_client::model::ModelBinding; -use promptforge_model_client::model::{ModelInvocation, ModelSet}; +use promptforge_model_client::model::{ModelInvocation, ModelSet, Temperature}; use promptforge_types::detail::model_id_from_validated; use std::sync::{Arc, Mutex}; +/// The runtime's selected label, if any. +fn used(runtime: &ModelRuntime) -> Option<&str> { + runtime.selection().map(|(label, _)| label) +} + #[test] fn model_runtime_select_allows_reselection() { // Selections are read at call time, so a later `models.use` replaces the // earlier one and steers the next model round. let mut runtime = ModelRuntime::new(); - assert!(runtime.used().is_none()); - runtime.select("writer".to_owned()); - assert_eq!(runtime.used(), Some("writer")); - runtime.select("other".to_owned()); + assert!(used(&runtime).is_none()); + runtime.select("writer".to_owned(), UseOptions::default()); + assert_eq!(used(&runtime), Some("writer")); + runtime.select("other".to_owned(), UseOptions::default()); assert_eq!( - runtime.used(), + used(&runtime), Some("other"), "a second select must replace the first" ); @@ -26,7 +31,7 @@ fn model_runtime_select_allows_reselection() { #[test] fn model_runtime_starts_with_no_selection() { let runtime = ModelRuntime::new(); - assert!(runtime.used().is_none(), "fresh runtime has no selection"); + assert!(used(&runtime).is_none(), "fresh runtime has no selection"); } /// A bound role for the shared set: `label`, with the keyword set recorded. @@ -86,7 +91,7 @@ fn models_use_selects_a_bound_role_by_label() { .eval() .expect("a bound label selects"); assert_eq!(handle, "writer|writer"); - assert_eq!(runtime.lock().expect("runtime lock").used(), Some("writer")); + assert_eq!(used(&runtime.lock().expect("runtime lock")), Some("writer")); } #[test] @@ -104,6 +109,187 @@ fn models_use_rejects_an_unbound_label() { ); } +#[test] +fn models_use_options_reach_the_returned_handle() { + let (lua, _, runtime) = models_vm(); + let (temperature, max_tokens): (f64, u32) = lua + .load( + "local h = models.use('writer', { temperature = 0.3, max_tokens = 256 }); \ + return h.temperature, h.max_tokens", + ) + .eval() + .expect("a valid options table is accepted"); + assert!((temperature - 0.3).abs() < f64::EPSILON); + assert_eq!(max_tokens, 256); + assert_eq!(used(&runtime.lock().expect("runtime lock")), Some("writer")); +} + +#[test] +fn models_use_accepts_an_integer_temperature_and_leaves_omitted_fields_nil() { + let (lua, _, _) = models_vm(); + let (temperature, max_tokens_is_nil): (f64, bool) = lua + .load("local h = models.use('writer', { temperature = 0 }); return h.temperature, h.max_tokens == nil") + .eval() + .expect("an integer temperature is accepted"); + assert!(temperature.abs() < f64::EPSILON); + assert!( + max_tokens_is_nil, + "an omitted field keeps the role's default" + ); +} + +#[test] +fn models_use_rejects_invalid_options_without_selecting() { + let cases = [ + ( + "models.use('writer', { temperature = 2.5 })", + "models.use option temperature 2.5 is outside the supported range [0.0, 2.0]", + ), + ( + "models.use('writer', { temperature = -0.1 })", + "models.use option temperature -0.1 is outside the supported range [0.0, 2.0]", + ), + ( + "models.use('writer', { temperature = 0/0 })", + "models.use option temperature must be finite, got NaN", + ), + ( + "models.use('writer', { temperature = 'hot' })", + "models.use option temperature must be a number, got string", + ), + ( + "models.use('writer', { max_tokens = 0 })", + "models.use option max_tokens must be an integer in [1, 4294967295], got 0", + ), + ( + "models.use('writer', { max_tokens = -1 })", + "models.use option max_tokens must be an integer in [1, 4294967295], got -1", + ), + ( + "models.use('writer', { max_tokens = 1.5 })", + "models.use option max_tokens must be an integer in [1, 4294967295], got 1.5", + ), + ( + "models.use('writer', { max_tokens = 'many' })", + "models.use option max_tokens must be an integer in [1, 4294967295], got string", + ), + ( + "models.use('writer', { top_p = 0.9 })", + "models.use option \"top_p\" is unknown: expected temperature or max_tokens", + ), + ( + "models.use('writer', { 0.5 })", + "models.use option names must be strings, got integer", + ), + ( + "models.use('writer', 'fast')", + "models.use options must be a table, got string", + ), + ( + "models.use('writer', {}, 1)", + "models.use takes at most 2 arguments, got 3", + ), + ]; + for (chunk, expected) in cases { + let (lua, _, runtime) = models_vm(); + let error = lua + .load(chunk) + .exec() + .expect_err("an invalid options argument is a hard error"); + assert!( + error.to_string().contains(expected), + "{chunk}: expected {expected:?} in {error}" + ); + assert!( + used(&runtime.lock().expect("runtime lock")).is_none(), + "{chunk}: a rejected call must not select" + ); + } +} + +#[test] +fn models_use_reports_the_first_bad_option_in_key_order_on_every_state() { + // Each fresh state walks `pairs` under its own hash seed; the rejection + // must not follow that walk. + let cases = [ + ( + "models.use('writer', { top_p = 1, max_tokens = 0, temperature = 3 })", + "models.use option max_tokens must be an integer in [1, 4294967295], got 0", + ), + ( + "models.use('writer', { temperature = 3, top_p = 1 })", + "models.use option temperature 3 is outside the supported range [0.0, 2.0]", + ), + ( + "models.use('writer', { 'x', [true] = 1, temperature = 3 })", + "models.use option names must be strings, got boolean", + ), + ]; + for (chunk, expected) in cases { + for _ in 0..32 { + let (lua, _, _) = models_vm(); + let error = lua + .load(chunk) + .exec() + .expect_err("an invalid options table is a hard error"); + assert!( + error.to_string().contains(expected), + "{chunk}: expected {expected:?} in {error}" + ); + } + } +} + +/// The section's effective binding's `(temperature, max_tokens)`, read the +/// way the engine's Chat-effect sites read it. +fn effective_sampling( + set: &std::sync::Mutex, + runtime: &std::sync::Mutex, +) -> (Option, Option) { + let binding = crate::resolve_model_binding(set, runtime) + .expect("the model state is readable") + .expect("a selection resolves"); + let invocation = binding.invocation(); + ( + invocation.temperature.map(Temperature::get), + invocation.max_tokens.map(std::num::NonZeroU32::get), + ) +} + +#[test] +fn the_selection_carries_its_options_until_a_later_models_use_replaces_them() { + let (lua, set, runtime) = models_vm(); + lua.load("models.use('writer', { temperature = 0.5, max_tokens = 64 })") + .exec() + .expect("a valid options table is accepted"); + assert_eq!(effective_sampling(&set, &runtime), (Some(0.5), Some(64))); + + let (temperature_is_nil, max_tokens_is_nil): (bool, bool) = lua + .load("local h = models.use('writer'); return h.temperature == nil, h.max_tokens == nil") + .eval() + .expect("a plain models.use selects"); + assert!(temperature_is_nil && max_tokens_is_nil); + assert_eq!( + effective_sampling(&set, &runtime), + (None, None), + "a plain models.use clears the earlier options" + ); +} + +#[test] +fn a_models_get_handle_for_the_selected_label_keeps_the_role_defaults() { + let (lua, _, _) = models_vm(); + let (temperature_is_nil, max_tokens_is_nil): (bool, bool) = lua + .load( + "models.use('writer', { temperature = 0.5, max_tokens = 64 }); \ + local h = models.get('writer'); \ + return h.temperature == nil, h.max_tokens == nil", + ) + .eval() + .expect("models.get inspects the bound role"); + assert!(temperature_is_nil && max_tokens_is_nil); +} + #[test] fn models_default_takes_a_label_and_parks_the_prompt_wide_default() { let (lua, set, _) = models_vm(); diff --git a/crates/promptforge-internal/lua/src/models.rs b/crates/promptforge-internal/lua/src/models.rs index e634f6478..295a38db8 100644 --- a/crates/promptforge-internal/lua/src/models.rs +++ b/crates/promptforge-internal/lua/src/models.rs @@ -2,18 +2,20 @@ //! //! Binding is frontmatter: the run's roles arrive pre-filled from prepare in //! the shared [`ModelSet`], and the table selects among them by label. -//! `models.use` records the section's selection, `models.default` parks the -//! prompt-wide default, `models.get` inspects a bound role without selecting -//! it, and `models.infer` runs the one tool-free round through the -//! executor-installed hook. +//! `models.use` records the section's selection with its optional sampling +//! options, `models.default` parks the prompt-wide default, `models.get` +//! inspects a bound role without selecting it, and `models.infer` runs the +//! one tool-free round through the executor-installed hook. use std::num::NonZeroU32; use std::sync::Arc; use std::sync::Mutex; -use mlua::{Lua, Table}; +use mlua::{Lua, MultiValue, Table, Value}; -use promptforge_model_client::model::{ModelBinding, ModelId, ModelInvocation, ModelSet}; +use promptforge_model_client::model::{ + ModelBinding, ModelId, ModelInvocation, ModelSet, Temperature, TemperatureError, +}; use crate::alias::validate_alias; use crate::{Error, Result}; @@ -76,10 +78,139 @@ fn lock_models(set: &Mutex) -> mlua::Result, + max_tokens: Option, +} + +impl UseOptions { + /// Overrides the binding's invocation with the fields these options set. + pub(crate) fn apply(self, binding: ModelBinding) -> ModelBinding { + let mut invocation = binding.invocation().clone(); + invocation.temperature = self.temperature.or(invocation.temperature); + invocation.max_tokens = self.max_tokens.or(invocation.max_tokens); + binding.with_invocation(invocation) + } +} + +/// A `models.use` option rejection stating required versus actual. +fn invalid_option(option: &str, required: &str, actual: impl std::fmt::Display) -> mlua::Error { + mlua::Error::external(format!( + "models.use option {option} must be {required}, got {actual}" + )) +} + +/// Decodes `temperature`: a Lua integer or number, bounded only by +/// [`Temperature::new`]. +fn decode_temperature(value: &Value) -> mlua::Result { + let number = match value { + Value::Number(number) => Ok(*number), + Value::Integer(number) => + { + #[expect( + clippy::cast_precision_loss, + reason = "any magnitude that loses precision fails the [0.0, 2.0] check" + )] + Ok(*number as f64) + } + other => Err(invalid_option("temperature", "a number", other.type_name())), + }?; + Temperature::new(number).map_err(|error| match error { + TemperatureError::NotFinite => invalid_option("temperature", "finite", number), + other => mlua::Error::external(format!("models.use option {other}")), + }) +} + +/// Decodes `max_tokens`: a positive integer that fits a [`NonZeroU32`], +/// given as a Lua integer or an integral float. +fn decode_max_tokens(value: &Value) -> mlua::Result { + let count = match value { + Value::Integer(number) => u32::try_from(*number).ok(), + Value::Number(number) + if number.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(number) => + { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "integral and range checked against u32" + )] + Some(*number as u32) + } + _ => None, + }; + count.and_then(NonZeroU32::new).ok_or_else(|| { + let actual = match value { + Value::Integer(number) => number.to_string(), + Value::Number(number) => number.to_string(), + other => other.type_name().to_owned(), + }; + invalid_option("max_tokens", "an integer in [1, 4294967295]", actual) + }) +} + +/// Where `models.use` checks one option entry: non-string keys first, +/// grouped by type because their rejection names only the type, then names +/// bytewise. `pairs` walks in the state's hash-seed order, so checking in +/// this order makes the first rejection a function of the table's contents. +fn option_check_order(key: &Value) -> (bool, Vec) { + match key { + Value::String(name) => (true, name.as_bytes().to_vec()), + other => (false, other.type_name().as_bytes().to_vec()), + } +} + +/// Decodes the `models.use` arguments after the label: an optional options +/// table and nothing more. +fn parse_use_options(options: Value, rest: &MultiValue) -> mlua::Result { + if !rest.is_empty() { + return Err(mlua::Error::external(format!( + "models.use takes at most 2 arguments, got {}", + rest.len() + 2 + ))); + } + let table = match options { + Value::Nil => return Ok(UseOptions::default()), + Value::Table(table) => table, + other => { + return Err(mlua::Error::external(format!( + "models.use options must be a table, got {}", + other.type_name() + ))); + } + }; + let mut entries = table + .pairs::() + .collect::>>()?; + entries.sort_by_cached_key(|(key, _)| option_check_order(key)); + let mut parsed = UseOptions::default(); + for (key, value) in entries { + let Value::String(key) = key else { + return Err(mlua::Error::external(format!( + "models.use option names must be strings, got {}", + key.type_name() + ))); + }; + match key.to_string_lossy().as_str() { + "temperature" => parsed.temperature = Some(decode_temperature(&value)?), + "max_tokens" => parsed.max_tokens = Some(decode_max_tokens(&value)?), + other => { + return Err(mlua::Error::external(format!( + "models.use option {other:?} is unknown: expected temperature or max_tokens" + ))); + } + } + } + Ok(parsed) +} + +/// Section model-selection state: the current `models.use` label and the +/// options it set. #[derive(Debug)] pub struct ModelRuntime { - used: Option, + used: Option<(String, UseOptions)>, } impl ModelRuntime { @@ -87,24 +218,28 @@ impl ModelRuntime { ModelRuntime { used: None } } - /// The current `models.use` selection, if any. - pub(crate) fn used(&self) -> Option<&str> { - self.used.as_deref() + /// The current `models.use` label and its options, if any. + pub(crate) fn selection(&self) -> Option<(&str, UseOptions)> { + self.used + .as_ref() + .map(|(label, options)| (label.as_str(), *options)) } - /// Records a `models.use` selection, replacing any prior one: the - /// selection is read at call time, so the latest call steers the next - /// model round. - pub(crate) fn select(&mut self, alias: String) { - self.used = Some(alias); + /// Records a `models.use` selection, replacing any prior label and + /// options: the selection is read at call time, so the latest call + /// steers the next model round. + pub(crate) fn select(&mut self, alias: String, options: UseOptions) { + self.used = Some((alias, options)); } } /// Installs the `models` table into one section VM (H1 included: there is /// one install path for every section). /// -/// The table reads and writes the run's shared [`ModelSet`]: `models.use` -/// records the section's own selection in `runtime`, while +/// The table reads and writes the run's shared [`ModelSet`]: +/// `models.use(label, options?)` records the section's own selection in +/// `runtime`, with an optional `temperature` / `max_tokens` table that +/// applies to rounds on that selection and to the handle it returns, while /// `models.default(label)` records the prompt-wide default in the shared /// set - a static prompt-wide fact, conventionally called from H1 but not /// privileged to it. Re-selecting the same label is a no-op, so a shared @@ -135,22 +270,27 @@ pub(crate) fn install_models( let frozen = Arc::clone(set); let state = Arc::clone(runtime); let use_fn = lua - .create_function(move |_, label: String| -> mlua::Result { - validate_alias(&label).map_err(mlua::Error::external)?; - let binding = lock_models(&frozen)? - .binding(&label) - .cloned() - .ok_or_else(|| { - mlua::Error::external(format!( - "models.use label {label:?} is not a bound model role" - )) - })?; - let mut state = state - .lock() - .map_err(|_| mlua::Error::external("model declaration runtime was poisoned"))?; - state.select(label); - Ok(LuaModelHandle::from_binding(&binding)) - }) + .create_function( + move |_, + (label, options, rest): (String, Value, MultiValue)| + -> mlua::Result { + validate_alias(&label).map_err(mlua::Error::external)?; + let binding = lock_models(&frozen)? + .binding(&label) + .cloned() + .ok_or_else(|| { + mlua::Error::external(format!( + "models.use label {label:?} is not a bound model role" + )) + })?; + let options = parse_use_options(options, &rest)?; + let mut state = state + .lock() + .map_err(|_| mlua::Error::external("model declaration runtime was poisoned"))?; + state.select(label, options); + Ok(LuaModelHandle::from_binding(&options.apply(binding))) + }, + ) .map_err(Error::lua)?; models.set("use", use_fn).map_err(Error::lua)?; diff --git a/crates/promptforge-internal/lua/src/vm.rs b/crates/promptforge-internal/lua/src/vm.rs index 74984f1bf..e2e2951bd 100644 --- a/crates/promptforge-internal/lua/src/vm.rs +++ b/crates/promptforge-internal/lua/src/vm.rs @@ -1341,8 +1341,8 @@ pub fn current_tool_bindings( } /// Reads the section's effective model binding through the run's model view -/// without mutating the model runtime: the H2 `models.use` selection, else -/// the prompt-wide `models.default` baseline. +/// without mutating the model runtime: the H2 `models.use` selection with +/// its options applied, else the prompt-wide `models.default` baseline. /// /// # Errors /// Returns [`Error::Lua`] if the model runtime's mutex is poisoned or the @@ -1351,21 +1351,25 @@ pub fn resolve_model_binding( bindings: &dyn ModelView, runtime: &Mutex, ) -> Result> { - let used = { + let selection = { let runtime = runtime .lock() .map_err(|_| Error::Lua("model declaration runtime was poisoned".to_owned()))?; - runtime.used().map(String::from) + runtime + .selection() + .map(|(alias, options)| (alias.to_owned(), options)) }; - let alias = match used { - Some(alias) => Some(alias), - None => bindings.default()?, + let frozen = |alias: &str| -> Result { + bindings + .binding(alias)? + .ok_or_else(|| Error::Lua(format!("model alias {alias:?} has no frozen binding"))) }; - match alias { - Some(alias) => Ok(Some(bindings.binding(&alias)?.ok_or_else(|| { - Error::Lua(format!("model alias {alias:?} has no frozen binding")) - })?)), - None => Ok(None), + match selection { + Some((alias, options)) => Ok(Some(options.apply(frozen(&alias)?))), + None => match bindings.default()? { + Some(alias) => Ok(Some(frozen(&alias)?)), + None => Ok(None), + }, } } diff --git a/crates/promptforge-internal/model-client/src/model/options.rs b/crates/promptforge-internal/model-client/src/model/options.rs index fbe1df28c..dcb91ed73 100644 --- a/crates/promptforge-internal/model-client/src/model/options.rs +++ b/crates/promptforge-internal/model-client/src/model/options.rs @@ -132,6 +132,13 @@ impl ModelBinding { self } + /// Replaces the frozen per-request fields. + #[must_use] + pub fn with_invocation(mut self, invocation: ModelInvocation) -> Self { + self.invocation = invocation; + self + } + /// Returns the bound role's keyword set. #[must_use] pub fn capabilities(&self) -> &[String] { diff --git a/crates/promptforge/public-api.txt b/crates/promptforge/public-api.txt index eb30b891c..dfd17a244 100644 --- a/crates/promptforge/public-api.txt +++ b/crates/promptforge/public-api.txt @@ -558,6 +558,7 @@ pub fn promptforge::model::ModelBinding::id(&self) -> &promptforge::model::Model pub fn promptforge::model::ModelBinding::invocation(&self) -> &promptforge::model::ModelInvocation pub fn promptforge::model::ModelBinding::new(alias: impl core::convert::Into, description: impl core::convert::Into, id: promptforge::model::ModelId, invocation: promptforge::model::ModelInvocation, context: core::num::nonzero::NonZeroU32) -> Self pub fn promptforge::model::ModelBinding::with_capabilities(self, capabilities: alloc::vec::Vec) -> Self +pub fn promptforge::model::ModelBinding::with_invocation(self, invocation: promptforge::model::ModelInvocation) -> Self pub fn promptforge::model::ModelBindings::is_empty(&self) -> bool pub fn promptforge::model::ModelBindings::len(&self) -> usize pub fn promptforge::model::ModelBindings::model(&self, id: &promptforge::model::ModelId) -> core::option::Option<&promptforge::model::ModelDescriptor> diff --git a/crates/promptforge/tests/suite/prepare.rs b/crates/promptforge/tests/suite/prepare.rs index 696a7aa3f..42ec9967a 100644 --- a/crates/promptforge/tests/suite/prepare.rs +++ b/crates/promptforge/tests/suite/prepare.rs @@ -237,10 +237,10 @@ fn a_hard_keyword_the_current_model_fails_is_reported() { assert_eq!(unmet.actual, "Never"); let prompt = parse(DECLARES_NO_THINKING, "declares-no-thinking"); - // `no-thinking` against a Switchable model. + // `no-thinking` against an Always model. let (_ctx, requirements) = env.prepare( &prompt, - context("fill").model(current_model(32_000, ThinkingMode::Switchable)), + context("fill").model(current_model(32_000, ThinkingMode::Always)), ); let [unmet] = requirements.unmet_requirements.as_slice() else { panic!( @@ -251,7 +251,7 @@ fn a_hard_keyword_the_current_model_fails_is_reported() { assert_eq!(unmet.role, "triage"); assert_eq!(unmet.check, RequirementCheck::HardKeyword); assert_eq!(unmet.required, "no-thinking"); - assert_eq!(unmet.actual, "Switchable"); + assert_eq!(unmet.actual, "Always"); } // ToolBindings and slot filling: exact slots fill by identity against diff --git a/crates/shared-ui/package.json b/crates/shared-ui/package.json index 13dba3841..1b209e6f8 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 5a5fd2daf..ff7a48361 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 d575dab9e..5ddc6ac61 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 `
` element; the consumer appends it. */ readonly element: HTMLElement; /** The left text region. */ @@ -42,8 +42,8 @@ export interface StatusBarShell { setBusy(busy: boolean): void; } -/** Creates the status bar shell. */ -export function createStatusBarShell(): StatusBarShell { +/** Creates the status bar view. */ +export function createStatusBarView(): StatusBarView { const element = document.createElement("footer"); element.className = "status-bar"; element.setAttribute("role", "status"); diff --git a/crates/shared-ui/toast.ts b/crates/shared-ui/toast.ts index b406460d0..d02fd525b 100644 --- a/crates/shared-ui/toast.ts +++ b/crates/shared-ui/toast.ts @@ -1,6 +1,6 @@ // Bottom-right toast stack [Adapted: Open WebUI]: success/error/info // entries that dismiss themselves after four seconds. Shared by both -// UIs: the gateway's composition root mounts one for shell and view +// UIs: the gateway's composition root mounts one for desk and view // notifications, the workshop mounts one for update notifications. import "./toast.css"; diff --git a/crates/shared-ui/tokens.css b/crates/shared-ui/tokens.css index 768fa72c2..683007e26 100644 --- a/crates/shared-ui/tokens.css +++ b/crates/shared-ui/tokens.css @@ -74,7 +74,7 @@ --cursor-shadow-secondary: #0000001F; --cursor-shadow-tertiary: #0000000F; - /* VS Code theme surfaces (white-based alpha, for shell chrome) */ + /* VS Code theme surfaces (white-based alpha, for desk chrome) */ --bg: #181818; --bg-raised: #141414; --bg-hover: #F0F0F011; diff --git a/crates/workshop/README.md b/crates/workshop/README.md deleted file mode 100644 index 682a6dd5d..000000000 --- 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/shell/AGENTS.md b/crates/workshop/desktop/AGENTS.md similarity index 75% rename from crates/workshop/shell/AGENTS.md rename to crates/workshop/desktop/AGENTS.md index 691181cc9..a5ae9e126 100644 --- a/crates/workshop/shell/AGENTS.md +++ b/crates/workshop/desktop/AGENTS.md @@ -1,12 +1,12 @@ # workshop -This crate owns the desktop shell and its product lifecycle. +This crate owns the desktop app and its product lifecycle. - Unsafe is confined to the Windows bridge (`src/bridge.rs`): dense working COM with documented failure modes and the crate's only unsafe code; its module-level `#[expect(unsafe_code)]` is deliberate, and every unsafe block has a `// SAFETY:` comment on the immediately preceding line. Do not restructure it casually, and never edit it without running its tests. No other module contains unsafe code. - Discovery, server-spawn, health-wait, window, and webview boot failures surface loudly with their full error chain. - The running event loop degrades and reports recoverable bridge failures instead of crashing the window. -- Gateway launch is detached from the shell through the gateway-api-discovery launch contract. The shell never hosts the Gateway in-process. -- The shell does not read Gateway configuration, own the Gateway discovery file, or kill the Gateway as part of ordinary shell teardown. +- Gateway launch is detached from the desktop app through the gateway-api-discovery launch contract. The desktop app never hosts the Gateway in-process. +- The desktop app does not read Gateway configuration, own the Gateway discovery file, or kill the Gateway as part of ordinary teardown. - Quit requests authenticated shutdown only for a sidecar-attached Gateway. A LAN-configured Gateway remains running. - The gateway supervisor (`src/gateway/supervisor.rs`) stays in this crate. Porting it to a shared crate defers to the headless agent mode plan, which shapes the shared API. - Build the window capability programmatically for the exact bound port. Do not replace it with a wildcard-port capability file. diff --git a/crates/workshop/shell/Cargo.toml b/crates/workshop/desktop/Cargo.toml similarity index 88% rename from crates/workshop/shell/Cargo.toml rename to crates/workshop/desktop/Cargo.toml index 8fce4c577..e5e711cd9 100644 --- a/crates/workshop/shell/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. @@ -48,7 +48,7 @@ version = "0.38" version = "0.61" # Linux microphone permission: WebKitGTK ships with enable-media-stream off -# and a deny-all permission handler; src/bridge.rs opts the session in. +# and a deny-all permission handler; src/linux_media.rs opts the session in. [target.'cfg(target_os = "linux")'.dependencies.webkit2gtk] version = "2.0.2" @@ -69,8 +69,8 @@ gateway-api-discovery = { workspace = true, features = ["test-fixtures"] } # Not `workspace = true`: the WebView2 file-drop bridge (src/bridge.rs) is # raw COM and cannot be written without unsafe, and a workspace `forbid` # cannot be overridden by a module allow. The workspace lint set is -# mirrored with unsafe_code lowered to deny, which bridge.rs alone opts -# out of. +# mirrored with unsafe_code lowered to deny (which bridge.rs alone opts +# out of) and clippy pedantic lowered to warn. [lints.rust] unsafe_code = "deny" missing_docs = "warn" 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/desktop/icons/32x32.png b/crates/workshop/desktop/icons/32x32.png new file mode 100644 index 000000000..9a3d7a932 Binary files /dev/null and b/crates/workshop/desktop/icons/32x32.png differ diff --git a/crates/workshop/desktop/icons/64x64.png b/crates/workshop/desktop/icons/64x64.png new file mode 100644 index 000000000..39330d8be Binary files /dev/null and b/crates/workshop/desktop/icons/64x64.png differ diff --git a/crates/workshop/shell/icons/AGENTS.md b/crates/workshop/desktop/icons/AGENTS.md similarity index 64% rename from crates/workshop/shell/icons/AGENTS.md rename to crates/workshop/desktop/icons/AGENTS.md index 00b84e4b4..50ccf048c 100644 --- a/crates/workshop/shell/icons/AGENTS.md +++ b/crates/workshop/desktop/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/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/desktop/icons/icon.ico b/crates/workshop/desktop/icons/icon.ico new file mode 100644 index 000000000..875eb11cd Binary files /dev/null and b/crates/workshop/desktop/icons/icon.ico differ 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 99% rename from crates/workshop/shell/installer.nsi rename to crates/workshop/desktop/installer.nsi index 1f63cc8e1..7bc2a317c 100644 --- a/crates/workshop/shell/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/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 99% rename from crates/workshop/shell/src/bridge.rs rename to crates/workshop/desktop/src/bridge.rs index 863737980..7293c554c 100644 --- a/crates/workshop/shell/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/shell/src/config.rs b/crates/workshop/desktop/src/config.rs similarity index 88% rename from crates/workshop/shell/src/config.rs rename to crates/workshop/desktop/src/config.rs index 16e93b4f3..1c490c149 100644 --- a/crates/workshop/shell/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/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 83% rename from crates/workshop/shell/src/gateway.rs rename to crates/workshop/desktop/src/gateway.rs index e77db2480..e9ac75cf4 100644 --- a/crates/workshop/shell/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/shell/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs similarity index 97% rename from crates/workshop/shell/src/gateway/boot.rs rename to crates/workshop/desktop/src/gateway/boot.rs index 65e5a9ffe..2601586d4 100644 --- a/crates/workshop/shell/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/shell/src/gateway/identity.rs b/crates/workshop/desktop/src/gateway/identity.rs similarity index 87% rename from crates/workshop/shell/src/gateway/identity.rs rename to crates/workshop/desktop/src/gateway/identity.rs index 21f599a3c..73b56b731 100644 --- a/crates/workshop/shell/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/supervisor/launch.rs b/crates/workshop/desktop/src/gateway/supervisor/launch.rs new file mode 100644 index 000000000..b50840da2 --- /dev/null +++ b/crates/workshop/desktop/src/gateway/supervisor/launch.rs @@ -0,0 +1,151 @@ +//! Cancellable recovery launch and readiness wait. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use gateway_api_discovery::{ + CancellationToken, GatewayDiscoveryFile, LaunchDecision, Resolution, SidecarError, +}; + +use super::boot; + +/// Budget for recovery launch-race and readiness phases. +const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Delay between recovery readiness polls. +const RECOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Settles and performs a cancellable recovery launch. +pub(super) fn launch_and_attach_cancellable( + run_dir: &Path, + exe: &Path, + cancellation: &CancellationToken, +) -> anyhow::Result { + launch_and_attach_cancellable_with( + run_dir, + exe, + cancellation, + gateway_api_discovery::launch_or_attach_cancellable, + |exe, _| boot::spawn_detached(exe), + wait_for_launched_file_cancellable, + ) +} + +/// Recovery launch with each blocking phase injected. +pub(crate) fn launch_and_attach_cancellable_with( + run_dir: &Path, + exe: &Path, + cancellation: &CancellationToken, + settle: Settle, + spawn: Spawn, + wait: Wait, +) -> anyhow::Result +where + Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, + Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result, + Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, +{ + match settle(run_dir, RECOVERY_TIMEOUT, cancellation) + .context("settle the gateway launch race")? + { + LaunchDecision::Attach(file) => { + if cancellation.is_cancelled() { + anyhow::bail!("gateway attachment was cancelled"); + } + Ok(boot::RecoveryLaunch::Attached(file)) + } + LaunchDecision::Launch(lock) => { + let child_pid = run_effect_if_active(cancellation, "gateway launch", |cancellation| { + if cancellation.is_cancelled() { + anyhow::bail!("gateway launch was cancelled"); + } + spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) + })?; + let file = wait(run_dir, RECOVERY_TIMEOUT, cancellation)?; + drop(lock); + Ok(boot::RecoveryLaunch::Launched { child_pid, file }) + } + decision => anyhow::bail!("an unknown launch decision: {decision:?}"), + } +} + +/// Linearizes one externally visible recovery effect with cancellation. +pub(crate) fn run_effect_if_active( + cancellation: &CancellationToken, + phase: &'static str, + operation: impl FnOnce(&CancellationToken) -> anyhow::Result, +) -> anyhow::Result { + match cancellation.run_if_active(|| operation(cancellation)) { + Some(result) => result, + None => anyhow::bail!("{phase} was cancelled"), + } +} + +/// Waits for a launched recovery Gateway with production probes. +fn wait_for_launched_file_cancellable( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, +) -> anyhow::Result { + wait_for_launched_file_cancellable_with( + run_dir, + timeout, + cancellation, + gateway_api_discovery::wait_for_health_cancellable, + gateway_api_discovery::resolve_cancellable, + ) +} + +/// Recovery readiness wait with health and validation injected. +pub(crate) fn wait_for_launched_file_cancellable_with( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, + mut health: Health, + mut resolve: Resolve, +) -> anyhow::Result +where + Health: + FnMut(&str, Duration, &CancellationToken) -> Result<(), gateway_api_discovery::HealthError>, + Resolve: FnMut(&Path, &CancellationToken) -> Result, +{ + let deadline = Instant::now() + timeout; + loop { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if let Ok(Some(file)) = GatewayDiscoveryFile::read(run_dir) { + let remaining = deadline.saturating_duration_since(Instant::now()); + let url = format!("http://127.0.0.1:{}", file.port); + health(&url, remaining, cancellation) + .context("the launched gateway did not answer its health probe")?; + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + match resolve(run_dir, cancellation) { + Ok(Resolution::Attach(validated)) => { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + return Ok(validated); + } + Err(SidecarError::Cancelled) => { + anyhow::bail!("the launched gateway wait was cancelled"); + } + Ok(_) | Err(_) => {} + } + } + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if Instant::now() >= deadline { + anyhow::bail!( + "the launched gateway wrote no validated gateway discovery file within {timeout:?}" + ); + } + if cancellation.wait_timeout(RECOVERY_POLL_INTERVAL) { + anyhow::bail!("the launched gateway wait was cancelled"); + } + } +} diff --git a/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs b/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs new file mode 100644 index 000000000..5fdb0aaaa --- /dev/null +++ b/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs @@ -0,0 +1,161 @@ +//! Supervisor thread lifecycle and bounded shutdown. + +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use gateway_api_discovery::CancellationToken; + +use super::signals::{Completion, StopSignal}; + +/// Maximum designed supervisor shutdown latency. +const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); + +/// The running local-sidecar supervisor. +#[derive(Debug)] +pub(crate) struct GatewaySupervisor { + stop: StopSignal, + completion: Completion, + stop_bridge_completion: Completion, + thread: Option>, + stop_bridge: Option>, + publication: Option, + shutdown_budget: Duration, +} + +/// How bounded supervisor shutdown ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SupervisorShutdown { + /// The worker completed and joined normally. + Joined, + /// The worker completed but panicked. + Panicked, + /// The deadline elapsed, so the worker handle was detached. + Detached, +} + +impl GatewaySupervisor { + /// Spawns one owned supervisor thread. + #[cfg(test)] + pub(crate) fn spawn( + supervise: impl FnOnce(CancellationToken) + Send + 'static, + ) -> anyhow::Result { + Self::spawn_inner(None, SUPERVISOR_SHUTDOWN_BUDGET, supervise) + } + + pub(crate) fn spawn_with_publication( + publication: workshop_server_api::GatewayUpdater, + supervise: impl FnOnce(CancellationToken) + Send + 'static, + ) -> anyhow::Result { + Self::spawn_inner(Some(publication), SUPERVISOR_SHUTDOWN_BUDGET, supervise) + } + + #[cfg(test)] + pub(crate) fn spawn_with_budget( + shutdown_budget: Duration, + supervise: impl FnOnce(CancellationToken) + Send + 'static, + ) -> anyhow::Result { + Self::spawn_inner(None, shutdown_budget, supervise) + } + + fn spawn_inner( + publication: Option, + shutdown_budget: Duration, + supervise: impl FnOnce(CancellationToken) + Send + 'static, + ) -> anyhow::Result { + let cancellation = CancellationToken::new(); + let stop = StopSignal::default(); + let worker_stop = stop.clone(); + let completion = Completion::default(); + let worker_completion = completion.clone(); + let stop_bridge_completion = Completion::default(); + let bridge_completion = stop_bridge_completion.clone(); + let bridge_cancellation = cancellation.clone(); + let stop_bridge = std::thread::Builder::new() + .name("gateway-supervisor-stop".to_owned()) + .spawn(move || { + let _completion = bridge_completion.guard(); + worker_stop.wait(); + bridge_cancellation.cancel(); + }) + .context("spawn the gateway supervisor stop bridge")?; + let thread = match std::thread::Builder::new() + .name("gateway-supervisor".to_owned()) + .spawn(move || { + let _completion = worker_completion.guard(); + supervise(cancellation); + }) { + Ok(thread) => thread, + Err(source) => { + stop.signal(); + let error = anyhow::Error::new(source).context("spawn the gateway supervisor"); + return match stop_bridge.join() { + Ok(()) => Err(error), + Err(_) => Err(error.context( + "the gateway supervisor stop bridge panicked during spawn rollback", + )), + }; + } + }; + Ok(Self { + stop, + completion, + stop_bridge_completion, + thread: Some(thread), + stop_bridge: Some(stop_bridge), + publication, + shutdown_budget, + }) + } + + /// Revokes publication, requests stop, and waits at most one deadline. + /// + /// This is the blocking, outcome-reporting path; `Drop` only signals + /// and detaches. + pub(crate) fn shutdown(mut self) -> SupervisorShutdown { + self.stop_and_join() + } + + fn stop_and_join(&mut self) -> SupervisorShutdown { + let deadline = Instant::now() + self.shutdown_budget; + if let Some(publication) = self.publication.as_ref() { + publication.close_publication(); + } + self.stop.signal(); + let thread = self.thread.take(); + let stop_bridge = self.stop_bridge.take(); + let (Some(thread), Some(stop_bridge)) = (thread, stop_bridge) else { + return SupervisorShutdown::Joined; + }; + if !self.completion.wait_until(deadline) + || !self.stop_bridge_completion.wait_until(deadline) + { + drop(thread); + drop(stop_bridge); + return SupervisorShutdown::Detached; + } + match (thread.join(), stop_bridge.join()) { + (Ok(()), Ok(())) => SupervisorShutdown::Joined, + (Err(_), _) | (_, Err(_)) => SupervisorShutdown::Panicked, + } + } + + #[cfg(test)] + pub(crate) fn wake_completion_for_test(&self) { + self.completion.wake(); + } +} + +impl Drop for GatewaySupervisor { + fn drop(&mut self) { + // `shutdown()` is the bounded, outcome-reporting path. Drop can + // neither wait nor report, so it revokes publication, signals the + // stop, and detaches both threads; the worker captures only owned + // state, so a detached thread finishes on its own. + if let Some(publication) = self.publication.as_ref() { + publication.close_publication(); + } + self.stop.signal(); + drop(self.thread.take()); + drop(self.stop_bridge.take()); + } +} diff --git a/crates/workshop/desktop/src/gateway/supervisor/mod.rs b/crates/workshop/desktop/src/gateway/supervisor/mod.rs new file mode 100644 index 000000000..bf21018aa --- /dev/null +++ b/crates/workshop/desktop/src/gateway/supervisor/mod.rs @@ -0,0 +1,234 @@ +//! Continuous local Gateway supervision and recovery. + +use std::path::Path; +use std::time::Duration; + +use anyhow::Context as _; +use gateway_api_discovery::{CancellationToken, Resolution, SidecarError, ValidatedConnection}; + +use super::boot; +use super::identity::GatewayAttachment; + +mod launch; +mod lifecycle; +mod recovery; +mod signals; + +#[cfg(test)] +pub(super) use launch::{ + launch_and_attach_cancellable_with, run_effect_if_active, + wait_for_launched_file_cancellable_with, +}; +pub(crate) use lifecycle::{GatewaySupervisor, SupervisorShutdown}; +pub(super) use recovery::RecoveryIdentity; +pub(crate) use recovery::{RecoveryCandidate, RecoveryOwnership}; + +use launch::launch_and_attach_cancellable; + +/// Healthy-sidecar supervision cadence. +const SUPERVISION_INTERVAL: Duration = Duration::from_secs(5); + +/// First delay after a failed re-resolution or relaunch. +const SUPERVISION_BASE_DELAY: Duration = Duration::from_millis(250); + +/// Ceiling on repeated sidecar recovery attempts. +pub(super) const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); + +/// One sidecar liveness observation. +pub(super) enum SupervisionProbe { + /// Another process already published a live replacement. + Replacement(Identity), + /// No live local Gateway is currently discoverable. + Missing, +} + +/// A validated identity retained across supervision classifications. +pub(super) trait SupervisedGatewayIdentity { + /// Whether both values prove the same process boot. + fn same_boot(&self, other: &Self) -> bool; + + /// Disarms cleanup after this identity becomes authoritative. + fn publication_succeeded(&mut self) {} + + /// Shuts down an unpublished owned child through the explicit, + /// error-reporting path. Identities that own no child do nothing. + fn shutdown_unpublished(self) + where + Self: Sized, + { + } +} + +/// Starts runtime supervision only for a gateway discovery file sidecar. +/// +/// # Errors +/// Returns an error when the supervisor cannot locate its runtime paths or +/// spawn its owned thread. +pub(crate) fn supervise( + attachment: &GatewayAttachment, + updater: workshop_server_api::GatewayUpdater, +) -> anyhow::Result> { + let Some(initial) = attachment.sidecar_identity().cloned() else { + return Ok(None); + }; + let run_dir = + gateway_api_discovery::default_run_dir().context("locate the sidecar run directory")?; + let exe_dir = std::env::current_exe() + .context("locate the executable")? + .parent() + .map(Path::to_path_buf) + .context("the executable has no parent directory")?; + let sibling = boot::sibling_gateway(&exe_dir); + let supervisor_publication = updater.clone(); + GatewaySupervisor::spawn_with_publication(supervisor_publication, move |cancellation| { + run_supervision( + RecoveryIdentity::Stable(initial), + |_, cancellation| match gateway_api_discovery::resolve_cancellable( + &run_dir, + cancellation, + ) { + Ok(Resolution::Attach(file)) => { + match ValidatedConnection::validate_cancellable(file, cancellation) { + Ok(identity) => { + SupervisionProbe::Replacement(RecoveryIdentity::Stable(identity)) + } + Err(error) => { + eprintln!("could not retain the replacement gateway identity: {error}"); + SupervisionProbe::Missing + } + } + } + Ok(_) | Err(SidecarError::Cancelled) => SupervisionProbe::Missing, + Err(error) => { + eprintln!("could not re-resolve the local gateway: {error}"); + SupervisionProbe::Missing + } + }, + |cancellation| { + let exe = sibling.as_deref().context( + "the local gateway disappeared and no sibling gateway executable is installed", + )?; + let recovery = launch_and_attach_cancellable(&run_dir, exe, cancellation)?; + validate_recovery(recovery, cancellation) + }, + |identity, cancellation| { + if cancellation.is_cancelled() { + anyhow::bail!("gateway publication was cancelled"); + } + if updater.publication_closed() { + anyhow::bail!("gateway publication is closed"); + } + if updater + .replace_sidecar_cancellable(identity.validated(), cancellation) + .context("publish the replacement gateway endpoint")? + { + Ok(()) + } else { + anyhow::bail!("gateway publication was cancelled") + } + }, + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + }) + .map(Some) +} + +/// Validates a recovery result and authenticates child ownership by exact pid. +pub(super) fn validate_recovery( + recovery: boot::RecoveryLaunch, + cancellation: &CancellationToken, +) -> anyhow::Result { + let (child_pid, file) = match recovery { + boot::RecoveryLaunch::Attached(file) => (None, file), + boot::RecoveryLaunch::Launched { child_pid, file } => (Some(child_pid), file), + }; + let validated = ValidatedConnection::validate_cancellable(file, cancellation) + .context("retain the recovered gateway identity")?; + Ok(match child_pid { + Some(child_pid) => match RecoveryCandidate::authenticate(child_pid, validated) { + RecoveryOwnership::Owned(candidate) => RecoveryIdentity::Candidate(candidate), + RecoveryOwnership::Unowned(unowned) => RecoveryIdentity::Stable(unowned), + }, + None => RecoveryIdentity::Stable(validated), + }) +} + +/// Runs the supervision state machine with I/O injected for tests. +pub(super) fn run_supervision( + mut current: Identity, + mut probe: Probe, + mut recover: Recover, + mut publish: Publish, + mut wait: Wait, + cancellation: &CancellationToken, +) where + Identity: SupervisedGatewayIdentity, + Probe: FnMut(&Identity, &CancellationToken) -> SupervisionProbe, + Recover: FnMut(&CancellationToken) -> Result, + Publish: FnMut(&Identity, &CancellationToken) -> Result<(), Error>, + Wait: FnMut(Duration, &CancellationToken) -> bool, + Error: std::fmt::Display, +{ + let mut retry_delay = SUPERVISION_BASE_DELAY; + loop { + if cancellation.is_cancelled() { + return; + } + let observation = probe(¤t, cancellation); + if cancellation.is_cancelled() { + return; + } + match observation { + SupervisionProbe::Replacement(identity) if identity.same_boot(¤t) => { + retry_delay = SUPERVISION_BASE_DELAY; + if wait(SUPERVISION_INTERVAL, cancellation) { + return; + } + continue; + } + SupervisionProbe::Replacement(mut identity) => match publish(&identity, cancellation) { + Ok(()) => { + identity.publication_succeeded(); + if cancellation.is_cancelled() { + return; + } + current = identity; + retry_delay = SUPERVISION_BASE_DELAY; + continue; + } + Err(error) => { + eprintln!("could not publish a replacement local gateway: {error}"); + } + }, + SupervisionProbe::Missing => match recover(cancellation) { + Ok(_) if cancellation.is_cancelled() => return, + Ok(mut identity) => match publish(&identity, cancellation) { + Ok(()) => { + identity.publication_succeeded(); + if cancellation.is_cancelled() { + return; + } + current = identity; + retry_delay = SUPERVISION_BASE_DELAY; + continue; + } + Err(error) => { + eprintln!("could not publish a replacement local gateway: {error}"); + // A recovered child this process launched stays + // unpublished, so it is shut down through the + // explicit, error-reporting path. + identity.shutdown_unpublished(); + } + }, + Err(error) => { + eprintln!("could not recover the local gateway: {error}"); + } + }, + } + if cancellation.is_cancelled() || wait(retry_delay, cancellation) { + return; + } + retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); + } +} diff --git a/crates/workshop/desktop/src/gateway/supervisor/recovery.rs b/crates/workshop/desktop/src/gateway/supervisor/recovery.rs new file mode 100644 index 000000000..bd43ad135 --- /dev/null +++ b/crates/workshop/desktop/src/gateway/supervisor/recovery.rs @@ -0,0 +1,136 @@ +//! Recovery-candidate ownership and identity classification. + +use std::time::{Duration, Instant}; + +use gateway_api_discovery::{ShutdownError, ValidatedConnection}; + +use super::super::identity::same_gateway_identity; +use super::SupervisedGatewayIdentity; + +/// Separate bound for authenticated cleanup of an unpublished owned child. +const LATE_CHILD_SHUTDOWN_BUDGET: Duration = Duration::from_secs(1); + +/// A validated recovery process whose pid proves it is the child we spawned. +#[derive(Debug)] +pub(crate) struct RecoveryCandidate { + child_pid: u32, + validated: ValidatedConnection, + published: bool, +} + +pub(crate) enum RecoveryOwnership { + Owned(RecoveryCandidate), + Unowned(ValidatedConnection), +} + +impl RecoveryCandidate { + /// Claims cleanup authority only when validation names the spawned pid. + pub(crate) fn authenticate( + child_pid: u32, + validated: ValidatedConnection, + ) -> RecoveryOwnership { + if validated.pid() != child_pid { + return RecoveryOwnership::Unowned(validated); + } + RecoveryOwnership::Owned(Self { + child_pid, + validated, + published: false, + }) + } + + pub(crate) fn validated(&self) -> &ValidatedConnection { + &self.validated + } + + pub(crate) fn published(&mut self) { + self.published = true; + } + + /// Shuts down the unpublished recovered child within the late-child + /// budget. + /// + /// This is the blocking, error-reporting path; `Drop` only signals on + /// a detached thread. The drop signal is disarmed either way: the + /// caller receives the outcome, so a failed delivery is reported here + /// rather than retried silently. + pub(crate) fn shutdown(mut self) -> Result<(), ShutdownError> { + if self.published { + return Ok(()); + } + debug_assert_eq!(self.child_pid, self.validated.pid()); + self.published = true; + let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; + gateway_api_discovery::request_shutdown_before(&self.validated, deadline) + } +} + +impl Drop for RecoveryCandidate { + fn drop(&mut self) { + if self.published { + return; + } + debug_assert_eq!(self.child_pid, self.validated.pid()); + // Drop can neither block nor report: the bounded authenticated + // request runs on a detached thread, so a missed explicit + // `shutdown()` still signals the unpublished gateway process. + let validated = self.validated.clone(); + let signalled = std::thread::Builder::new() + .name("gateway-late-child-shutdown".to_owned()) + .spawn(move || { + let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; + if let Err(error) = + gateway_api_discovery::request_shutdown_before(&validated, deadline) + { + // The detached signal has no error return channel, so + // diagnostics are the only place this cleanup failure + // can surface. + eprintln!("could not shut down an unpublished recovered gateway: {error}"); + } + }); + if let Err(error) = signalled { + eprintln!("could not signal an unpublished recovered gateway: {error}"); + } + } +} + +impl SupervisedGatewayIdentity for ValidatedConnection { + fn same_boot(&self, other: &Self) -> bool { + same_gateway_identity(self, other) + } +} + +#[derive(Debug)] +pub(crate) enum RecoveryIdentity { + Stable(ValidatedConnection), + Candidate(RecoveryCandidate), +} + +impl RecoveryIdentity { + pub(super) fn validated(&self) -> &ValidatedConnection { + match self { + Self::Stable(validated) => validated, + Self::Candidate(candidate) => candidate.validated(), + } + } +} + +impl SupervisedGatewayIdentity for RecoveryIdentity { + fn same_boot(&self, other: &Self) -> bool { + self.validated().same_boot(other.validated()) + } + + fn publication_succeeded(&mut self) { + if let Self::Candidate(candidate) = self { + candidate.published(); + } + } + + fn shutdown_unpublished(self) { + if let Self::Candidate(candidate) = self + && let Err(error) = candidate.shutdown() + { + eprintln!("could not shut down an unpublished recovered gateway: {error}"); + } + } +} diff --git a/crates/workshop/desktop/src/gateway/supervisor/signals.rs b/crates/workshop/desktop/src/gateway/supervisor/signals.rs new file mode 100644 index 000000000..374242065 --- /dev/null +++ b/crates/workshop/desktop/src/gateway/supervisor/signals.rs @@ -0,0 +1,112 @@ +//! Stop and completion signals for bounded supervisor shutdown. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; +use std::time::Instant; + +/// A stop request that never waits for in-progress supervisor work. +#[derive(Clone, Debug, Default)] +pub(super) struct StopSignal { + state: Arc, +} + +#[derive(Debug, Default)] +struct StopState { + requested: AtomicBool, + waiter: Mutex<()>, + wake: Condvar, +} + +impl StopSignal { + pub(super) fn signal(&self) { + let waiter = self + .state + .waiter + .lock() + .unwrap_or_else(PoisonError::into_inner); + self.state.requested.store(true, Ordering::SeqCst); + self.state.wake.notify_all(); + drop(waiter); + } + + pub(super) fn wait(&self) { + if self.state.requested.load(Ordering::SeqCst) { + return; + } + let waiter = self + .state + .waiter + .lock() + .unwrap_or_else(PoisonError::into_inner); + drop( + self.state + .wake + .wait_while(waiter, |()| !self.state.requested.load(Ordering::SeqCst)) + .unwrap_or_else(PoisonError::into_inner), + ); + } +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Completion { + state: Arc, +} + +#[derive(Debug, Default)] +struct CompletionState { + finished: Mutex, + wake: Condvar, +} + +impl Completion { + pub(super) fn guard(&self) -> CompletionGuard { + CompletionGuard(self.clone()) + } + + pub(super) fn wait_until(&self, deadline: Instant) -> bool { + let mut finished = self + .state + .finished + .lock() + .unwrap_or_else(PoisonError::into_inner); + loop { + if *finished { + return true; + } + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if remaining.is_zero() { + return false; + } + let (next, timeout) = self + .state + .wake + .wait_timeout(finished, remaining) + .unwrap_or_else(PoisonError::into_inner); + finished = next; + if timeout.timed_out() && !*finished { + return false; + } + } + } + + #[cfg(test)] + pub(super) fn wake(&self) { + self.state.wake.notify_all(); + } +} + +pub(super) struct CompletionGuard(Completion); + +impl Drop for CompletionGuard { + fn drop(&mut self) { + *self + .0 + .state + .finished + .lock() + .unwrap_or_else(PoisonError::into_inner) = true; + self.0.state.wake.notify_all(); + } +} 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 99% rename from crates/workshop/shell/src/gateway/tests/boot.rs rename to crates/workshop/desktop/src/gateway/tests/boot.rs index dad90346b..08c9d15fa 100644 --- a/crates/workshop/shell/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/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 98% rename from crates/workshop/shell/src/gateway/tests/recovery.rs rename to crates/workshop/desktop/src/gateway/tests/recovery.rs index d618c3ca4..74f3b8662 100644 --- a/crates/workshop/shell/src/gateway/tests/recovery.rs +++ b/crates/workshop/desktop/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/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 95% rename from crates/workshop/shell/src/menu.rs rename to crates/workshop/desktop/src/menu.rs index 7fb02846f..3a927fa50 100644 --- a/crates/workshop/shell/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/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 94% rename from crates/workshop/shell/src/quit.rs rename to crates/workshop/desktop/src/quit.rs index 582a09c18..9c162d517 100644 --- a/crates/workshop/shell/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/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 98% rename from crates/workshop/shell/src/window_state.rs rename to crates/workshop/desktop/src/window_state.rs index b262b99cd..bf4f93b75 100644 --- a/crates/workshop/shell/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/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/crates/workshop/gateway/Cargo.toml b/crates/workshop/gateway/Cargo.toml index 95a631fc2..65df27f3e 100644 --- a/crates/workshop/gateway/Cargo.toml +++ b/crates/workshop/gateway/Cargo.toml @@ -6,10 +6,10 @@ 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"] +test-fixtures = ["dep:tempfile", "workshop-support/test-fixtures"] [dependencies] arc-swap.workspace = true @@ -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 @@ -42,6 +40,10 @@ axum.workspace = true gateway-api-discovery = { workspace = true, features = ["test-fixtures"] } tempfile.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +# The gateway's tests call `workshop_support::fixtures::serve`; enabling the +# feature here (not only through the crate's own `test-fixtures` feature) +# keeps `cargo test -p workshop-gateway` compiling without `--all-features`. +workshop-support = { workspace = true, features = ["test-fixtures"] } [lints] workspace = true diff --git a/crates/workshop/gateway/src/gateway.rs b/crates/workshop/gateway/src/gateway.rs index d32b6bb8e..27eb21b92 100644 --- a/crates/workshop/gateway/src/gateway.rs +++ b/crates/workshop/gateway/src/gateway.rs @@ -16,8 +16,8 @@ mod sse; pub mod socket; pub use events::{ - CacheEvent, CacheResponse, ForwardedResponse, GatewayResponse, SsePayloadStream, SwitchOutcome, - SwitchResponse, + CacheEvent, CacheResponse, ForwardedResponse, GatewayResponse, SsePayloadStream, + SwitchProfileBody, SwitchResponse, }; pub use progress::ProgressStream; pub use socket::GatewayRealtimeSocket; @@ -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] @@ -339,7 +339,7 @@ impl GatewayClient { if !answer.status.is_success() { return Ok(SwitchResponse::Buffered(answer)); } - serde_json::from_slice::(&answer.body) + serde_json::from_slice::(&answer.body) .map(SwitchResponse::Selected) .map_err(|source| GatewayError::Malformed { message: "the switch-profile answer is not the outcome document".to_owned(), diff --git a/crates/workshop/gateway/src/gateway/events.rs b/crates/workshop/gateway/src/gateway/events.rs index bfec85a21..c780d8eb6 100644 --- a/crates/workshop/gateway/src/gateway/events.rs +++ b/crates/workshop/gateway/src/gateway/events.rs @@ -115,7 +115,7 @@ pub enum CacheEvent { /// The gateway's answer to a profile selection, `POST /admin/switch-profile`. /// /// An accepted selection answers one JSON document decoding as -/// [`SwitchOutcome`]: the gateway persisted the selection and reports +/// [`SwitchProfileBody`]: the gateway persisted the selection and reports /// whether it must restart to load it. A refusal (bad auth, a malformed or /// undefined name) is buffered rather than reported as an error, matching /// the relay contract of the other client methods. @@ -123,7 +123,7 @@ pub enum CacheEvent { #[non_exhaustive] pub enum SwitchResponse { /// The gateway accepted and persisted the selection. - Selected(SwitchOutcome), + Selected(SwitchProfileBody), /// A refusal, buffered: the gateway's error envelope. Buffered(GatewayResponse), @@ -131,7 +131,7 @@ pub enum SwitchResponse { /// The body of an accepted profile selection. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct SwitchOutcome { +pub struct SwitchProfileBody { /// The selection now persisted: a profile name, or `None` for no /// profile. #[serde(default)] diff --git a/crates/workshop/gateway/src/gateway/tests.rs b/crates/workshop/gateway/src/gateway/tests.rs index 8515c6c33..4b3a76771 100644 --- a/crates/workshop/gateway/src/gateway/tests.rs +++ b/crates/workshop/gateway/src/gateway/tests.rs @@ -12,15 +12,7 @@ mod timeouts; /// Binds `app` on a free loopback port and returns its base URL. pub(super) async fn serve(app: axum::Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(app).await; format!("http://{addr}") } diff --git a/crates/workshop/gateway/src/gateway/tests/switch.rs b/crates/workshop/gateway/src/gateway/tests/switch.rs index 82605c134..86bf74279 100644 --- a/crates/workshop/gateway/src/gateway/tests/switch.rs +++ b/crates/workshop/gateway/src/gateway/tests/switch.rs @@ -46,7 +46,7 @@ async fn a_named_selection_posts_the_name_and_decodes_the_outcome() { }; assert_eq!( outcome, - SwitchOutcome { + SwitchProfileBody { profile: Some("beta".to_string()), restart_required: true, } @@ -75,7 +75,7 @@ async fn the_no_profile_selection_posts_null_and_decodes_a_null_profile() { }; assert_eq!( outcome, - SwitchOutcome { + SwitchProfileBody { profile: None, restart_required: false, } diff --git a/crates/workshop/gateway/src/gateway_binding.rs b/crates/workshop/gateway/src/gateway_binding.rs index fc3504a88..d6c1016f4 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/gateway_progress.rs b/crates/workshop/gateway/src/gateway_progress.rs index c0cf70d28..473edcff2 100644 --- a/crates/workshop/gateway/src/gateway_progress.rs +++ b/crates/workshop/gateway/src/gateway_progress.rs @@ -17,7 +17,6 @@ //! appeared still waits out its minimum visible time first, so a //! dropped stream cannot flash it. -#[path = "gateway_progress-presenter.rs"] mod presenter; use std::future::Future; @@ -29,7 +28,8 @@ use tokio::time::Instant; use workshop_registry::Push; -use crate::gateway_binding::GatewayBinding; +use crate::gateway::ProgressStream; +use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; use crate::heartbeat::GatewayHealth; #[cfg(test)] @@ -127,10 +127,10 @@ struct Signals<'a> { gateway_changed: &'a mut watch::Receiver, } -/// The subscription loop: idle while the gateway is unreachable, and while -/// reachable hold one subscription whose snapshots drive the presenter. -/// Every wait goes through [`until`], so the stop signal wins each select -/// and the presenter's deadlines keep ticking between subscriptions. +/// The subscription loop, in named phases: idle while unreachable, +/// subscribe, drive the subscription, and recover from its end. Every +/// wait goes through [`until`], so the stop signal wins each select and +/// the presenter's deadlines keep ticking between subscriptions. async fn run( gateway: &GatewayBinding, push: &Push, @@ -147,84 +147,132 @@ async fn run( }; let mut presenter = Presenter::new(timing.policy); loop { - while !*signals.reachable.borrow_and_update() { - match until( - std::future::pending::<()>(), - &mut signals, - &mut presenter, - push, - ) - .await - { - Err(Ended::Stop) => return, - Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} - } + if let Err(Ended::Stop) = idle_until_reachable(&mut signals, &mut presenter, push).await { + return; } let snapshot = gateway.snapshot(); - let stream = match until( - snapshot.client().subscribe_progress(), - &mut signals, - &mut presenter, - push, - ) - .await - { + let stream = match subscribe(&snapshot, timing, &mut signals, &mut presenter, push).await { + Ok(stream) => stream, Err(Ended::Stop) => return, Err(Ended::Lost | Ended::Rebind) => continue, - Ok(Ok(stream)) => stream, - Ok(Err(error)) => { - tracing::warn!(%error, "gateway progress subscription failed"); - match until( - tokio::time::sleep(timing.resubscribe_delay), - &mut signals, - &mut presenter, - push, - ) - .await - { - Err(Ended::Stop) => return, - Err(Ended::Lost | Ended::Rebind) | Ok(()) => continue, - } - } }; - tokio::pin!(stream); - let ended = loop { - match until(stream.next(), &mut signals, &mut presenter, push).await { - Err(ended) => break ended, - Ok(Some(Ok(snapshot))) => presenter.apply(snapshot, Instant::now(), push), - // One malformed snapshot or a terminal read failure; the - // stream itself decides which by continuing or ending. - Ok(Some(Err(error))) => { - tracing::warn!(%error, "gateway progress snapshot skipped"); - } - Ok(None) => break Ended::Lost, - } - }; - match ended { - Ended::Stop => return, - // The subscription is gone, so its progress is stale: the bar - // rests (after any minimum-visible hold) until the next - // subscription reports work. - Ended::Rebind => { - presenter.detach(Instant::now(), push); - continue; - } - Ended::Lost => presenter.detach(Instant::now(), push), + let ended = drive_stream(stream, &mut signals, &mut presenter, push).await; + if let Err(Ended::Stop) = recover(ended, timing, &mut signals, &mut presenter, push).await { + return; + } + } +} + +/// Idles while the gateway reads unreachable, waking on any control +/// signal. Returns to proceed with a subscription, or on stop. +async fn idle_until_reachable( + signals: &mut Signals<'_>, + presenter: &mut Presenter, + push: &Push, +) -> Result<(), Ended> { + while !*signals.reachable.borrow_and_update() { + match until(std::future::pending::<()>(), signals, presenter, push).await { + Err(Ended::Stop) => return Err(Ended::Stop), + Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} + } + } + Ok(()) +} + +/// Opens one progress subscription, waiting out the resubscribe delay and +/// looping around when the endpoint declines. Returns the stream, or an +/// [`Ended`] telling the loop to stop or loop around again. +async fn subscribe( + snapshot: &GatewaySnapshot, + timing: Timing, + signals: &mut Signals<'_>, + presenter: &mut Presenter, + push: &Push, +) -> Result { + match until( + snapshot.client().subscribe_progress(), + signals, + presenter, + push, + ) + .await + { + Err(ended) => return Err(ended), + Ok(Ok(stream)) => return Ok(stream), + Ok(Err(error)) => { + tracing::warn!(%error, "gateway progress subscription failed"); } - if *signals.reachable.borrow_and_update() { - match until( - tokio::time::sleep(timing.resubscribe_delay), - &mut signals, - &mut presenter, - push, - ) - .await - { - Err(Ended::Stop) => return, - Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} + } + // A declined subscription waits out the resubscribe delay, then loops + // around to try again. + match until( + tokio::time::sleep(timing.resubscribe_delay), + signals, + presenter, + push, + ) + .await + { + Err(Ended::Stop) => Err(Ended::Stop), + Err(Ended::Lost | Ended::Rebind) | Ok(()) => Err(Ended::Lost), + } +} + +/// Drives one subscription: every snapshot reaches the presenter, a +/// malformed snapshot is skipped, and the stream's own end is reported. +async fn drive_stream( + stream: ProgressStream, + signals: &mut Signals<'_>, + presenter: &mut Presenter, + push: &Push, +) -> Ended { + tokio::pin!(stream); + loop { + match until(stream.next(), signals, presenter, push).await { + Err(ended) => break ended, + Ok(Some(Ok(snapshot))) => presenter.apply(snapshot, Instant::now(), push), + // One malformed snapshot or a terminal read failure; the + // stream itself decides which by continuing or ending. + Ok(Some(Err(error))) => { + tracing::warn!(%error, "gateway progress snapshot skipped"); } + Ok(None) => break Ended::Lost, + } + } +} + +/// Handles a subscription's end: the bar rests because a subscription the +/// workshop can no longer hear is stale, and - while still reachable - the +/// loop waits out the resubscribe delay before subscribing again. +async fn recover( + ended: Ended, + timing: Timing, + signals: &mut Signals<'_>, + presenter: &mut Presenter, + push: &Push, +) -> Result<(), Ended> { + match ended { + Ended::Stop => return Err(Ended::Stop), + Ended::Rebind => { + presenter.detach(Instant::now(), push); + return Ok(()); + } + Ended::Lost => presenter.detach(Instant::now(), push), + } + if *signals.reachable.borrow_and_update() { + match until( + tokio::time::sleep(timing.resubscribe_delay), + signals, + presenter, + push, + ) + .await + { + Err(Ended::Stop) => return Err(Ended::Stop), + Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} } } + Ok(()) } /// Awaits `future` with the control signals and the presenter's next @@ -264,5 +312,4 @@ async fn wake_at(at: Option) { } #[cfg(test)] -#[path = "gateway_progress-tests.rs"] mod tests; diff --git a/crates/workshop/gateway/src/gateway_progress-presenter.rs b/crates/workshop/gateway/src/gateway_progress/presenter.rs similarity index 100% rename from crates/workshop/gateway/src/gateway_progress-presenter.rs rename to crates/workshop/gateway/src/gateway_progress/presenter.rs diff --git a/crates/workshop/gateway/src/gateway_progress-tests.rs b/crates/workshop/gateway/src/gateway_progress/tests.rs similarity index 96% rename from crates/workshop/gateway/src/gateway_progress-tests.rs rename to crates/workshop/gateway/src/gateway_progress/tests.rs index 15e0ca9ec..64454231b 100644 --- a/crates/workshop/gateway/src/gateway_progress-tests.rs +++ b/crates/workshop/gateway/src/gateway_progress/tests.rs @@ -37,15 +37,7 @@ const FAST_TIMING: Timing = Timing { /// Binds `app` as a mock gateway on a free loopback port and returns its /// base URL. async fn spawn_gateway(app: axum::Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(app).await; format!("http://{addr}") } @@ -369,7 +361,5 @@ async fn a_reconnect_rests_the_bar_and_resubscribes_once() { subscriber.shutdown().await; } -#[path = "gateway_progress-tests-presenter.rs"] mod presenter; -#[path = "gateway_progress-tests-recovery.rs"] mod recovery; diff --git a/crates/workshop/gateway/src/gateway_progress-tests-presenter.rs b/crates/workshop/gateway/src/gateway_progress/tests/presenter.rs similarity index 100% rename from crates/workshop/gateway/src/gateway_progress-tests-presenter.rs rename to crates/workshop/gateway/src/gateway_progress/tests/presenter.rs diff --git a/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs b/crates/workshop/gateway/src/gateway_progress/tests/recovery.rs similarity index 100% rename from crates/workshop/gateway/src/gateway_progress-tests-recovery.rs rename to crates/workshop/gateway/src/gateway_progress/tests/recovery.rs diff --git a/crates/workshop/gateway/src/handles.rs b/crates/workshop/gateway/src/handles.rs index 6a663e503..bbdc8a863 100644 --- a/crates/workshop/gateway/src/handles.rs +++ b/crates/workshop/gateway/src/handles.rs @@ -48,17 +48,29 @@ pub fn register(registry: &Registry, handles: GatewayHandles) -> Registration { registry.register_state::(Arc::new(handles)) } +/// The gateway subsystem's background-task registration guards: the +/// reachability heartbeat and the gateway progress subscriber. Dropping +/// them deregisters the tasks. +#[derive(Debug)] +#[must_use = "dropping the registrations deregisters the tasks"] +pub struct GatewayTaskRegistrations { + /// The reachability heartbeat. + pub heartbeat: Registration, + /// The gateway progress subscriber. + pub subscriber: 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( registry: &Registry, handles: &GatewayHandles, backoff: ReconnectBackoff, -) -> (Registration, Registration) { +) -> GatewayTaskRegistrations { let heartbeat = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ let registry = registry.clone(); let binding = handles.binding().clone(); @@ -83,5 +95,8 @@ pub fn register_tasks( ShutdownHandle::new(move || task.shutdown()) } }))); - (heartbeat, subscriber) + GatewayTaskRegistrations { + heartbeat, + subscriber, + } } diff --git a/crates/workshop/gateway/src/heartbeat.rs b/crates/workshop/gateway/src/heartbeat.rs index 158447c20..4bc756aa2 100644 --- a/crates/workshop/gateway/src/heartbeat.rs +++ b/crates/workshop/gateway/src/heartbeat.rs @@ -210,6 +210,19 @@ struct RefreshState { selection_restored: bool, } +/// Why a phase of the probe loop ended before it completed. +enum Phase { + /// The stop signal fired, or a control channel closed: end the loop. + Stop, + /// The gateway binding was replaced: reset iteration state and + /// restart the loop from its top. + Rebind, +} + +/// The probe loop, in named phases: wait out the probe interval, probe +/// the health endpoint, announce a transition, refresh stale menu sources, +/// and restore the selection. The stop signal wins every select, and a +/// replaced binding restarts the iteration from the top. async fn run( gateway: &GatewayBinding, push: &Push, @@ -222,101 +235,54 @@ async fn run( let mut refresh = RefreshState::default(); let mut gateway_changed = gateway.subscribe(); loop { - // The first probe runs immediately; every later one waits here. - if let Some(reachable) = last { - let wait = if reachable { - interval - } else if let Some(delay) = backoff.next_delay() { - delay - } else { - push.push_failure( - "Gateway reconnect stopped", - "the reconnect budget is exhausted; restart the workshop to retry", - Activity::General, - ); - break; - }; - tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } - last = None; - refresh = RefreshState::default(); - continue; - } - () = tokio::time::sleep(wait) => {} + match await_probe_interval(last, interval, backoff, push, stop, &mut gateway_changed).await + { + Ok(()) => {} + Err(Phase::Stop) => break, + Err(Phase::Rebind) => { + last = None; + refresh = RefreshState::default(); + continue; } } let snapshot = gateway.snapshot(); let generation = snapshot.generation(); - let reachable = tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } + let reachable = match probe(snapshot.as_ref(), stop, &mut gateway_changed).await { + Ok(reachable) => reachable, + Err(Phase::Stop) => break, + Err(Phase::Rebind) => { last = None; refresh = RefreshState::default(); continue; } - reachable = snapshot.client().health() => reachable, }; if gateway.generation() != generation { last = None; refresh = RefreshState::default(); continue; } - health.publish(reachable); - let transitioned = last != Some(reachable); - last = Some(reachable); - if transitioned { - // The menu recomputes chat_ready from reachability, so the - // verdict feeds it before any slower refresh work below. - push.menu().set_gateway_reachable(reachable); - if reachable { - push.push_status_update( - CONNECTED_LABEL, - "the gateway answers its health probe", - Activity::General, - ); - } else { - push.push_status_update( - UNREACHABLE_LABEL, - UNREACHABLE_DESCRIPTION, - Activity::General, - ); - } - } + announce_transition(push, health, reachable, &mut last); if !reachable { refresh = RefreshState::default(); continue; } if !refresh.profiles_ready || !refresh.catalog_ready { - // All menu state is server-owned and reaches the UI via - // socket pushes - the UI fetches nothing on boot - so every - // transition into reachable, boot's first probe included, - // (re)populates the profile state and the model catalog. - // Healthy ticks independently repeat either refresh until both - // sources are populated, because health and one ready source do - // not imply the other source is ready. The interval above bounds - // retries and keeps this from becoming a busy loop. - tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } + match refresh_sources( + snapshot.as_ref(), + push, + &mut refresh, + stop, + &mut gateway_changed, + ) + .await + { + Ok(()) => {} + Err(Phase::Stop) => break, + Err(Phase::Rebind) => { last = None; refresh = RefreshState::default(); continue; } - () = refresh_incomplete_sources( - &snapshot, - push, - &mut refresh, - ) => {} } } if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { @@ -331,6 +297,124 @@ async fn run( } } +/// The wait before a probe: skipped on the first, `interval` while the +/// gateway answered last time, and the backoff's next delay while it did +/// not, ending the loop when the backoff's budget exhausts. +async fn await_probe_interval( + last: Option, + interval: Duration, + backoff: &ReconnectBackoff, + push: &Push, + stop: &mut oneshot::Receiver<()>, + gateway_changed: &mut watch::Receiver, +) -> Result<(), Phase> { + let Some(reachable) = last else { + return Ok(()); + }; + let wait = if reachable { + interval + } else if let Some(delay) = backoff.next_delay() { + delay + } else { + push.push_failure( + "Gateway reconnect stopped", + "the reconnect budget is exhausted; restart the workshop to retry", + Activity::General, + ); + return Err(Phase::Stop); + }; + tokio::select! { + _ = &mut *stop => Err(Phase::Stop), + changed = gateway_changed.changed() => { + if changed.is_err() { + Err(Phase::Stop) + } else { + Err(Phase::Rebind) + } + } + () = tokio::time::sleep(wait) => Ok(()), + } +} + +/// One bounded health probe against the current snapshot's client. +async fn probe( + snapshot: &GatewaySnapshot, + stop: &mut oneshot::Receiver<()>, + gateway_changed: &mut watch::Receiver, +) -> Result { + tokio::select! { + _ = &mut *stop => Err(Phase::Stop), + changed = gateway_changed.changed() => { + if changed.is_err() { + Err(Phase::Stop) + } else { + Err(Phase::Rebind) + } + } + reachable = snapshot.client().health() => Ok(reachable), + } +} + +/// Publishes the probe outcome and, when it differs from the last +/// verdict, reports the transition to the menu and the status bar. +fn announce_transition( + push: &Push, + health: &GatewayHealth, + reachable: bool, + last: &mut Option, +) { + health.publish(reachable); + let transitioned = *last != Some(reachable); + *last = Some(reachable); + if transitioned { + // The menu recomputes chat_ready from reachability, so the + // verdict feeds it before any slower refresh work below. + push.menu().set_gateway_reachable(reachable); + if reachable { + push.push_status_update( + CONNECTED_LABEL, + "the gateway answers its health probe", + Activity::General, + ); + } else { + push.push_status_update( + UNREACHABLE_LABEL, + UNREACHABLE_DESCRIPTION, + Activity::General, + ); + } + } +} + +/// Refreshes the menu sources that have not converged, racing the stop +/// signal and a binding replacement against the refresh. All menu state +/// is server-owned and reaches the UI via socket pushes - the UI fetches +/// nothing on boot - so every transition into reachable, boot's first +/// probe included, (re)populates the profile state and the model catalog. +/// Healthy ticks independently repeat either refresh until both sources +/// are populated, because health and one ready source do not imply the +/// other source is ready; the probe interval bounds retries and keeps this +/// from becoming a busy loop. +async fn refresh_sources( + snapshot: &GatewaySnapshot, + push: &Push, + refresh: &mut RefreshState, + stop: &mut oneshot::Receiver<()>, + gateway_changed: &mut watch::Receiver, +) -> Result<(), Phase> { + tokio::select! { + _ = &mut *stop => Err(Phase::Stop), + changed = gateway_changed.changed() => { + if changed.is_err() { + Err(Phase::Stop) + } else { + Err(Phase::Rebind) + } + } + () = refresh_incomplete_sources(snapshot, push, refresh) => Ok(()), + } +} + /// Refreshes only the Gateway-owned menu sources that have not converged. async fn refresh_incomplete_sources( snapshot: &GatewaySnapshot, diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index 10cffc47f..834c759b5 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -2,16 +2,16 @@ //! HTTP client for the PromptForge gateway's OpenAI-compatible API, the //! replaceable endpoint binding and discovery-file resolution, the //! reachability heartbeat, the gateway progress subscriber, and the -//! workshop's run event log. +//! gateway cache API. //! //! ## Invariants //! //! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, -//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! `workshop-support`. Read the repository-root `AGENTS.md` before adding an import. //! - 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 @@ -25,19 +25,17 @@ 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; pub use gateway::{ CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, ProgressStream, - SsePayloadStream, SwitchOutcome, SwitchResponse, + SsePayloadStream, SwitchProfileBody, SwitchResponse, }; pub use gateway_binding::{ GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater, }; -pub use handles::{GatewayHandles, register, register_tasks}; +pub use handles::{GatewayHandles, GatewayTaskRegistrations, 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 c84cf8e9e..000000000 --- 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 f546e0151..000000000 --- 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/gateway/src/resolve.rs b/crates/workshop/gateway/src/resolve.rs index e61729d83..29e434bde 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/menu/src/handles.rs b/crates/workshop/menu/src/handles.rs index 40dc3a59d..2ba7ed7e1 100644 --- a/crates/workshop/menu/src/handles.rs +++ b/crates/workshop/menu/src/handles.rs @@ -41,17 +41,27 @@ impl MenuHandles { } } +/// The menu subsystem's registration guards: the catalog sink, the +/// workbench sink, and the menu's state handle. Dropping them +/// deregisters the subsystem. +#[derive(Debug)] +#[must_use = "dropping the registrations deregisters the subsystem"] +pub struct MenuRegistrations { + /// The catalog channel's receiving end. + pub catalog_sink: Registration, + /// The workbench mutators. + pub menu_sink: Registration, + /// The menu's state handle. + pub state: Registration, +} + /// Registers the menu subsystem into the registry: the catalog /// channel's receiving end and the workbench mutators, which same-tier /// subsystems (the gateway heartbeat's refreshes) drive through the /// registry's push facade, plus the subsystem's state handles. The /// returned guards keep the registrations alive; the composition root /// holds them for the process lifetime. -pub fn register( - registry: &Registry, - catalog: &CatalogBus, - menu: &MenuBus, -) -> (Registration, Registration, Registration) { +pub fn register(registry: &Registry, catalog: &CatalogBus, menu: &MenuBus) -> MenuRegistrations { let catalog_guard = registry.register_sink::(Arc::new(CatalogSinkAdapter::new({ let catalog = catalog.clone(); @@ -77,5 +87,9 @@ pub fn register( ))); let state = registry .register_state::(Arc::new(MenuHandles::new(catalog.clone(), menu.clone()))); - (catalog_guard, menu_guard, state) + MenuRegistrations { + catalog_sink: catalog_guard, + menu_sink: menu_guard, + state, + } } diff --git a/crates/workshop/menu/src/lib.rs b/crates/workshop/menu/src/lib.rs index 337902e03..5b5f2c636 100644 --- a/crates/workshop/menu/src/lib.rs +++ b/crates/workshop/menu/src/lib.rs @@ -7,7 +7,7 @@ //! ## Invariants //! //! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, -//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! `workshop-support`. Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - The server owns all Model-menu state and the UI only renders it; @@ -25,5 +25,5 @@ pub mod handles; pub mod menu; pub use catalog::{CatalogBus, ChatCatalog, is_chat_capable}; -pub use handles::{MenuHandles, register}; +pub use handles::{MenuHandles, MenuRegistrations, register}; pub use menu::{MenuBus, MenuRefusal, SwitchOutcome}; diff --git a/crates/workshop/protocol/src/error.rs b/crates/workshop/protocol/src/error.rs index b0fd3627e..4bdb9aac3 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/src/lib.rs b/crates/workshop/protocol/src/lib.rs index 657e06121..9fd58a22e 100644 --- a/crates/workshop/protocol/src/lib.rs +++ b/crates/workshop/protocol/src/lib.rs @@ -11,14 +11,17 @@ //! 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 //! //! - Tier: vocabulary; may depend on: no internal `workshop-*` crates. -//! Read `AGENTS.md` before adding an import. +//! Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - Zero I/O: no sockets, tasks, or clocks, so every wire shape is @@ -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 1b3c317cc..0bb9edc32 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 000000000..b35a4b01d --- /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/frames.rs b/crates/workshop/protocol/tests/it/frames.rs index e1d3da331..34142dd18 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/protocol/tests/it/main.rs b/crates/workshop/protocol/tests/it/main.rs index 6caa20d65..3ce38a011 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 000000000..99e753885 --- /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/registry/Cargo.toml b/crates/workshop/registry/Cargo.toml index 2470df045..943273af4 100644 --- a/crates/workshop/registry/Cargo.toml +++ b/crates/workshop/registry/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true -description = "Workshop subsystem registry: sealed proxy slots subsystems self-register into, so the composition root never names them" +description = "Workshop subsystem registry: sealed proxy slots subsystems self-register into, so the composition root meets them through subsystem-named traits instead of concrete types" [dependencies] axum.workspace = true diff --git a/crates/workshop/registry/src/lib.rs b/crates/workshop/registry/src/lib.rs index 74cc1f0c2..b1243bb5c 100644 --- a/crates/workshop/registry/src/lib.rs +++ b/crates/workshop/registry/src/lib.rs @@ -6,19 +6,27 @@ //! so the composition root never hand-wires what a subsystem can //! announce itself. //! +//! The runtime links the subsystem-named seams carry: the gateway +//! drives the menu through [`MenuPush`], publishing a model catalog +//! forces a menu reconcile through [`Push::push_models_catalog`], and +//! agent sessions read the workspace's granted roots through +//! [`WorkspaceRoots`]. +//! //! ## Invariants //! //! - Tier: vocabulary; may depend on: `workshop-protocol` (the wire -//! types the push-channel contributions use). Read `AGENTS.md` -//! before adding an import. +//! types the push-channel contributions use). Read the +//! repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - Every subsystem trait is sealed (a private empty supertrait), so //! only this crate implements them: registrants plug in through the //! adapters provided here, never by implementing a trait downstream. -//! - Never add a field, slot, or accessor naming a subsystem; a new -//! subsystem changes its own crate and one `register` call, never -//! this crate. +//! - Subsystem names live only in the sealed traits ([`MenuSink`], +//! [`CatalogSink`], [`StatusSink`], [`WorkspaceRoots`], [`MenuPush`]): +//! a new subsystem adds its trait and adapter here and changes nothing +//! else; the collections stay keyed by type, never by a per-subsystem +//! field or accessor. //! - An unregistered contribution is a graceful no-op, never an error: //! consumers branch on `None` and continue degraded, and the [`Push`] //! facade drops intents whose sink is unregistered. The composition diff --git a/crates/workshop/registry/src/registry.rs b/crates/workshop/registry/src/registry.rs index 360b55a68..5675e827c 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 846b8eb3d..ec207c0b3 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 f227d1969..c3723292f 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 46fc13b9b..9509656dd 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 fa7755e84..eb0678498 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 -//! `AGENTS.md` before adding an import. +//! - Tier: desktop-app boundary; may depend on: `workshop-server` only. Read +//! the repository-root `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/AGENTS.md b/crates/workshop/server/AGENTS.md index 5961ade74..ad0916422 100644 --- a/crates/workshop/server/AGENTS.md +++ b/crates/workshop/server/AGENTS.md @@ -9,7 +9,7 @@ This crate owns the Workshop HTTP and WebSocket server and its host-embeddable s - One task owns each socket, its protocol policy, and its cleanup. Agent sessions may survive socket disconnect; other per-request relay work does not gain a session registry. - Every pushed message type is durable or ephemeral. Durable delivery supports replay and duplicate tolerance; ephemeral delivery may coalesce or drop under lag and restores its latest complete snapshot after reconnect. - Work held for a disconnected client cancels through its ownership guard. -- Application state is composed at boot: each subsystem registers its handles into `workshop-registry`, and the shell asserts the composition at startup. Runtime reads of absent optional contributions degrade to no-ops. Do not pass one subsystem's handles into another subsystem's constructor, and do not reintroduce per-request panics on missing registrations. -- Agent sessions run in the harness, reached only through `harness-api`. The shell constructs the `Harness` at boot and registers its handle like every other subsystem; everything the harness knows about the shell (the gateway binding, the chat catalog, the host snapshot) crosses its public API as pushed data, never as a bus, a registry, or a callback. Status-bar reporting for a session is derived on the shell's side from the session's events, deltas, and error reports. +- Application state is composed at boot: each subsystem registers its handles into `workshop-registry`, and the server asserts the composition at startup. Runtime reads of absent optional contributions degrade to no-ops. Do not pass one subsystem's handles into another subsystem's constructor, and do not reintroduce per-request panics on missing registrations. +- Agent sessions run in the harness, reached only through `harness-api`. The server builds the `Harness` at boot and registers its handle like every other subsystem; everything the harness knows about the server (the gateway binding, the chat catalog, the host snapshot) crosses its public API as pushed data, never as a bus, a registry, or a callback. Status-bar reporting for a session is derived on the server's side from the session's events, deltas, and error reports. - Asset construction failures return to the host. API-path misses return 404 instead of the SPA index. - Held sockets and uncooperative clients must not make server shutdown unbounded. diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index be6adfb0c..2c9614d4b 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 @@ -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/README.md b/crates/workshop/server/README.md deleted file mode 100644 index 52ac02cf5..000000000 --- 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 625b95da1..26bed415e 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/server/src/agents.rs b/crates/workshop/server/src/agents.rs index b28e89860..0822d0996 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -1,29 +1,29 @@ -//! The sessions subsystem of the shell: 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 -//! in the harness. +//! The sessions subsystem of the server: the `/agents/ws` agent-session +//! socket ([`socket`]), the `/v1/models` catalog relay ([`relay`]), their +//! shared route state ([`state`]), and [`AgentSessions`], the server's +//! opener of agent sessions in the harness. The `/ws` workshop socket is +//! [`crate::workshop_socket`], outside this subsystem. //! //! 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 -//! arrives as data pushed through its public API (`bindings`): the +//! 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 -//! relay derives it from the session's events, deltas, and error reports. +//! Status-bar reporting stays in the server (`status`): a per-session +//! reporter 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. mod bindings; pub(crate) mod relay; -pub(crate) mod session; pub(crate) mod socket; +pub(crate) mod socket_frames; pub(crate) mod state; mod status; @@ -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 -/// wires up - the status relay. +/// 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 reporter. /// -/// 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. /// @@ -148,7 +148,7 @@ impl AgentSessions { args: String::new(), }) .await?; - status::spawn_relay( + status::spawn_reporter( &session, self.inner.registry.push(), self.inner.backoff.clone(), diff --git a/crates/workshop/server/src/agents/bindings.rs b/crates/workshop/server/src/agents/bindings.rs index ab64fd9a3..d38afcb04 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/relay-tests.rs b/crates/workshop/server/src/agents/relay-tests.rs index e17e6d14c..2ba4c3555 100644 --- a/crates/workshop/server/src/agents/relay-tests.rs +++ b/crates/workshop/server/src/agents/relay-tests.rs @@ -26,15 +26,7 @@ async fn body_bytes(response: Response) -> axum::body::Bytes { /// Binds `app` as a mock gateway on a free loopback port and returns its /// base URL. async fn spawn_gateway(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(app).await; format!("http://{addr}") } diff --git a/crates/workshop/server/src/agents/relay.rs b/crates/workshop/server/src/agents/relay.rs index 4d3ec93d4..17bb21dad 100644 --- a/crates/workshop/server/src/agents/relay.rs +++ b/crates/workshop/server/src/agents/relay.rs @@ -11,11 +11,6 @@ use workshop_registry::Push; use super::state::SessionsState; -/// Whether wire bodies include internal failure detail. Debug builds append -/// the source chain to the envelope message; production bodies stay at -/// the failure's own message. -const LEAK_DETAIL: bool = cfg!(debug_assertions); - /// Relays the gateway's model catalog to the caller verbatim. /// /// While the heartbeat reports the gateway down, the route @@ -86,7 +81,7 @@ pub(crate) fn relay(result: Result) -> Response { fn gateway_message(error: &GatewayError) -> String { use std::fmt::Write as _; let mut message = error.to_string(); - if LEAK_DETAIL { + if workshop_support::LEAK_DETAIL { let mut source = std::error::Error::source(error); while let Some(cause) = source { // fmt::Write to a String cannot fail; the Result is a trait diff --git a/crates/workshop/server/src/agents/socket.rs b/crates/workshop/server/src/agents/socket.rs index 85e9bd18a..7765942b7 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; @@ -28,22 +28,21 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::HeaderMap; use axum::response::Response; use harness_api::{ - Delta, DeltaKind, Session, SessionEvent, SessionFailure, WaitError, WaitFrame, display_chain, + Delta, Session, SessionEvent, SessionFailure, WaitError, WaitFrame, display_chain, }; -use promptforge::event::Event; use tokio::sync::broadcast; use workshop_protocol::{ - Activity, AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame, - ErrorFrame, InputFrame, InputResponse, + Activity, AgentSessionFrame, AgentsFrame, ErrorFrame, InputFrame, InputResponse, }; use super::LaunchRefusal; -use super::session::{cross_site_refusal, send_error, send_frame}; +use super::socket_frames::{delta_frame, drain_events, frame_entry, input_frame}; use super::state::SessionsState; +use crate::workshop_socket::{cross_site_refusal, send_error, send_frame}; /// Upgrades a `GET /agents/ws` request to an agent-session socket. A -/// foreign `Origin` is refused with 403, as the workbench socket's +/// foreign `Origin` is refused with 403, as the workshop socket's /// upgrade is. pub(crate) async fn upgrade( State(state): State, @@ -58,16 +57,16 @@ pub(crate) async fn upgrade( /// The attachment state of one socket: the session it serves and the /// per-client cursors deriving durable-frame indices. -struct Attached { +pub(crate) struct Attached { /// The session this socket serves. - session: Session, + pub(crate) session: Session, /// The next transcript index to consider; everything below it has /// been read (framed or skipped) for this client already. - cursor: u64, + pub(crate) cursor: u64, /// The wire index the next framed entry takes: the count of /// transcript entries with a wire shape sent so far, so the durable /// frames number the transcript the client renders, gap-free. - framed: u64, + pub(crate) framed: u64, } /// Receives from an optional subscription, pending forever when absent, @@ -216,29 +215,6 @@ type Subscriptions<'a> = ( &'a mut Option>, ); -/// Renders a harness wait frame as the protocol's input frame: the one -/// place the harness's wait vocabulary meets Workshop's wire shape. -fn input_frame(frame: WaitFrame) -> InputFrame { - match frame { - WaitFrame::Required { token } => InputFrame::Required { token }, - WaitFrame::Cancelled { token } => InputFrame::Cancelled { token }, - } -} - -/// Renders a harness delta as the protocol's delta frame, the reply stamp -/// passed through; `None` for a side channel the wire has no label for, -/// dropped like a lagged delta because the completed-reply event repairs -/// the transcript. -fn delta_frame(delta: Delta) -> Option { - let channel = match delta.kind { - DeltaKind::Text => AgentDeltaKind::Text, - DeltaKind::Reasoning => AgentDeltaKind::Reasoning, - // `DeltaKind` is `#[non_exhaustive]` in `harness-sessions`. - _ => return None, - }; - Some(AgentDeltaFrame::new(channel, delta.content, delta.reply)) -} - /// Handles one inbound text frame. A `false` return means the client is /// gone and the socket loop should end. async fn handle_frame( @@ -296,7 +272,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(); } @@ -428,53 +404,6 @@ async fn on_event_wake( } } -/// Sends every transcript entry past the client's cursor as a durable -/// `agent_event` frame stamped with its wire index and, on the -/// model-round content kinds, the reply stamp its deltas had. A `false` -/// return means the client is gone. -async fn drain_events(attached: &mut Attached, socket: &mut WebSocket) -> bool { - let transcript = match attached.session.transcript(attached.cursor).await { - Ok(transcript) => transcript, - Err(error) => { - // The run log refused the read; the next wakeup retries from - // the same cursor, so nothing is skipped. - tracing::warn!(session = %attached.session.id(), %error, "transcript read failed"); - return true; - } - }; - for entry in &transcript { - if !frame_entry(attached, entry, socket).await { - return false; - } - } - true -} - -/// Frames one transcript entry at or past the cursor and advances the -/// cursor over it. An entry with no wire shape (lifecycle, task, and -/// debug events) advances the cursor without a frame or a wire index. A -/// `false` return means the client is gone. -async fn frame_entry( - attached: &mut Attached, - entry: &SessionEvent, - socket: &mut WebSocket, -) -> bool { - if entry.index < attached.cursor { - return true; - } - attached.cursor = entry.index + 1; - let Ok(event) = serde_json::from_value::(entry.event.clone()) else { - // A stored payload this build cannot read has no wire shape - // either; the transcript's index sequence stays whole. - return true; - }; - let Some(frame) = AgentEventFrame::new(attached.framed, entry.reply, &event) else { - return true; - }; - attached.framed += 1; - send_frame(socket, &frame).await -} - /// Re-announces every unresolved wait to this socket in creation order - /// the attach-time (and lag-repair) half of the durable input-frame /// promise. A `false` return means the client is gone. diff --git a/crates/workshop/server/src/agents/socket_frames-tests.rs b/crates/workshop/server/src/agents/socket_frames-tests.rs new file mode 100644 index 000000000..8ab5cc9b5 --- /dev/null +++ b/crates/workshop/server/src/agents/socket_frames-tests.rs @@ -0,0 +1,65 @@ +//! Table-driven tests for the agent socket's framing helpers: the pure +//! render functions that map harness vocabulary onto Workshop wire +//! shapes. The durable-event helpers (`drain_events`, `frame_entry`) +//! need a live session and socket, so the integration socket tests cover +//! them end to end. + +use harness_api::{Delta, WaitFrame}; +use workshop_protocol::{AgentDeltaFrame, AgentDeltaKind, InputFrame}; + +use super::{delta_frame, input_frame}; + +#[test] +fn input_frames_map_harness_waits_to_wire_shapes() { + let cases = [ + ( + WaitFrame::Required { + token: "token-a".to_owned(), + }, + InputFrame::Required { + token: "token-a".to_owned(), + }, + ), + ( + WaitFrame::Cancelled { + token: "token-b".to_owned(), + }, + InputFrame::Cancelled { + token: "token-b".to_owned(), + }, + ), + ]; + + for (wait, expected) in cases { + assert_eq!(input_frame(wait), expected); + } +} + +#[test] +fn delta_frames_map_harness_channels_to_wire_shapes() { + // `Delta` is `#[non_exhaustive]` in `harness-sessions`, so each + // fixture is deserialized rather than written as a struct literal. + let cases = [ + ( + r#"{"kind":"text","content":"po","reply":2}"#, + Some(AgentDeltaFrame::new( + AgentDeltaKind::Text, + "po".to_owned(), + 2, + )), + ), + ( + r#"{"kind":"reasoning","content":"hmm","reply":3}"#, + Some(AgentDeltaFrame::new( + AgentDeltaKind::Reasoning, + "hmm".to_owned(), + 3, + )), + ), + ]; + + for (json, expected) in cases { + let delta: Delta = serde_json::from_str(json).expect("the delta fixture parses"); + assert_eq!(delta_frame(delta), expected); + } +} diff --git a/crates/workshop/server/src/agents/socket_frames.rs b/crates/workshop/server/src/agents/socket_frames.rs new file mode 100644 index 000000000..684dc757d --- /dev/null +++ b/crates/workshop/server/src/agents/socket_frames.rs @@ -0,0 +1,87 @@ +//! The agent socket's framing helpers: the pure render functions that +//! map the harness's wait and delta vocabulary onto Workshop's wire +//! shapes, and the durable-event framing that drains a session's +//! transcript past the per-client cursor. Split out of the `socket` +//! module so each stays under the 500-line ceiling. + +use axum::extract::ws::WebSocket; +use harness_api::{Delta, DeltaKind, SessionEvent, WaitFrame}; +use promptforge::event::Event; +use workshop_protocol::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, InputFrame}; + +use super::socket::Attached; +use crate::workshop_socket::send_frame; + +/// Renders a harness wait frame as the protocol's input frame: the one +/// place the harness's wait vocabulary meets Workshop's wire shape. +pub(crate) fn input_frame(frame: WaitFrame) -> InputFrame { + match frame { + WaitFrame::Required { token } => InputFrame::Required { token }, + WaitFrame::Cancelled { token } => InputFrame::Cancelled { token }, + } +} + +/// Renders a harness delta as the protocol's delta frame, the reply stamp +/// passed through; `None` for a side channel the wire has no label for, +/// dropped like a lagged delta because the completed-reply event repairs +/// the transcript. +pub(crate) fn delta_frame(delta: Delta) -> Option { + let channel = match delta.kind { + DeltaKind::Text => AgentDeltaKind::Text, + DeltaKind::Reasoning => AgentDeltaKind::Reasoning, + // `DeltaKind` is `#[non_exhaustive]` in `harness-sessions`. + _ => return None, + }; + Some(AgentDeltaFrame::new(channel, delta.content, delta.reply)) +} + +/// Sends every transcript entry past the client's cursor as a durable +/// `agent_event` frame stamped with its wire index and, on the +/// model-round content kinds, the reply stamp its deltas had. A `false` +/// return means the client is gone. +pub(crate) async fn drain_events(attached: &mut Attached, socket: &mut WebSocket) -> bool { + let transcript = match attached.session.transcript(attached.cursor).await { + Ok(transcript) => transcript, + Err(error) => { + // The run log refused the read; the next wakeup retries from + // the same cursor, so nothing is skipped. + tracing::warn!(session = %attached.session.id(), %error, "transcript read failed"); + return true; + } + }; + for entry in &transcript { + if !frame_entry(attached, entry, socket).await { + return false; + } + } + true +} + +/// Frames one transcript entry at or past the cursor and advances the +/// cursor over it. An entry with no wire shape (lifecycle, task, and +/// debug events) advances the cursor without a frame or a wire index. A +/// `false` return means the client is gone. +pub(crate) async fn frame_entry( + attached: &mut Attached, + entry: &SessionEvent, + socket: &mut WebSocket, +) -> bool { + if entry.index < attached.cursor { + return true; + } + attached.cursor = entry.index + 1; + let Ok(event) = serde_json::from_value::(entry.event.clone()) else { + // A stored payload this build cannot read has no wire shape + // either; the transcript's index sequence stays whole. + return true; + }; + let Some(frame) = AgentEventFrame::new(attached.framed, entry.reply, &event) else { + return true; + }; + attached.framed += 1; + send_frame(socket, &frame).await +} + +#[cfg(test)] +#[path = "socket_frames-tests.rs"] +mod tests; diff --git a/crates/workshop/server/src/agents/state.rs b/crates/workshop/server/src/agents/state.rs index 538d48e89..af605e75c 100644 --- a/crates/workshop/server/src/agents/state.rs +++ b/crates/workshop/server/src/agents/state.rs @@ -18,18 +18,18 @@ use workshop_registry::{ }; use workshop_support::{RELAY_DEADLINE, with_deadline}; -use super::{AgentSessions, bindings, relay, session, socket}; +use super::{AgentSessions, bindings, relay, 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) } @@ -138,13 +138,13 @@ pub(crate) fn routes(state: SessionsState) -> Router { Router::new().route("/v1/models", get(relay::models)), RELAY_DEADLINE, ) - .route("/ws", get(session::upgrade)) + .route("/ws", get(crate::workshop_socket::upgrade)) .route("/agents/ws", get(socket::upgrade)) .with_state(state) } /// 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-tests.rs b/crates/workshop/server/src/agents/status-tests.rs index ab4013e3f..876acdc71 100644 --- a/crates/workshop/server/src/agents/status-tests.rs +++ b/crates/workshop/server/src/agents/status-tests.rs @@ -24,7 +24,7 @@ fn wired_push() -> ( } /// A session event holding one engine event under the fixed test -/// coordinates, in the persisted shape the relay reads. +/// coordinates, in the persisted shape the reporter reads. fn session_event(event: &Event) -> SessionEvent { SessionEvent { index: 0, @@ -57,7 +57,7 @@ fn reply_event() -> Event { /// non-thinking status; the survived turns keep the boundary as their /// label because the agent is still running, and only a run that ended /// reads `Agent failed`. The label comes from the kind alone: the message -/// is deliberately unlike the label, so a relay that read the sentence +/// is deliberately unlike the label, so a reporter that read the sentence /// would mislabel every row. #[test] fn every_error_report_pushes_a_terminal_failure_status() { diff --git a/crates/workshop/server/src/agents/status.rs b/crates/workshop/server/src/agents/status.rs index 57853e1c3..96c91426d 100644 --- a/crates/workshop/server/src/agents/status.rs +++ b/crates/workshop/server/src/agents/status.rs @@ -1,9 +1,8 @@ -//! 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 +//! The server's status reporter 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 +//! One reporter task per session, spawned at launch. It holds only the //! session's broadcast receivers, never the session handle, so it ends by //! itself when the harness lets the session go and the last socket //! detaches: the channels close, and the loop returns. @@ -15,19 +14,23 @@ use workshop_protocol::Activity; use workshop_registry::Push; use workshop_support::ReconnectBackoff; -/// Spawns the relay for `session`, reporting through `push` and resetting +/// Spawns the reporter for `session`, reporting through `push` and resetting /// `backoff` on completed replies. -pub(super) fn spawn_relay(session: &harness_api::Session, push: Push, backoff: ReconnectBackoff) { +pub(super) fn spawn_reporter( + session: &harness_api::Session, + push: Push, + backoff: ReconnectBackoff, +) { let events = session.subscribe_events(); let deltas = session.subscribe_deltas(); let errors = session.subscribe_errors(); - tokio::spawn(relay(events, deltas, errors, push, backoff)); + tokio::spawn(report(events, deltas, errors, push, backoff)); } -/// Relays until the session's channels close. Deltas are drained ahead of +/// Reports until the session's channels close. Deltas are drained ahead of /// events, so a round's activity pulses precede the idle its reply /// pushes when both sit queued. -async fn relay( +async fn report( mut events: broadcast::Receiver, mut deltas: broadcast::Receiver, mut errors: broadcast::Receiver, @@ -74,7 +77,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) { @@ -96,7 +99,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 876021714..da6cebca1 100644 --- a/crates/workshop/server/src/app.rs +++ b/crates/workshop/server/src/app.rs @@ -5,15 +5,14 @@ //! 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. +mod compose; #[cfg(any(test, feature = "test-fixtures"))] -#[path = "app-fixtures.rs"] pub(crate) mod fixtures; #[cfg(test)] -#[path = "app-tests.rs"] mod tests; use std::fmt; @@ -21,29 +20,28 @@ use std::sync::Arc; use axum::Router; -use harness_api::Harness; - use workshop_gateway::GatewayHandles; +use workshop_gateway::gateway::GatewayError; +#[cfg(test)] +use workshop_gateway::gateway_binding::GatewayBinding; +use workshop_gateway::gateway_binding::{GatewaySnapshot, GatewayUpdater}; +use workshop_gateway::heartbeat::GatewayHealth; +use workshop_gateway::resolve::ResolvedGateway; use workshop_menu::MenuHandles; -use workshop_registry::{Push, Registration, Registry, WorkspaceRoots}; +use workshop_menu::catalog::CatalogBus; +use workshop_menu::menu::MenuBus; +use workshop_registry::{Push, Registration, Registry}; use workshop_status::StatusBus; use workshop_support::{Config, DEFAULT_DEADLINE, ReconnectBackoff, with_deadline}; -use workshop_user_state::UserStateStore; use workshop_workspace::Workspace; -use crate::agents::{self, AgentSessions, SessionsState}; -use crate::catalog::CatalogBus; -use crate::gateway::GatewayError; -use crate::gateway_binding::{GatewayBinding, GatewaySnapshot, GatewayUpdater}; -use crate::heartbeat::GatewayHealth; -use crate::menu::MenuBus; -use crate::resolve::ResolvedGateway; +use crate::agents::AgentSessions; 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 @@ -95,7 +93,8 @@ impl AppState { /// exists and the config has no explicit gateway, and /// [`StateError::Gateway`] if the HTTP client cannot be built. pub fn new(config: &Config) -> Result { - let gateway = crate::resolve::resolve(&config.gateway).map_err(StateError::Resolution)?; + let gateway = + workshop_gateway::resolve::resolve(&config.gateway).map_err(StateError::Resolution)?; state_with_gateway(config, &gateway) } @@ -273,7 +272,7 @@ pub fn state_with_gateway( config: &Config, gateway: &ResolvedGateway, ) -> Result { - compose(config, gateway, None, None) + compose::compose(config, gateway, None, None) } /// [`state_with_gateway`] with the profile switch's sidecar restart bound @@ -290,7 +289,7 @@ pub fn state_with_gateway_and_restart_bound( gateway: &ResolvedGateway, restart_bound: std::time::Duration, ) -> Result { - compose(config, gateway, None, Some(restart_bound)) + compose::compose(config, gateway, None, Some(restart_bound)) } /// [`state_with_gateway`] with one subsystem's `register` call removed: @@ -306,128 +305,7 @@ pub fn state_with_gateway_omitting( gateway: &ResolvedGateway, omit: Omit, ) -> Result { - compose(config, gateway, Some(omit), None) -} - -/// The composition root behind [`state_with_gateway`]; `omit` removes -/// one subsystem's `register` call for the boot-failure test, and -/// `restart_bound` replaces the sessions subsystem's sidecar restart -/// bound when given. -fn compose( - config: &Config, - gateway: &ResolvedGateway, - omit: Option, - restart_bound: Option, -) -> Result { - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - // The per-profile model memory lives in the state directory; a bad - // or missing memory file costs the memory, never startup. - let state_dir = &config.server.state_dir; - // A crash between an atomic write's temp file and its rename - // orphans the temp; boot is the one moment the directory is - // known and quiet, so it is swept here. - workshop_support::sweep_orphaned_temps(state_dir); - let menu = MenuBus::new(catalog.clone(), Some(state_dir)); - // The subsystems self-register: consumers reach their channels, - // sinks, state handles, routes, and tasks through the registry's - // collections instead of by name. - let registry = Registry::new(); - let mut registrations = Registrations::new(); - if omit != Some(Omit::Status) { - let (channel, sink, state) = workshop_status::register(®istry, &status); - registrations.hold(channel); - registrations.hold(sink); - registrations.hold(state); - } - if omit != Some(Omit::Menu) { - let (catalog_sink, menu_sink, menu_state) = - workshop_menu::register(®istry, &catalog, &menu); - registrations.hold(catalog_sink); - registrations.hold(menu_sink); - registrations.hold(menu_state); - } - let push = registry.push(); - // Startup phases are reported as they run; with no client connected - // yet these land on an empty bus, ready for the first session. - crate::resolve::report(gateway, &push); - let gateway_binding = GatewayBinding::new_with_identity( - gateway.base_url(), - gateway.api_key(), - gateway.identity().cloned(), - ) - .map_err(StateError::Gateway)?; - let backoff = ReconnectBackoff::new(); - let health = GatewayHealth::new(); - let gateway_handles = GatewayHandles::new(gateway_binding, health.clone()); - if omit != Some(Omit::Gateway) { - registrations.hold(workshop_gateway::register( - ®istry, - gateway_handles.clone(), - )); - } - // The background tasks register beside the state handles; the shell - // spawns them from the registry's task vector when it starts - // serving. - let (heartbeat, subscriber) = - workshop_gateway::register_tasks(®istry, &gateway_handles, backoff.clone()); - registrations.hold(heartbeat); - registrations.hold(subscriber); - // The workspace remembers its last-used file in the state directory; - // boot follows that memory through `reopen_last_workspace` once the - // runtime is up, since the reopen is async and composition is not. - let workspace = Workspace::with_state_dir(state_dir); - if omit != Some(Omit::Workspace) { - let (routes, state, roots) = workshop_workspace::register(®istry, &workspace); - registrations.hold(routes); - registrations.hold(state); - registrations.hold(roots); - // The shutdown lever that closes the workspace file inside the - // graceful stop, so a quit leaves one complete file and no - // sidecar. - registrations.hold(workshop_workspace::register_tasks(®istry, &workspace)); - } - // The account-scoped UI state lives beside the menu memory in the - // state directory; a bad or missing file costs the state, never - // startup. - let user_state = Arc::new(UserStateStore::new(state_dir)); - let (routes, state) = workshop_user_state::register(®istry, user_state); - registrations.hold(routes); - 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. - 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); - if let Some(bound) = restart_bound { - sessions = sessions.with_restart_bound(bound); - } - if omit != Some(Omit::Sessions) { - let (routes, harness, agents) = agents::register(®istry, &sessions, harness, &agents); - registrations.hold(routes); - registrations.hold(harness); - registrations.hold(agents); - // The bindings forwarder, spawned with serving like every task. - registrations.hold(agents::register_tasks(®istry)); - } - // The boot contract: every subsystem's handle set is present before - // state is shared, so a missing contribution fails here, naming the - // type, instead of panicking later at first use. - registry.require::()?; - registry.require::()?; - registry.require::()?; - registry.require::()?; - registry.require::()?; - registry.require::()?; - registry.require::()?; - registry.require::()?; - push.push_idle(); - Ok(AppState { - backoff, - registry, - _registrations: registrations, - }) + compose::compose(config, gateway, Some(omit), None) } /// A shared-state construction failure: rich, init-only, and never sent @@ -444,7 +322,7 @@ pub enum StateError { /// no explicit `[gateway]` config. #[non_exhaustive] #[error("resolve the gateway endpoint")] - Resolution(#[source] crate::resolve::ResolveError), + Resolution(#[source] workshop_gateway::resolve::ResolveError), /// A required subsystem contribution was never registered: the /// composition root itself is broken, so boot fails naming the @@ -455,13 +333,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`, @@ -470,7 +348,8 @@ pub fn router(state: AppState) -> Router { let registry = state.registry().clone(); let mut api = Router::new() .merge(routes::realtime::routes(state.clone())) - .merge(routes::gateway_config::routes(state)); + .merge(routes::gateway_config::routes(state)) + .merge(with_deadline(routes::prompts::routes(), DEFAULT_DEADLINE)); // The subsystems' routes merge in registration order; an empty // vector is a graceful no-op. for registrar in registry.routes() { @@ -482,7 +361,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/app/compose.rs b/crates/workshop/server/src/app/compose.rs new file mode 100644 index 000000000..1f1ac4ccb --- /dev/null +++ b/crates/workshop/server/src/app/compose.rs @@ -0,0 +1,223 @@ +//! The composition root: constructs every subsystem, registers it into +//! the shared [`Registry`], and assembles the shared [`AppState`]. Each +//! subsystem owns a `register` helper here so the composition reads as a +//! single list of subsystem registrations in registration order. + +use std::sync::Arc; + +use harness_api::Harness; + +use workshop_gateway::GatewayHandles; +use workshop_gateway::gateway_binding::GatewayBinding; +use workshop_gateway::heartbeat::GatewayHealth; +use workshop_gateway::resolve::ResolvedGateway; +use workshop_menu::MenuHandles; +use workshop_menu::catalog::CatalogBus; +use workshop_menu::menu::MenuBus; +use workshop_registry::{Push, Registry, WorkspaceRoots}; +use workshop_status::StatusBus; +use workshop_support::{Config, ReconnectBackoff}; +use workshop_user_state::UserStateStore; +use workshop_workspace::Workspace; + +use super::{AppState, Omit, Registrations, StateError}; +use crate::agents::{self, AgentSessions, SessionsState}; + +/// The composition root behind [`super::state_with_gateway`]; `omit` +/// removes one subsystem's `register` call for the boot-failure test, and +/// `restart_bound` replaces the sessions subsystem's sidecar restart +/// bound when given. +pub(super) fn compose( + config: &Config, + gateway: &ResolvedGateway, + omit: Option, + restart_bound: Option, +) -> Result { + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + // The per-profile model memory lives in the state directory; a bad + // or missing memory file costs the memory, never startup. + let state_dir = &config.server.state_dir; + // A crash between an atomic write's temp file and its rename + // orphans the temp; boot is the one moment the directory is + // known and quiet, so it is swept here. + workshop_support::sweep_orphaned_temps(state_dir); + let menu = MenuBus::new(catalog.clone(), Some(state_dir)); + // The subsystems self-register: consumers reach their channels, + // sinks, state handles, routes, and tasks through the registry's + // collections instead of by name. + let registry = Registry::new(); + let mut registrations = Registrations::new(); + register_status(®istry, &mut registrations, &status, omit); + register_menu(®istry, &mut registrations, &catalog, &menu, omit); + let push = registry.push(); + let backoff = register_gateway(®istry, &mut registrations, gateway, &push, omit)?; + register_workspace(®istry, &mut registrations, state_dir, omit); + register_user_state(®istry, &mut registrations, state_dir); + register_sessions( + ®istry, + &mut registrations, + config, + &backoff, + restart_bound, + omit, + ); + // The boot contract: every subsystem's handle set is present before + // state is shared, so a missing contribution fails here, naming the + // type, instead of panicking later at first use. + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + push.push_idle(); + Ok(AppState { + backoff, + registry, + _registrations: registrations, + }) +} + +/// The status subsystem: its bus plus the push channel, sink, and state +/// handle it self-registers. +fn register_status( + registry: &Registry, + registrations: &mut Registrations, + status: &StatusBus, + omit: Option, +) { + if omit != Some(Omit::Status) { + let regs = workshop_status::register(registry, status); + registrations.hold(regs.channel); + registrations.hold(regs.sink); + registrations.hold(regs.state); + } +} + +/// The menu subsystem: its catalog and menu buses plus the sinks and +/// state handle it self-registers. +fn register_menu( + registry: &Registry, + registrations: &mut Registrations, + catalog: &CatalogBus, + menu: &MenuBus, + omit: Option, +) { + if omit != Some(Omit::Menu) { + let regs = workshop_menu::register(registry, catalog, menu); + registrations.hold(regs.catalog_sink); + registrations.hold(regs.menu_sink); + registrations.hold(regs.state); + } +} + +/// The gateway subsystem: reports the resolved gateway, builds the +/// replaceable binding, and self-registers its handles and background +/// tasks. Returns the shared reconnect backoff the sessions subsystem +/// later draws from. +fn register_gateway( + registry: &Registry, + registrations: &mut Registrations, + gateway: &ResolvedGateway, + push: &Push, + omit: Option, +) -> Result { + // Startup phases are reported as they run; with no client connected + // yet these land on an empty bus, ready for the first session. + workshop_gateway::resolve::report(gateway, push); + let gateway_binding = GatewayBinding::new_with_identity( + gateway.base_url(), + gateway.api_key(), + gateway.identity().cloned(), + ) + .map_err(StateError::Gateway)?; + let backoff = ReconnectBackoff::new(); + let health = GatewayHealth::new(); + let gateway_handles = GatewayHandles::new(gateway_binding, health.clone()); + if omit != Some(Omit::Gateway) { + registrations.hold(workshop_gateway::register( + registry, + gateway_handles.clone(), + )); + } + // The background tasks register beside the state handles; the server + // spawns them from the registry's task vector when it starts + // serving. + let tasks = workshop_gateway::register_tasks(registry, &gateway_handles, backoff.clone()); + registrations.hold(tasks.heartbeat); + registrations.hold(tasks.subscriber); + Ok(backoff) +} + +/// The workspace subsystem: its file handle plus the routes, state, +/// roots, and shutdown task it self-registers. +fn register_workspace( + registry: &Registry, + registrations: &mut Registrations, + state_dir: &std::path::Path, + omit: Option, +) { + // The workspace remembers its last-used file in the state directory; + // boot follows that memory through `reopen_last_workspace` once the + // runtime is up, since the reopen is async and composition is not. + let workspace = Workspace::with_state_dir(state_dir); + if omit != Some(Omit::Workspace) { + let regs = workshop_workspace::register(registry, &workspace); + registrations.hold(regs.routes); + registrations.hold(regs.state); + registrations.hold(regs.roots); + // The shutdown lever that closes the workspace file inside the + // graceful stop, so a quit leaves one complete file and no + // sidecar. + registrations.hold(workshop_workspace::register_tasks(registry, &workspace)); + } +} + +/// The user-state subsystem: the account-scoped UI state store plus the +/// routes and state handle it self-registers. +fn register_user_state( + registry: &Registry, + registrations: &mut Registrations, + state_dir: &std::path::Path, +) { + // The account-scoped UI state lives beside the menu memory in the + // state directory; a bad or missing file costs the state, never + // startup. + let user_state = Arc::new(UserStateStore::new(state_dir)); + let regs = workshop_user_state::register(registry, user_state); + registrations.hold(regs.routes); + registrations.hold(regs.state); +} + +/// The harness (agent-sessions) subsystem: the harness, the agent-session +/// opener, and the `/ws` sessions state, plus the routes and bindings +/// task it self-registers. +fn register_sessions( + registry: &Registry, + registrations: &mut Registrations, + config: &Config, + backoff: &ReconnectBackoff, + restart_bound: Option, + omit: Option, +) { + // 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 server's state through its public API. + let harness = agents::harness_for(config, registry); + let agents = AgentSessions::new(registry.clone(), backoff.clone()); + let mut sessions = SessionsState::new(registry.clone(), crate::cross_site::origin_allowed); + if let Some(bound) = restart_bound { + sessions = sessions.with_restart_bound(bound); + } + if omit != Some(Omit::Sessions) { + let (routes, harness, agents) = agents::register(registry, &sessions, harness, &agents); + registrations.hold(routes); + registrations.hold(harness); + registrations.hold(agents); + // The bindings forwarder, spawned with serving like every task. + registrations.hold(agents::register_tasks(registry)); + } +} diff --git a/crates/workshop/server/src/app-fixtures.rs b/crates/workshop/server/src/app/fixtures.rs similarity index 86% rename from crates/workshop/server/src/app-fixtures.rs rename to crates/workshop/server/src/app/fixtures.rs index bafd07d69..e41f5c694 100644 --- a/crates/workshop/server/src/app-fixtures.rs +++ b/crates/workshop/server/src/app/fixtures.rs @@ -51,7 +51,7 @@ pub(crate) fn config_for(base_url: &str, state_dir: &Path) -> Config { pub(crate) fn state_for(base_url: &str) -> (AppState, tempfile::TempDir) { let state_dir = tempfile::TempDir::new().expect("tempdir"); let config = config_for(base_url, state_dir.path()); - let gateway = crate::resolve::ResolvedGateway::from_config(&config.gateway); + let gateway = workshop_gateway::resolve::ResolvedGateway::from_config(&config.gateway); let state = state_with_gateway(&config, &gateway).expect("state builds in tests"); (state, state_dir) } @@ -71,14 +71,6 @@ pub(crate) async fn body_bytes(response: Response) -> axum::body::Bytes { /// Panics when the loopback bind fails or the bound address cannot be /// read. pub async fn spawn_gateway(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(app).await; format!("http://{addr}") } diff --git a/crates/workshop/server/src/app-tests.rs b/crates/workshop/server/src/app/tests.rs similarity index 98% rename from crates/workshop/server/src/app-tests.rs rename to crates/workshop/server/src/app/tests.rs index 819a627bc..281e92d3a 100644 --- a/crates/workshop/server/src/app-tests.rs +++ b/crates/workshop/server/src/app/tests.rs @@ -7,7 +7,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::get; use super::fixtures::{body_bytes, config_for, spawn_gateway, state_for}; -use crate::gateway::GatewayClient; +use workshop_gateway::gateway::GatewayClient; /// The `last-workspace` pointer file inside `state_dir`. fn pointer_path(state_dir: &std::path::Path) -> std::path::PathBuf { @@ -156,7 +156,7 @@ fn default_bind_is_loopback_port_7910() { #[test] fn the_relay_deadline_outlasts_the_gateway_request_timeout() { assert!( - workshop_support::RELAY_DEADLINE > crate::gateway::REQUEST_TIMEOUT, + workshop_support::RELAY_DEADLINE > workshop_gateway::gateway::REQUEST_TIMEOUT, "the route deadline must let the gateway client time out first, \ so the caller sees the relay's 502 rather than a blunt 408" ); diff --git a/crates/workshop/server/src/assets.rs b/crates/workshop/server/src/assets.rs index 9df080c0d..dcc70a539 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,8 +65,8 @@ impl AssetManifest { } /// The narrow asset-serving interface of the server's webview asset -/// layer. The shell wires one implementation into the asset routes: -/// [`EmbeddedAssets`] in a normal build, [`NoopAssets`] under the +/// 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. pub(crate) trait AssetServer { diff --git a/crates/workshop/server/src/cross_site.rs b/crates/workshop/server/src/cross_site.rs index 6c6ebb3e3..3ff79d5eb 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 ed3b375e1..ddc20641a 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 b740cf9a9..98750d15a 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,26 +8,20 @@ //! 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 //! ([`workshop_support::ConfigError`], [`crate::serve::SpawnError`]) and //! never cross the wire. -use std::fmt::Write as _; - use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; +use promptforge::{ParseError, ParseErrorKind}; +use workshop_gateway::gateway::GatewayError; use workshop_protocol::ErrorEnvelope; - -use crate::gateway::GatewayError; - -/// Whether wire bodies include internal failure detail. Debug builds append -/// the source chain to the envelope message; production bodies stay at the -/// variant's own message. -const LEAK_DETAIL: bool = cfg!(debug_assertions); +use workshop_support::{LEAK_DETAIL, render_message}; /// A failure answered over the HTTP wire. /// @@ -62,15 +56,47 @@ pub(crate) enum AppError { /// An embedded UI asset is missing from the bundle. #[error("ui asset not found: {0}")] AssetMissing(String), + + /// A posted prompt failed to parse; the code names the failure kind + /// and the message carries the parser's `line N: ` prefix. + #[error("{message}")] + PromptParse { + /// The pre-rendered message, including the `line N: ` prefix when + /// the parser located the failure. + message: String, + /// The wire code for the parse-failure kind. + code: &'static str, + }, } impl AppError { + /// Builds the wire error for a prompt that failed to parse: the code + /// names the failure kind and the message carries the parser's + /// `line N: ` prefix when it located the failure. + pub(crate) fn prompt_parse(error: &ParseError) -> Self { + let code = match error.kind() { + ParseErrorKind::Frontmatter => "parse_frontmatter", + ParseErrorKind::Structure => "parse_structure", + ParseErrorKind::Fence => "parse_fence", + ParseErrorKind::List => "parse_list", + ParseErrorKind::Lua => "parse_lua", + // A kind added after this route predates its wire code. + _ => "parse_error", + }; + let message = match error.line() { + Some(line) => format!("line {line}: {error}"), + None => error.to_string(), + }; + Self::PromptParse { message, code } + } + /// The one HTTP status this failure answers with. fn status(&self) -> StatusCode { match self { Self::Gateway(_) => StatusCode::BAD_GATEWAY, Self::CrossSite | Self::ForwardDenied => StatusCode::FORBIDDEN, Self::NotJson => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::PromptParse { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::AssetMissing(_) => StatusCode::NOT_FOUND, } } @@ -83,6 +109,7 @@ impl AppError { Self::CrossSite => Some("cross_site"), Self::NotJson => Some("not_json"), Self::ForwardDenied => Some("forward_denied"), + Self::PromptParse { code, .. } => Some(code), Self::AssetMissing(_) => None, } } @@ -111,23 +138,6 @@ impl IntoResponse for AppError { } } -/// Renders the envelope message for `error`: its own `Display` text, with -/// the source chain appended as `: cause` segments when `leak_detail` is -/// set. -fn render_message(error: &AppError, leak_detail: bool) -> String { - let mut message = error.to_string(); - if leak_detail { - let mut source = std::error::Error::source(error); - while let Some(cause) = source { - // fmt::Write to a String cannot fail; the Result is a trait - // artifact. - let _ = write!(message, ": {cause}"); - source = cause.source(); - } - } - message -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/workshop/server/src/fixtures.rs b/crates/workshop/server/src/fixtures.rs index 0bc486723..94e929b13 100644 --- a/crates/workshop/server/src/fixtures.rs +++ b/crates/workshop/server/src/fixtures.rs @@ -3,12 +3,12 @@ #[cfg(feature = "test-fixtures")] pub use crate::app::state_with_gateway_and_restart_bound; pub use crate::app::{Omit, state_with_gateway, state_with_gateway_omitting}; -pub use crate::catalog::CatalogBus; -pub use crate::heartbeat::{GatewayHealth, Heartbeat}; -pub use crate::menu::{MenuBus, MenuRefusal}; pub use crate::push::Push; -pub use crate::status::StatusBus; +pub use workshop_gateway::heartbeat::{GatewayHealth, Heartbeat}; +pub use workshop_menu::catalog::CatalogBus; +pub use workshop_menu::menu::{MenuBus, MenuRefusal}; pub use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +pub use workshop_status::status::StatusBus; pub use workshop_support::ReconnectBackoff; #[cfg(feature = "test-fixtures")] @@ -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. @@ -61,8 +61,8 @@ pub fn spawn_heartbeat( interval: std::time::Duration, backoff: ReconnectBackoff, ) -> Heartbeat { - crate::heartbeat::spawn( - crate::gateway_binding::GatewayBinding::from_client(client), + workshop_gateway::heartbeat::spawn( + workshop_gateway::gateway_binding::GatewayBinding::from_client(client), push, health, interval, diff --git a/crates/workshop/server/src/lib.rs b/crates/workshop/server/src/lib.rs index 588773ca7..6902d1bc1 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 @@ -10,39 +10,41 @@ //! //! The crate is the composition root of the workshop server //! decomposition: the feature subsystems (`workshop-user-state`, -//! `workshop-workspace`, and the sessions subsystem in `agents`: the -//! `/ws` workbench socket, the `/agents/ws` agent-session socket, and the -//! `/v1/models` catalog relay), the domain services (`workshop-gateway`, -//! `workshop-status`, `workshop-menu`), and the vocabulary crates -//! (`workshop-protocol`, `workshop-registry`, `workshop-support`) are -//! assembled in `app.rs`, where every subsystem self-registers its +//! `workshop-workspace`, and the sessions subsystem in `agents`, which +//! serves the `/agents/ws` agent-session socket and the `/v1/models` +//! catalog relay), the `/ws` workshop socket in `workshop_socket`, the +//! domain services (`workshop-gateway`, `workshop-status`, +//! `workshop-menu`), and the vocabulary crates (`workshop-protocol`, +//! `workshop-registry`, `workshop-support`) are assembled in `app` +//! (helpers in `app::compose`), where every subsystem self-registers its //! routes, state handles, and push channels into the registry - the //! harness among them. //! //! ## 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`, //! `workshop-workspace`), the harness's public API `harness-api`, and -//! the engine's public API `promptforge`. Read `AGENTS.md` -//! before adding an import. +//! the engine's public API `promptforge`. Read the repository-root +//! `AGENTS.md` and `crates/workshop/server/AGENTS.md` before adding +//! an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - One task owns each socket: a single `select!` loop reads inbound //! 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 @@ -56,15 +58,7 @@ mod csp; mod error; 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 -// 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_menu::{catalog, menu}; -pub use workshop_status::status; +mod workshop_socket; /// The intent-named push facade over the registry's producer sink slots: /// business code reports what happened and never chooses a severity or @@ -90,18 +84,17 @@ 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_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 /// keeps one import path. pub use harness_api::WaitError; pub use push::Push; -pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; pub use serve::{ServerHandle, SpawnError, Termination, spawn}; +pub use workshop_gateway::gateway::{ + GatewayClient, GatewayError, GatewayResponse, SwitchProfileBody, SwitchResponse, +}; +pub use workshop_gateway::gateway_binding::{GatewayPublicationError, GatewayUpdater}; +pub use workshop_gateway::resolve::{GatewaySource, ResolveError, ResolvedGateway}; pub use workshop_protocol::{Activity, InputFrame, InputResponse}; pub use workshop_support::{ AgentsConfig, Config, ConfigError, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, diff --git a/crates/workshop/server/src/main.rs b/crates/workshop/server/src/main.rs index 4b1049e52..30737d66c 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.rs b/crates/workshop/server/src/routes.rs index 58cb861c4..b8db93e5f 100644 --- a/crates/workshop/server/src/routes.rs +++ b/crates/workshop/server/src/routes.rs @@ -7,4 +7,5 @@ pub(crate) mod assets; pub(crate) mod gateway_config; pub(crate) mod health; +pub(crate) mod prompts; pub(crate) mod realtime; diff --git a/crates/workshop/server/src/routes/assets.rs b/crates/workshop/server/src/routes/assets.rs index e6d3b5cef..850836a68 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/workspace/src/handlers-prompts-tests.rs b/crates/workshop/server/src/routes/prompts-tests.rs similarity index 94% rename from crates/workshop/workspace/src/handlers-prompts-tests.rs rename to crates/workshop/server/src/routes/prompts-tests.rs index 33659da69..f035214fb 100644 --- a/crates/workshop/workspace/src/handlers-prompts-tests.rs +++ b/crates/workshop/server/src/routes/prompts-tests.rs @@ -5,26 +5,25 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt as _; -use crate::handlers::routes; -use crate::workspace::Workspace; +use crate::app::fixtures::{body_bytes, state_for}; +use crate::app::router; -/// Posts `body` to `/prompts/contract` on a fresh empty workspace and +/// Posts `body` to `/prompts/contract` on the assembled server router and /// returns the status with the decoded JSON body. async fn post_contract(body: serde_json::Value) -> (StatusCode, serde_json::Value) { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); let request = Request::builder() .method("POST") .uri("/prompts/contract") .header(axum::http::header::CONTENT_TYPE, "application/json") .body(Body::from(body.to_string())) .expect("static request parts are valid"); - let response = routes(Workspace::new()) + let response = router(state) .oneshot(request) .await .expect("the router is infallible"); let status = response.status(); - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("the body is in memory already"); + let bytes = body_bytes(response).await; let json = serde_json::from_slice(&bytes).expect("the body is JSON"); (status, json) } diff --git a/crates/workshop/workspace/src/handlers-prompts.rs b/crates/workshop/server/src/routes/prompts.rs similarity index 83% rename from crates/workshop/workspace/src/handlers-prompts.rs rename to crates/workshop/server/src/routes/prompts.rs index d5482a828..619c99b73 100644 --- a/crates/workshop/workspace/src/handlers-prompts.rs +++ b/crates/workshop/server/src/routes/prompts.rs @@ -8,23 +8,22 @@ //! `Serialize` structs built from [`Frontmatter`] accessors. use axum::Json; -use axum::http::{StatusCode, header}; +use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::post; use serde::{Deserialize, Serialize}; +use promptforge::Prompt; use promptforge::prompt::{ ArgDecl, ArgsDecl, CapabilityDecl, FileDecl, Frontmatter, ModelKeyword, ModelRole, ToolSlot, }; -use promptforge::{ParseError, ParseErrorKind, Prompt}; -use workshop_protocol::ErrorEnvelope; -use crate::workspace::Workspace; +use crate::error::AppError; -/// The prompt routes, merged into the subsystem's router by the parent -/// module so they share its state type, deadline tier, and cross-site -/// guard. The parse is pure. -pub(super) fn routes() -> axum::Router { +/// The `/prompts/contract` route. The parse is pure, so the router +/// carries no state; the server mounts it under the default deadline and +/// its cross-site guard. +pub(crate) fn routes() -> axum::Router { axum::Router::new().route("/prompts/contract", post(contract)) } @@ -263,39 +262,10 @@ pub(crate) async fn contract(Json(body): Json) -> Response { Json(ContractResponse::from(prompt.frontmatter())), ) .into_response(), - Err(error) => parse_failure(&error), + Err(error) => AppError::prompt_parse(&error).into_response(), } } -/// Renders a parse failure as the standard error envelope: the machine -/// code is `parse_`, and the message includes the `line N: ` prefix -/// when the parser located the failure. -fn parse_failure(error: &ParseError) -> Response { - let code = match error.kind() { - ParseErrorKind::Frontmatter => "parse_frontmatter", - ParseErrorKind::Structure => "parse_structure", - ParseErrorKind::Fence => "parse_fence", - ParseErrorKind::List => "parse_list", - ParseErrorKind::Lua => "parse_lua", - // A kind added after this route predates its wire code. - _ => "parse_error", - }; - let message = match error.line() { - Some(line) => format!("line {line}: {error}"), - None => error.to_string(), - }; - let envelope = ErrorEnvelope::new(message, code); - // Serializing the envelope cannot fail: two strings only. - let body = - serde_json::to_string(&envelope).unwrap_or_else(|_| "prompt parse failed".to_owned()); - ( - StatusCode::UNPROCESSABLE_ENTITY, - [(header::CONTENT_TYPE, "application/json")], - body, - ) - .into_response() -} - #[cfg(test)] -#[path = "handlers-prompts-tests.rs"] +#[path = "prompts-tests.rs"] mod tests; 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 000000000..caef57fd5 --- /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 288c0b979..55c3f9784 100644 --- a/crates/workshop/server/src/routes/realtime.rs +++ b/crates/workshop/server/src/routes/realtime.rs @@ -14,7 +14,7 @@ use tokio_tungstenite::tungstenite::Message as GatewayMessage; use tokio_tungstenite::tungstenite::protocol::CloseFrame as GatewayCloseFrame; use crate::app::AppState; -use crate::gateway::GatewayRealtimeSocket; +use workshop_gateway::gateway::GatewayRealtimeSocket; const RELAY_IO_DEADLINE: Duration = Duration::from_millis(500); @@ -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/crates/workshop/server/src/serve-tests.rs b/crates/workshop/server/src/serve-tests.rs index 3ce6bc15d..f9b45d7b5 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 780f8f5a4..a481fb79a 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 @@ -15,8 +15,8 @@ use std::thread::JoinHandle; use std::time::Duration; use crate::app::{StateError, router, state_with_gateway}; -use crate::gateway_binding::GatewayUpdater; -use crate::resolve::ResolvedGateway; +use workshop_gateway::gateway_binding::GatewayUpdater; +use workshop_gateway::resolve::ResolvedGateway; use workshop_support::Config; /// How long a signaled shutdown waits for in-flight connections to drain @@ -190,7 +190,9 @@ fn spawn_inner( // failure is the plain no-gateway error, never a bind-then-fail. let gateway = match gateway { Some(gateway) => gateway, - None => crate::resolve::resolve(&config.gateway).map_err(StateError::Resolution)?, + None => { + workshop_gateway::resolve::resolve(&config.gateway).map_err(StateError::Resolution)? + } }; let initial_gateway_identity = gateway.identity().cloned(); let (ready_tx, ready_rx) = mpsc::channel(); @@ -328,6 +330,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/crates/workshop/server/src/agents/session-menu.rs b/crates/workshop/server/src/workshop_socket-menu.rs similarity index 98% rename from crates/workshop/server/src/agents/session-menu.rs rename to crates/workshop/server/src/workshop_socket-menu.rs index 6fdc93472..245d7e448 100644 --- a/crates/workshop/server/src/agents/session-menu.rs +++ b/crates/workshop/server/src/workshop_socket-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/server/src/agents/session.rs b/crates/workshop/server/src/workshop_socket.rs similarity index 97% rename from crates/workshop/server/src/agents/session.rs rename to crates/workshop/server/src/workshop_socket.rs index 6dc029466..653f6517d 100644 --- a/crates/workshop/server/src/agents/session.rs +++ b/crates/workshop/server/src/workshop_socket.rs @@ -20,7 +20,7 @@ //! refusals when the frame included one. A frame that is not a //! well-formed menu event is answered with an `error` frame and the //! session continues. Chat itself is on the `/agents/ws` socket -//! ([`super::socket`]). +//! ([`crate::agents::socket`]). //! //! One task owns the socket: a single `select!` loop reads inbound frames //! and writes every outbound frame itself - no outbox channel, no writer @@ -37,7 +37,7 @@ //! ([`SessionsState::registry`]), not named directly: an unregistered //! slot degrades the session to no status frames rather than failing it. -#[path = "session-menu.rs"] +#[path = "workshop_socket-menu.rs"] mod menu; use std::sync::atomic::{AtomicU64, Ordering}; @@ -50,7 +50,7 @@ use tokio::sync::broadcast; use workshop_protocol::{ErrorEnvelope, ErrorFrame}; -use super::state::SessionsState; +use crate::agents::state::SessionsState; use self::menu::{select_model, start_switch}; @@ -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/tests/it/agents.rs b/crates/workshop/server/tests/it/agents.rs index 971d23d82..e4ecc2a18 100644 --- a/crates/workshop/server/tests/it/agents.rs +++ b/crates/workshop/server/tests/it/agents.rs @@ -218,16 +218,7 @@ async fn spawn_agent_server_for_gateway(base_url: String) -> (String, tempfile:: state .catalog() .publish(vec![json!({ "id": "test-model", "object": "model" })]); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the agent test server"); - let addr = listener.local_addr().expect("agent test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("agent test server serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(router(state.clone())).await; (format!("ws://{addr}"), dir, state) } diff --git a/crates/workshop/server/tests/it/chat_gate.rs b/crates/workshop/server/tests/it/chat_gate.rs index be6f4e1dc..f95f959a1 100644 --- a/crates/workshop/server/tests/it/chat_gate.rs +++ b/crates/workshop/server/tests/it/chat_gate.rs @@ -218,16 +218,7 @@ async fn spawn_chat_server_with_selection(models: &[&str], selected: Option<&str .set_selected(selected) .expect("the selected model is in the retained catalog"); } - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the gate test server"); - let addr = listener.local_addr().expect("gate test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("gate test server serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(router(state.clone())).await; GateServer { ws_base: format!("ws://{addr}"), state, @@ -266,9 +257,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 448d9453d..7a509a73f 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.rs b/crates/workshop/server/tests/it/heartbeat_loop.rs index c37cdd2cd..eedc95631 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 @@ -46,17 +46,17 @@ type Guards = ( /// plus the guards keeping the registrations alive. fn wired_push(status: &StatusBus, catalog: &CatalogBus, menu: &MenuBus) -> (Push, Guards) { let registry = Registry::new(); - let (status_channel, status_sink, status_state) = workshop_status::register(®istry, status); - let (catalog_sink, menu_sink, menu_state) = workshop_menu::register(®istry, catalog, menu); + let status_regs = workshop_status::register(®istry, status); + let menu_regs = workshop_menu::register(®istry, catalog, menu); ( registry.push(), ( - status_channel, - status_sink, - status_state, - catalog_sink, - menu_sink, - menu_state, + status_regs.channel, + status_regs.sink, + status_regs.state, + menu_regs.catalog_sink, + menu_regs.menu_sink, + menu_regs.state, ), ) } @@ -119,15 +119,7 @@ async fn spawn_gateway(healthy: Arc) -> String { /// Binds `app` on a free loopback port and returns its base URL. async fn serve(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(app).await; format!("http://{addr}") } 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 331105efe..e5472afab 100644 --- a/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs +++ b/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs @@ -145,6 +145,8 @@ async fn a_healthy_gateway_retries_refresh_until_its_catalog_is_ready() { ); let requests_after_restore = state.requests.load(Ordering::Relaxed); + // The quiet window must be real time: the probes are real loopback HTTP + // on the test runtime, and a paused-clock advance cannot drive them. tokio::time::sleep(TEST_INTERVAL * 4).await; assert_eq!( state.requests.load(Ordering::Relaxed), diff --git a/crates/workshop/server/tests/it/main.rs b/crates/workshop/server/tests/it/main.rs index 6e782a60a..cb8eac5bd 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/realtime_relay/overload.rs b/crates/workshop/server/tests/it/realtime_relay/overload.rs index c98cb27b5..767c70edb 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/server/tests/it/save_timeout.rs b/crates/workshop/server/tests/it/save_timeout.rs new file mode 100644 index 000000000..76615f89f --- /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/server/tests/it/session.rs b/crates/workshop/server/tests/it/session.rs index 4f00db590..aa48f153b 100644 --- a/crates/workshop/server/tests/it/session.rs +++ b/crates/workshop/server/tests/it/session.rs @@ -69,16 +69,7 @@ async fn spawn_session_server(base_url: &str) -> (String, tempfile::TempDir, App // Discovery is bypassed: a test never consults the real run directory. let gateway = ResolvedGateway::from_config(&config.gateway); let state = state_with_gateway(&config, &gateway).expect("state builds in tests"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the session test server"); - let addr = listener.local_addr().expect("session test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("session test server serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(router(state.clone())).await; (format!("ws://{addr}/ws"), state_dir, state) } diff --git a/crates/workshop/server/tests/it/session/menu/restart.rs b/crates/workshop/server/tests/it/session/menu/restart.rs index e8162c6e4..251bde7ca 100644 --- a/crates/workshop/server/tests/it/session/menu/restart.rs +++ b/crates/workshop/server/tests/it/session/menu/restart.rs @@ -155,16 +155,7 @@ async fn spawn_sidecar_server( None => state_with_gateway(&config, &resolved), } .expect("state builds in tests"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the session test server"); - let addr = listener.local_addr().expect("session test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("session test server serves"); - }); + let (addr, _handle) = workshop_support::fixtures::serve(router(state.clone())).await; (format!("ws://{addr}/ws"), state_dir, state, gateway) } diff --git a/crates/workshop/server/tests/it/user_state.rs b/crates/workshop/server/tests/it/user_state.rs index 7f36aae40..1acc29d42 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/shell/README.md b/crates/workshop/shell/README.md deleted file mode 100644 index 96b830a8a..000000000 --- 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/shell/src/gateway/supervisor.rs b/crates/workshop/shell/src/gateway/supervisor.rs deleted file mode 100644 index 03c760796..000000000 --- a/crates/workshop/shell/src/gateway/supervisor.rs +++ /dev/null @@ -1,751 +0,0 @@ -//! Continuous local Gateway supervision and recovery. - -use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Condvar, Mutex, PoisonError}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use gateway_api_discovery::{ - CancellationToken, GatewayDiscoveryFile, LaunchDecision, Resolution, ShutdownError, - SidecarError, ValidatedConnection, -}; - -use super::boot; -use super::identity::{GatewayAttachment, same_gateway_identity}; - -/// Healthy-sidecar supervision cadence. -const SUPERVISION_INTERVAL: Duration = Duration::from_secs(5); - -/// First delay after a failed re-resolution or relaunch. -const SUPERVISION_BASE_DELAY: Duration = Duration::from_millis(250); - -/// Ceiling on repeated sidecar recovery attempts. -pub(super) const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); - -/// Maximum designed supervisor shutdown latency. -const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); - -/// Budget for recovery launch-race and readiness phases. -const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); - -/// Separate bound for authenticated cleanup of an unpublished owned child. -const LATE_CHILD_SHUTDOWN_BUDGET: Duration = Duration::from_secs(1); - -/// Delay between recovery readiness polls. -const RECOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// One sidecar liveness observation. -pub(super) enum SupervisionProbe { - /// Another process already published a live replacement. - Replacement(Identity), - /// No live local Gateway is currently discoverable. - Missing, -} - -/// A validated identity retained across supervision classifications. -pub(super) trait SupervisedGatewayIdentity { - /// Whether both values prove the same process boot. - fn same_boot(&self, other: &Self) -> bool; - - /// Disarms cleanup after this identity becomes authoritative. - fn publication_succeeded(&mut self) {} - - /// Shuts down an unpublished owned child through the explicit, - /// error-reporting path. Identities that own no child do nothing. - fn shutdown_unpublished(self) - where - Self: Sized, - { - } -} - -/// A validated recovery process whose pid proves it is the child we spawned. -#[derive(Debug)] -pub(crate) struct RecoveryCandidate { - child_pid: u32, - validated: ValidatedConnection, - published: bool, -} - -pub(super) enum RecoveryOwnership { - Owned(RecoveryCandidate), - Unowned(ValidatedConnection), -} - -impl RecoveryCandidate { - /// Claims cleanup authority only when validation names the spawned pid. - pub(super) fn authenticate( - child_pid: u32, - validated: ValidatedConnection, - ) -> RecoveryOwnership { - if validated.pid() != child_pid { - return RecoveryOwnership::Unowned(validated); - } - RecoveryOwnership::Owned(Self { - child_pid, - validated, - published: false, - }) - } - - pub(super) fn validated(&self) -> &ValidatedConnection { - &self.validated - } - - pub(super) fn published(&mut self) { - self.published = true; - } - - /// Shuts down the unpublished recovered child within the late-child - /// budget. - /// - /// This is the blocking, error-reporting path; `Drop` only signals on - /// a detached thread. The drop signal is disarmed either way: the - /// caller receives the outcome, so a failed delivery is reported here - /// rather than retried silently. - pub(super) fn shutdown(mut self) -> Result<(), ShutdownError> { - if self.published { - return Ok(()); - } - debug_assert_eq!(self.child_pid, self.validated.pid()); - self.published = true; - let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; - gateway_api_discovery::request_shutdown_before(&self.validated, deadline) - } -} - -impl Drop for RecoveryCandidate { - fn drop(&mut self) { - if self.published { - return; - } - debug_assert_eq!(self.child_pid, self.validated.pid()); - // Drop can neither block nor report: the bounded authenticated - // request runs on a detached thread, so a missed explicit - // `shutdown()` still signals the unpublished gateway process. - let validated = self.validated.clone(); - let signalled = std::thread::Builder::new() - .name("gateway-late-child-shutdown".to_owned()) - .spawn(move || { - let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; - if let Err(error) = - gateway_api_discovery::request_shutdown_before(&validated, deadline) - { - // The detached signal has no error return channel, so - // diagnostics are the only place this cleanup failure - // can surface. - eprintln!("could not shut down an unpublished recovered gateway: {error}"); - } - }); - if let Err(error) = signalled { - eprintln!("could not signal an unpublished recovered gateway: {error}"); - } - } -} - -impl SupervisedGatewayIdentity for ValidatedConnection { - fn same_boot(&self, other: &Self) -> bool { - same_gateway_identity(self, other) - } -} - -#[derive(Debug)] -pub(super) enum RecoveryIdentity { - Stable(ValidatedConnection), - Candidate(RecoveryCandidate), -} - -impl RecoveryIdentity { - fn validated(&self) -> &ValidatedConnection { - match self { - Self::Stable(validated) => validated, - Self::Candidate(candidate) => candidate.validated(), - } - } -} - -impl SupervisedGatewayIdentity for RecoveryIdentity { - fn same_boot(&self, other: &Self) -> bool { - self.validated().same_boot(other.validated()) - } - - fn publication_succeeded(&mut self) { - if let Self::Candidate(candidate) = self { - candidate.published(); - } - } - - fn shutdown_unpublished(self) { - if let Self::Candidate(candidate) = self - && let Err(error) = candidate.shutdown() - { - eprintln!("could not shut down an unpublished recovered gateway: {error}"); - } - } -} - -/// The running local-sidecar supervisor. -#[derive(Debug)] -pub(crate) struct GatewaySupervisor { - stop: StopSignal, - completion: Completion, - stop_bridge_completion: Completion, - thread: Option>, - stop_bridge: Option>, - publication: Option, - shutdown_budget: Duration, -} - -/// How bounded supervisor shutdown ended. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SupervisorShutdown { - /// The worker completed and joined normally. - Joined, - /// The worker completed but panicked. - Panicked, - /// The deadline elapsed, so the worker handle was detached. - Detached, -} - -/// A stop request that never waits for in-progress supervisor work. -#[derive(Clone, Debug, Default)] -struct StopSignal { - state: Arc, -} - -#[derive(Debug, Default)] -struct StopState { - requested: AtomicBool, - waiter: Mutex<()>, - wake: Condvar, -} - -impl StopSignal { - fn signal(&self) { - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - self.state.requested.store(true, Ordering::SeqCst); - self.state.wake.notify_all(); - drop(waiter); - } - - fn wait(&self) { - if self.state.requested.load(Ordering::SeqCst) { - return; - } - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - drop( - self.state - .wake - .wait_while(waiter, |()| !self.state.requested.load(Ordering::SeqCst)) - .unwrap_or_else(PoisonError::into_inner), - ); - } -} - -#[derive(Clone, Debug, Default)] -struct Completion { - state: Arc, -} - -#[derive(Debug, Default)] -struct CompletionState { - finished: Mutex, - wake: Condvar, -} - -impl Completion { - fn guard(&self) -> CompletionGuard { - CompletionGuard(self.clone()) - } - - fn wait_until(&self, deadline: Instant) -> bool { - let mut finished = self - .state - .finished - .lock() - .unwrap_or_else(PoisonError::into_inner); - loop { - if *finished { - return true; - } - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return false; - }; - if remaining.is_zero() { - return false; - } - let (next, timeout) = self - .state - .wake - .wait_timeout(finished, remaining) - .unwrap_or_else(PoisonError::into_inner); - finished = next; - if timeout.timed_out() && !*finished { - return false; - } - } - } - - #[cfg(test)] - fn wake(&self) { - self.state.wake.notify_all(); - } -} - -struct CompletionGuard(Completion); - -impl Drop for CompletionGuard { - fn drop(&mut self) { - *self - .0 - .state - .finished - .lock() - .unwrap_or_else(PoisonError::into_inner) = true; - self.0.state.wake.notify_all(); - } -} - -impl GatewaySupervisor { - /// Spawns one owned supervisor thread. - #[cfg(test)] - pub(super) fn spawn( - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(None, SUPERVISOR_SHUTDOWN_BUDGET, supervise) - } - - pub(super) fn spawn_with_publication( - publication: workshop_server_api::GatewayUpdater, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(Some(publication), SUPERVISOR_SHUTDOWN_BUDGET, supervise) - } - - #[cfg(test)] - pub(super) fn spawn_with_budget( - shutdown_budget: Duration, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(None, shutdown_budget, supervise) - } - - fn spawn_inner( - publication: Option, - shutdown_budget: Duration, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - let cancellation = CancellationToken::new(); - let stop = StopSignal::default(); - let worker_stop = stop.clone(); - let completion = Completion::default(); - let worker_completion = completion.clone(); - let stop_bridge_completion = Completion::default(); - let bridge_completion = stop_bridge_completion.clone(); - let bridge_cancellation = cancellation.clone(); - let stop_bridge = std::thread::Builder::new() - .name("gateway-supervisor-stop".to_owned()) - .spawn(move || { - let _completion = bridge_completion.guard(); - worker_stop.wait(); - bridge_cancellation.cancel(); - }) - .context("spawn the gateway supervisor stop bridge")?; - let thread = match std::thread::Builder::new() - .name("gateway-supervisor".to_owned()) - .spawn(move || { - let _completion = worker_completion.guard(); - supervise(cancellation); - }) { - Ok(thread) => thread, - Err(source) => { - stop.signal(); - let error = anyhow::Error::new(source).context("spawn the gateway supervisor"); - return match stop_bridge.join() { - Ok(()) => Err(error), - Err(_) => Err(error.context( - "the gateway supervisor stop bridge panicked during spawn rollback", - )), - }; - } - }; - Ok(Self { - stop, - completion, - stop_bridge_completion, - thread: Some(thread), - stop_bridge: Some(stop_bridge), - publication, - shutdown_budget, - }) - } - - /// Revokes publication, requests stop, and waits at most one deadline. - /// - /// This is the blocking, outcome-reporting path; `Drop` only signals - /// and detaches. - pub(crate) fn shutdown(mut self) -> SupervisorShutdown { - self.stop_and_join() - } - - fn stop_and_join(&mut self) -> SupervisorShutdown { - let deadline = Instant::now() + self.shutdown_budget; - if let Some(publication) = self.publication.as_ref() { - publication.close_publication(); - } - self.stop.signal(); - let thread = self.thread.take(); - let stop_bridge = self.stop_bridge.take(); - let (Some(thread), Some(stop_bridge)) = (thread, stop_bridge) else { - return SupervisorShutdown::Joined; - }; - if !self.completion.wait_until(deadline) - || !self.stop_bridge_completion.wait_until(deadline) - { - drop(thread); - drop(stop_bridge); - return SupervisorShutdown::Detached; - } - match (thread.join(), stop_bridge.join()) { - (Ok(()), Ok(())) => SupervisorShutdown::Joined, - (Err(_), _) | (_, Err(_)) => SupervisorShutdown::Panicked, - } - } - - #[cfg(test)] - pub(super) fn wake_completion_for_test(&self) { - self.completion.wake(); - } -} - -impl Drop for GatewaySupervisor { - fn drop(&mut self) { - // `shutdown()` is the bounded, outcome-reporting path. Drop can - // neither wait nor report, so it revokes publication, signals the - // stop, and detaches both threads; the worker captures only owned - // state, so a detached thread finishes on its own. - if let Some(publication) = self.publication.as_ref() { - publication.close_publication(); - } - self.stop.signal(); - drop(self.thread.take()); - drop(self.stop_bridge.take()); - } -} - -/// Starts runtime supervision only for a gateway discovery file sidecar. -/// -/// # Errors -/// Returns an error when the supervisor cannot locate its runtime paths or -/// spawn its owned thread. -pub(crate) fn supervise( - attachment: &GatewayAttachment, - updater: workshop_server_api::GatewayUpdater, -) -> anyhow::Result> { - let Some(initial) = attachment.sidecar_identity().cloned() else { - return Ok(None); - }; - let run_dir = - gateway_api_discovery::default_run_dir().context("locate the sidecar run directory")?; - let exe_dir = std::env::current_exe() - .context("locate the executable")? - .parent() - .map(Path::to_path_buf) - .context("the executable has no parent directory")?; - let sibling = boot::sibling_gateway(&exe_dir); - let supervisor_publication = updater.clone(); - GatewaySupervisor::spawn_with_publication(supervisor_publication, move |cancellation| { - run_supervision( - RecoveryIdentity::Stable(initial), - |_, cancellation| match gateway_api_discovery::resolve_cancellable( - &run_dir, - cancellation, - ) { - Ok(Resolution::Attach(file)) => { - match ValidatedConnection::validate_cancellable(file, cancellation) { - Ok(identity) => { - SupervisionProbe::Replacement(RecoveryIdentity::Stable(identity)) - } - Err(error) => { - eprintln!("could not retain the replacement gateway identity: {error}"); - SupervisionProbe::Missing - } - } - } - Ok(_) | Err(SidecarError::Cancelled) => SupervisionProbe::Missing, - Err(error) => { - eprintln!("could not re-resolve the local gateway: {error}"); - SupervisionProbe::Missing - } - }, - |cancellation| { - let exe = sibling.as_deref().context( - "the local gateway disappeared and no sibling gateway executable is installed", - )?; - let recovery = launch_and_attach_cancellable(&run_dir, exe, cancellation)?; - validate_recovery(recovery, cancellation) - }, - |identity, cancellation| { - if cancellation.is_cancelled() { - anyhow::bail!("gateway publication was cancelled"); - } - if updater.publication_closed() { - anyhow::bail!("gateway publication is closed"); - } - if updater - .replace_sidecar_cancellable(identity.validated(), cancellation) - .context("publish the replacement gateway endpoint")? - { - Ok(()) - } else { - anyhow::bail!("gateway publication was cancelled") - } - }, - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - }) - .map(Some) -} - -/// Validates a recovery result and authenticates child ownership by exact pid. -pub(super) fn validate_recovery( - recovery: boot::RecoveryLaunch, - cancellation: &CancellationToken, -) -> anyhow::Result { - let (child_pid, file) = match recovery { - boot::RecoveryLaunch::Attached(file) => (None, file), - boot::RecoveryLaunch::Launched { child_pid, file } => (Some(child_pid), file), - }; - let validated = ValidatedConnection::validate_cancellable(file, cancellation) - .context("retain the recovered gateway identity")?; - Ok(match child_pid { - Some(child_pid) => match RecoveryCandidate::authenticate(child_pid, validated) { - RecoveryOwnership::Owned(candidate) => RecoveryIdentity::Candidate(candidate), - RecoveryOwnership::Unowned(unowned) => RecoveryIdentity::Stable(unowned), - }, - None => RecoveryIdentity::Stable(validated), - }) -} - -/// Runs the supervision state machine with I/O injected for tests. -pub(super) fn run_supervision( - mut current: Identity, - mut probe: Probe, - mut recover: Recover, - mut publish: Publish, - mut wait: Wait, - cancellation: &CancellationToken, -) where - Identity: SupervisedGatewayIdentity, - Probe: FnMut(&Identity, &CancellationToken) -> SupervisionProbe, - Recover: FnMut(&CancellationToken) -> Result, - Publish: FnMut(&Identity, &CancellationToken) -> Result<(), Error>, - Wait: FnMut(Duration, &CancellationToken) -> bool, - Error: std::fmt::Display, -{ - let mut retry_delay = SUPERVISION_BASE_DELAY; - loop { - if cancellation.is_cancelled() { - return; - } - let observation = probe(¤t, cancellation); - if cancellation.is_cancelled() { - return; - } - match observation { - SupervisionProbe::Replacement(identity) if identity.same_boot(¤t) => { - retry_delay = SUPERVISION_BASE_DELAY; - if wait(SUPERVISION_INTERVAL, cancellation) { - return; - } - continue; - } - SupervisionProbe::Replacement(mut identity) => match publish(&identity, cancellation) { - Ok(()) => { - identity.publication_succeeded(); - if cancellation.is_cancelled() { - return; - } - current = identity; - retry_delay = SUPERVISION_BASE_DELAY; - continue; - } - Err(error) => { - eprintln!("could not publish a replacement local gateway: {error}"); - } - }, - SupervisionProbe::Missing => match recover(cancellation) { - Ok(_) if cancellation.is_cancelled() => return, - Ok(mut identity) => match publish(&identity, cancellation) { - Ok(()) => { - identity.publication_succeeded(); - if cancellation.is_cancelled() { - return; - } - current = identity; - retry_delay = SUPERVISION_BASE_DELAY; - continue; - } - Err(error) => { - eprintln!("could not publish a replacement local gateway: {error}"); - // A recovered child this process launched stays - // unpublished, so it is shut down through the - // explicit, error-reporting path. - identity.shutdown_unpublished(); - } - }, - Err(error) => { - eprintln!("could not recover the local gateway: {error}"); - } - }, - } - if cancellation.is_cancelled() || wait(retry_delay, cancellation) { - return; - } - retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); - } -} - -/// Settles and performs a cancellable recovery launch. -fn launch_and_attach_cancellable( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, -) -> anyhow::Result { - launch_and_attach_cancellable_with( - run_dir, - exe, - cancellation, - gateway_api_discovery::launch_or_attach_cancellable, - |exe, _| boot::spawn_detached(exe), - wait_for_launched_file_cancellable, - ) -} - -/// Recovery launch with each blocking phase injected. -pub(super) fn launch_and_attach_cancellable_with( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, - settle: Settle, - spawn: Spawn, - wait: Wait, -) -> anyhow::Result -where - Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, - Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result, - Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, -{ - match settle(run_dir, RECOVERY_TIMEOUT, cancellation) - .context("settle the gateway launch race")? - { - LaunchDecision::Attach(file) => { - if cancellation.is_cancelled() { - anyhow::bail!("gateway attachment was cancelled"); - } - Ok(boot::RecoveryLaunch::Attached(file)) - } - LaunchDecision::Launch(lock) => { - let child_pid = run_effect_if_active(cancellation, "gateway launch", |cancellation| { - if cancellation.is_cancelled() { - anyhow::bail!("gateway launch was cancelled"); - } - spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) - })?; - let file = wait(run_dir, RECOVERY_TIMEOUT, cancellation)?; - drop(lock); - Ok(boot::RecoveryLaunch::Launched { child_pid, file }) - } - decision => anyhow::bail!("an unknown launch decision: {decision:?}"), - } -} - -/// Linearizes one externally visible recovery effect with cancellation. -pub(super) fn run_effect_if_active( - cancellation: &CancellationToken, - phase: &'static str, - operation: impl FnOnce(&CancellationToken) -> anyhow::Result, -) -> anyhow::Result { - match cancellation.run_if_active(|| operation(cancellation)) { - Some(result) => result, - None => anyhow::bail!("{phase} was cancelled"), - } -} - -/// Waits for a launched recovery Gateway with production probes. -fn wait_for_launched_file_cancellable( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, -) -> anyhow::Result { - wait_for_launched_file_cancellable_with( - run_dir, - timeout, - cancellation, - gateway_api_discovery::wait_for_health_cancellable, - gateway_api_discovery::resolve_cancellable, - ) -} - -/// Recovery readiness wait with health and validation injected. -pub(super) fn wait_for_launched_file_cancellable_with( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, - mut health: Health, - mut resolve: Resolve, -) -> anyhow::Result -where - Health: - FnMut(&str, Duration, &CancellationToken) -> Result<(), gateway_api_discovery::HealthError>, - Resolve: FnMut(&Path, &CancellationToken) -> Result, -{ - let deadline = Instant::now() + timeout; - loop { - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - if let Ok(Some(file)) = GatewayDiscoveryFile::read(run_dir) { - let remaining = deadline.saturating_duration_since(Instant::now()); - let url = format!("http://127.0.0.1:{}", file.port); - health(&url, remaining, cancellation) - .context("the launched gateway did not answer its health probe")?; - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - match resolve(run_dir, cancellation) { - Ok(Resolution::Attach(validated)) => { - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - return Ok(validated); - } - Err(SidecarError::Cancelled) => { - anyhow::bail!("the launched gateway wait was cancelled"); - } - Ok(_) | Err(_) => {} - } - } - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - if Instant::now() >= deadline { - anyhow::bail!( - "the launched gateway wrote no validated gateway discovery file within {timeout:?}" - ); - } - if cancellation.wait_timeout(RECOVERY_POLL_INTERVAL) { - anyhow::bail!("the launched gateway wait was cancelled"); - } - } -} diff --git a/crates/workshop/status/src/handles.rs b/crates/workshop/status/src/handles.rs index e21784be1..447639c52 100644 --- a/crates/workshop/status/src/handles.rs +++ b/crates/workshop/status/src/handles.rs @@ -11,16 +11,27 @@ use workshop_registry::{ use crate::StatusBus; +/// The status subsystem's registration guards: the consumer-side push +/// channel, the producer-side sink, and the bus's state handle. Dropping +/// them deregisters the subsystem. +#[derive(Debug)] +#[must_use = "dropping the registrations deregisters the subsystem"] +pub struct StatusRegistrations { + /// The consumer-side push channel every `/ws` session subscribes through. + pub channel: Registration, + /// The producer-side sink same-tier subsystems emit through. + pub sink: Registration, + /// The bus itself as the subsystem's state handle. + pub state: Registration, +} + /// Registers the status subsystem into the registry: the consumer-side /// push channel every `/ws` session subscribes through, the /// producer-side sink same-tier subsystems emit through, and the bus /// itself as the subsystem's state handle. The returned guards keep the /// registrations alive; the composition root holds them for the process /// lifetime. -pub fn register( - registry: &Registry, - bus: &StatusBus, -) -> (Registration, Registration, Registration) { +pub fn register(registry: &Registry, bus: &StatusBus) -> StatusRegistrations { let channel = registry.register_state::(Arc::new(StatusChannelAdapter::new( { @@ -37,5 +48,9 @@ pub fn register( move |update| bus.emit(update) }))); let state = registry.register_state::(Arc::new(bus.clone())); - (channel, sink, state) + StatusRegistrations { + channel, + sink, + state, + } } diff --git a/crates/workshop/status/src/lib.rs b/crates/workshop/status/src/lib.rs index 8b37304c6..300439cd4 100644 --- a/crates/workshop/status/src/lib.rs +++ b/crates/workshop/status/src/lib.rs @@ -6,7 +6,7 @@ //! ## Invariants //! //! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, -//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! `workshop-support`. Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - Sending on the bus never blocks: a send with no subscribers is a @@ -23,5 +23,5 @@ pub mod status; pub mod handles; -pub use handles::register; +pub use handles::{StatusRegistrations, register}; pub use status::StatusBus; diff --git a/crates/workshop/status/src/status.rs b/crates/workshop/status/src/status.rs index 0d5fcc61d..cdcaac79b 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/crates/workshop/support/Cargo.toml b/crates/workshop/support/Cargo.toml index d0ea45372..44ee23bce 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 @@ -24,6 +25,7 @@ tracing.workspace = true workspace-hack.workspace = true [dev-dependencies] +reqwest.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } tower.workspace = true diff --git a/crates/workshop/support/src/atomic.rs b/crates/workshop/support/src/atomic.rs index f4fbcdd69..1c1f36855 100644 --- a/crates/workshop/support/src/atomic.rs +++ b/crates/workshop/support/src/atomic.rs @@ -1,5 +1,6 @@ -//! Crash-safe file writes shared by the workspace write endpoint and the -//! menu's model-memory persistence: each write lands in a uniquely named +//! Crash-safe file writes shared by the workspace write endpoint and +//! pointer module, the user-state store, and the menu's model-memory +//! persistence: each write lands in a uniquely named //! sibling temp file, is synced to disk, and is renamed over the target, //! so a crash at any moment leaves either the old contents or the new, //! never a truncation. The startup sweep removes temp files orphaned by diff --git a/crates/workshop/support/src/config.rs b/crates/workshop/support/src/config.rs index 7edcc69dc..c41418899 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/support/src/deadline.rs b/crates/workshop/support/src/deadline.rs index fac1624a6..bc9906f3d 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/error_message-tests.rs b/crates/workshop/support/src/error_message-tests.rs new file mode 100644 index 000000000..b6d033765 --- /dev/null +++ b/crates/workshop/support/src/error_message-tests.rs @@ -0,0 +1,37 @@ +//! Tests for the shared error-message rendering: the production message +//! stays at the error's own `Display` text, the debug message appends the +//! source chain, and the leak flag tracks debug assertions. + +use std::io; + +use super::*; + +/// A nested test error, so the source-chain walk has a cause to append. +#[derive(Debug, thiserror::Error)] +#[error("read failed")] +struct TestError { + /// The injected cause. + #[source] + source: io::Error, +} + +#[test] +fn production_messages_stay_at_the_variant_text() { + let error = TestError { + source: io::Error::other("disk gone"), + }; + assert_eq!(render_message(&error, false), "read failed"); +} + +#[test] +fn debug_messages_append_the_source_chain() { + let error = TestError { + source: io::Error::other("disk gone"), + }; + assert_eq!(render_message(&error, true), "read failed: disk gone"); +} + +#[test] +fn the_leak_flag_tracks_debug_assertions() { + assert_eq!(LEAK_DETAIL, cfg!(debug_assertions)); +} diff --git a/crates/workshop/support/src/error_message.rs b/crates/workshop/support/src/error_message.rs new file mode 100644 index 000000000..17a2b9b6e --- /dev/null +++ b/crates/workshop/support/src/error_message.rs @@ -0,0 +1,36 @@ +//! Error-message rendering shared by the workshop crates: the +//! debug-only source-chain leak flag and the helper that renders an +//! error's envelope message, its own `Display` text with the source +//! chain appended as `: cause` segments in debug builds only. + +use std::fmt::Write as _; + +/// Whether wire bodies include internal failure detail. Debug builds append +/// the source chain to the envelope message; production bodies stay at +/// the variant's own message. +pub const LEAK_DETAIL: bool = cfg!(debug_assertions); + +/// Renders the envelope message for `error`: its own `Display` text, with +/// the source chain appended as `: cause` segments when `leak_detail` is +/// set. +#[must_use] +pub fn render_message(error: &E, leak_detail: bool) -> String +where + E: std::fmt::Display + std::error::Error, +{ + let mut message = error.to_string(); + if leak_detail { + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + // fmt::Write to a String cannot fail; the Result is a trait + // artifact. + let _ = write!(message, ": {cause}"); + source = cause.source(); + } + } + message +} + +#[cfg(test)] +#[path = "error_message-tests.rs"] +mod tests; diff --git a/crates/workshop/support/src/fixtures.rs b/crates/workshop/support/src/fixtures.rs new file mode 100644 index 000000000..92b6e0874 --- /dev/null +++ b/crates/workshop/support/src/fixtures.rs @@ -0,0 +1,73 @@ +//! A mock HTTP server for the dependent crates' tests: binds the caller's +//! router on a free loopback port and serves it in a task. + +// An `allow` rather than an `expect`: whether the lint fires here depends +// on the build's cfg permutation (clippy's allow-expect-in-tests covers +// only `#[cfg(test)]` code, not this `test-fixtures`-gated module), so an +// expectation would be unfulfilled in some builds and fail the -D warnings +// gate. +#![allow( + clippy::expect_used, + reason = "test fixtures fail by panicking with the invariant named" +)] + +use std::net::SocketAddr; + +use axum::Router; +use tokio::task::JoinHandle; + +/// Binds `router` on a free loopback port and serves it in a task, +/// returning the bound address and the task handle. Drop the handle to +/// leave the server running for the test's lifetime, or abort it to stop +/// the server early. +/// +/// # Panics +/// Panics when the loopback bind fails or the bound address cannot be +/// read. +pub async fn serve(router: Router) -> (SocketAddr, JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock server"); + let addr = listener.local_addr().expect("mock server address"); + let handle = tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("mock server serves"); + }); + (addr, handle) +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::routing::get; + + /// Fetches the body of a GET to `url`, panicking on transport or + /// status errors. + async fn get_text(url: &str) -> String { + let response = reqwest::get(url) + .await + .expect("the request reaches the mock server"); + response.text().await.expect("the response body reads") + } + + #[tokio::test] + async fn a_mock_server_serves_its_router_on_the_reported_address() { + let (addr, _handle) = serve(Router::new().route("/probe", get(|| async { "pong" }))).await; + assert!(addr.ip().is_loopback(), "the bound address is loopback"); + assert_ne!(addr.port(), 0, "the bound address has a real port"); + let body = get_text(&format!("http://{addr}/probe")).await; + assert_eq!(body, "pong", "the served router answers the request"); + } + + #[tokio::test] + async fn the_returned_handle_stops_the_server_when_aborted() { + let (_addr, handle) = serve(Router::new().route("/", get(|| async { "up" }))).await; + handle.abort(); + assert!( + handle.await.unwrap_err().is_cancelled(), + "the task handle stops the server when aborted" + ); + } +} diff --git a/crates/workshop/support/src/lib.rs b/crates/workshop/support/src/lib.rs index 3667935a1..a7a4f1db4 100644 --- a/crates/workshop/support/src/lib.rs +++ b/crates/workshop/support/src/lib.rs @@ -1,13 +1,14 @@ //! workshop-support - the workshop server's support vocabulary: //! crash-safe atomic writes, the gateway reconnect backoff, route -//! deadline tiers, `workshop.toml` configuration, and the generic -//! retained broadcast bus the status, catalog, and menu buses are thin -//! wrappers over. +//! deadline tiers, `workshop.toml` configuration, the generic retained +//! broadcast bus the status, catalog, and menu buses are thin wrappers +//! over, the shared error-message rendering, and the JSON state-bucket +//! validator the user-state and workspace buckets both use. //! //! ## Invariants //! //! - Tier: vocabulary; may depend on: no internal `workshop-*` crates. -//! Read `AGENTS.md` before adding an import. +//! Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - A lock poisoned by a panicking peer recovers the value rather than @@ -18,6 +19,10 @@ mod backoff; mod bus; mod config; mod deadline; +mod error_message; +#[cfg(feature = "test-fixtures")] +pub mod fixtures; +mod state_bucket; pub use atomic::{sweep_orphaned_temps, write_atomic}; pub use backoff::{ReconnectBackoff, xorshift}; @@ -26,4 +31,11 @@ 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, +}; +pub use error_message::{LEAK_DETAIL, render_message}; +pub use state_bucket::{ + StateBucketError, check_bucket_cap, check_bucket_text, resolve_bucket_key, validate_bucket_body, +}; diff --git a/crates/workshop/support/src/state_bucket-tests.rs b/crates/workshop/support/src/state_bucket-tests.rs new file mode 100644 index 000000000..31557525f --- /dev/null +++ b/crates/workshop/support/src/state_bucket-tests.rs @@ -0,0 +1,86 @@ +//! Tests for the shared state-bucket validator: each refusal, an accepted +//! body, and the cheapest-refusal-first order the route boundary promises. + +use super::*; + +/// A two-key allow-list for the tests. +const KEYS: [&str; 2] = ["alpha", "beta"]; +/// A small cap so the over-cap fixtures stay short. +const CAP: usize = 16; + +#[test] +fn a_known_key_resolves_to_its_allow_list_entry() { + assert_eq!( + resolve_bucket_key("beta", &KEYS).expect("beta is allowed"), + "beta" + ); +} + +#[test] +fn an_unknown_key_is_refused() { + assert!(matches!( + resolve_bucket_key("gamma", &KEYS), + Err(StateBucketError::Key(ref key)) if key == "gamma" + )); +} + +#[test] +fn a_body_at_the_cap_is_accepted() { + assert!(check_bucket_cap(CAP, CAP).is_ok()); +} + +#[test] +fn a_body_past_the_cap_is_refused() { + assert!(matches!( + check_bucket_cap(CAP + 1, CAP), + Err(StateBucketError::TooLarge { actual, cap }) + if actual == CAP + 1 && cap == CAP + )); +} + +#[test] +fn text_that_parses_is_accepted() { + assert!(check_bucket_text(r#"{"n":1}"#).is_ok()); +} + +#[test] +fn text_that_does_not_parse_is_refused() { + assert!(matches!( + check_bucket_text("{ not json"), + Err(StateBucketError::NotJson { .. }) + )); +} + +#[test] +fn a_valid_body_parses_to_its_value() { + let value = validate_bucket_body("alpha", &KEYS, br#"{"n":1}"#, CAP).expect("a valid body"); + assert_eq!(value, serde_json::json!({ "n": 1 })); +} + +#[test] +fn a_non_json_body_is_refused() { + assert!(matches!( + validate_bucket_body("alpha", &KEYS, b"{", CAP), + Err(StateBucketError::NotJson { .. }) + )); +} + +#[test] +fn the_key_is_judged_before_the_body() { + // A foreign key wins over a body that is also invalid. + assert!(matches!( + validate_bucket_body("gamma", &KEYS, b"{", CAP), + Err(StateBucketError::Key(_)) + )); +} + +#[test] +fn the_size_is_judged_before_the_shape() { + // A body past the cap is refused as too-large even though it also + // fails to parse. + let oversized = vec![b'x'; CAP + 1]; + assert!(matches!( + validate_bucket_body("alpha", &KEYS, &oversized, CAP), + Err(StateBucketError::TooLarge { .. }) + )); +} diff --git a/crates/workshop/support/src/state_bucket.rs b/crates/workshop/support/src/state_bucket.rs new file mode 100644 index 000000000..56c69c50a --- /dev/null +++ b/crates/workshop/support/src/state_bucket.rs @@ -0,0 +1,91 @@ +//! The JSON state-bucket validator shared by the workshop's two persisted +//! ui-state buckets (the account-scoped user-state and the workspace +//! file): one allow-list check on the key, one size cap, and one JSON +//! parse, returning a support-level refusal the caller maps onto its own +//! wire error so the wire code and message stay the caller's. + +use serde_json::Value; + +/// A state-bucket refusal: one variant per way a put is refused. The +/// bucket crates map each onto their own wire error, so the wire code +/// and message stay the bucket's. +#[derive(Debug, thiserror::Error)] +pub enum StateBucketError { + /// The key is outside the allow-list. + #[error("state-bucket key {0:?} is not allowed")] + Key(String), + + /// The value's JSON text exceeds the cap. + #[error("state-bucket value is {actual} bytes; at most {cap} bytes are allowed")] + TooLarge { + /// The size of the refused value. + actual: usize, + /// The cap it exceeded. + cap: usize, + }, + + /// The value does not parse as JSON. + #[error("state-bucket value is not JSON")] + NotJson { + /// The parse refusal. + #[source] + source: serde_json::Error, + }, +} + +/// Resolves `key` to its allow-list entry. +/// +/// # Errors +/// Returns [`StateBucketError::Key`] when `key` is not in `allowed`. +pub fn resolve_bucket_key<'a>(key: &str, allowed: &[&'a str]) -> Result<&'a str, StateBucketError> { + allowed + .iter() + .copied() + .find(|allowed| *allowed == key) + .ok_or_else(|| StateBucketError::Key(key.to_owned())) +} + +/// Checks that a value whose JSON text is `actual` bytes fits under `cap`. +/// +/// # Errors +/// Returns [`StateBucketError::TooLarge`] past `cap`. +pub fn check_bucket_cap(actual: usize, cap: usize) -> Result<(), StateBucketError> { + if actual > cap { + return Err(StateBucketError::TooLarge { actual, cap }); + } + Ok(()) +} + +/// Checks that `text` parses as JSON, without building a value; the +/// file-backed bucket stores text verbatim and needs only the validity +/// check. +/// +/// # Errors +/// Returns [`StateBucketError::NotJson`] for text that does not parse. +pub fn check_bucket_text(text: &str) -> Result<(), StateBucketError> { + serde_json::from_str::(text) + .map(|_| ()) + .map_err(|source| StateBucketError::NotJson { source }) +} + +/// Validates one state-bucket put, in cheapest-refusal-first order: the +/// key against `allowed`, the body's size against `cap`, then the body's +/// shape. Returns the parsed value. +/// +/// # Errors +/// Returns [`StateBucketError::Key`], [`StateBucketError::TooLarge`], or +/// [`StateBucketError::NotJson`] for a refused input. +pub fn validate_bucket_body( + key: &str, + allowed: &[&str], + body: &[u8], + cap: usize, +) -> Result { + resolve_bucket_key(key, allowed)?; + check_bucket_cap(body.len(), cap)?; + serde_json::from_slice(body).map_err(|source| StateBucketError::NotJson { source }) +} + +#[cfg(test)] +#[path = "state_bucket-tests.rs"] +mod tests; diff --git a/crates/workshop/ui/AGENTS.md b/crates/workshop/ui/AGENTS.md index 6e51f4a0b..c3e4ee00c 100644 --- a/crates/workshop/ui/AGENTS.md +++ b/crates/workshop/ui/AGENTS.md @@ -2,7 +2,7 @@ TypeScript UI package at `crates/workshop/ui/`, a sibling of the `crates/workshop/server/` crate that builds and serves it: workshop chrome, agent controls, and the SPA served by workshop-server. `workshop-server`'s `build.rs` bundles it through `build-ui::build_sibling("../ui", ...)`. -- `src/` has three layers: `base/` (lifecycle, events, paths, the `WorkshopPart` base class), `services/` (DOM-free registries and services), and `parts/` (the feature directories - every panel extends `base/workshop-part.ts`, hence the name). Imports flow from `parts` through `services` to `base`, never in reverse. `main.ts` is the composition root and nothing imports it. +- `src/` has three layers: `base/` (lifecycle, events, paths, the `WorkshopPart` base class), `services/` (DOM-free registries and services), and `parts/` (the feature directories - every panel extends `base/workshop-part.ts`, hence the name). Imports flow from `parts` through `services` to `base`, never in reverse. `main.ts` is the composition root and nothing imports it. A service token and its interface types always live in `services/`, even when the implementing widget is DOM-bound and stays in `parts/` (the status bar, the closed-editor stack, the editor-settings and quick-input services): consumers import the token from `services/`, never from `parts/`. - Shared state lives in a service with a change emitter, constructed once at the composition root and passed through constructors. Do not store application state in mutable module globals. - Workshop agent controls target Cursor's workspace-sidebar agent surface, not the Glass Agents Window or editor-tab agent. Workbench chrome uses VS Code theme tokens, and Cursor-native controls use the shared Cursor design tokens. @@ -10,7 +10,7 @@ TypeScript UI package at `crates/workshop/ui/`, a sibling of the `crates/worksho The workbench follows VS Code's mechanics: a command registry, a menu registry keyed by `MenuId`, a keybinding registry with a pure chord resolver, a context-key service with a `when` expression language, and a prefix-keyed quick-access registry. -- Registries live in `services/` and are DOM-free. `Commands`, `Menus`, `KeybindingsRegistry`, and `QuickAccessRegistry` are module-level singletons; contribution files write to them at module scope, before any service exists. They hold registration data, not application state - the no-mutable-globals rule above still governs state. `ContextKeyService`, `QuickInputService`, `EditorSettingsService`, `TextControlService`, and `RecentFilesStore` are services with tokens in `services/service-registry.ts`, resolved with `getService`. +- Registries live in `services/` and are DOM-free. `Commands`, `Menus`, `KeybindingsRegistry`, and `QuickAccessRegistry` are module-level singletons; contribution files write to them at module scope, before any service exists. They hold registration data, not application state - the no-mutable-globals rule above still governs state. `ContextKeyService`, `QuickInputService`, `EditorSettingsService`, `TextControlService`, and `RecentFilesStore` are services with tokens in `services/service-registry.ts`, resolved with `getService`. The DOM-bound services keep their implementations in `parts/`, but their tokens and interface types live in `services/`: `STATUS_BAR` (`services/status-bar.ts`), `CLOSED_EDITORS` (`services/closed-editors.ts`), `EDITOR_SETTINGS_SERVICE` (`services/editor-settings-service.ts`), and `QUICK_INPUT_SERVICE` (`services/quick-input-service.ts`). - Register work through `registerAction` (`services/action-registry.ts`). One descriptor fans out into the command registry (with metadata), one menu row per `menu` entry plus a Command Palette row when `f1` is set, and the keybinding rule with `precondition` ANDed into its `when`. Every `when`/`precondition`/`toggled`/keybinding string is parsed once at registration; a malformed string comes back as a `ParseError` value, never a throw at render. - Command ids and context-key names are VS Code's, verbatim: `workbench.action.files.save`, `workbench.view.explorer`, `editor.action.clipboardCutAction`, and context keys `editorTextFocus`, `inputFocus`, `textInputFocus`, `sideBarVisible`, `auxiliaryBarVisible`, `statusBarVisible`, `isFullscreen`, `isWeb`, `activeEditor`, `editorLangId`, `chordPending`, `config.editor.*`. Reuse an existing id or key before inventing one; Cursor-only rows with no public id use the `workbench.action.*` namespace. - Keybindings are chord strings (`"ctrlcmd+s"`, `"ctrl+m ctrl+o"`). `ctrlcmd` resolves to Cmd on macOS and Ctrl elsewhere; rules may include `mac`/`linux` overrides. Every Ctrl-based chord binds `ctrlcmd`. diff --git a/crates/workshop/ui/build.mjs b/crates/workshop/ui/build.mjs index 71f18b519..7ffef069d 100644 --- a/crates/workshop/ui/build.mjs +++ b/crates/workshop/ui/build.mjs @@ -49,7 +49,7 @@ const STATIC_FILES = [ // Code splitting is on: the panel registry's import thunks (the agent // session's Shiki/TipTap graph, the editor's CodeMirror) become lazily // loaded chunks under dist/chunks/, and the initial bundle holds only -// the boot shell, services, and chrome. Every bundle file is +// the entry bundle, services, and chrome. Every bundle file is // content-hashed (the entry under bundle/, the chunks under chunks/), so // the server can mark them Cache-Control: immutable; dist/manifest.json // maps the logical names (app.js, app.css) to the hashed files, and the diff --git a/crates/workshop/ui/index.html b/crates/workshop/ui/index.html index fb7f659e6..afb3640c5 100644 --- a/crates/workshop/ui/index.html +++ b/crates/workshop/ui/index.html @@ -38,7 +38,7 @@
-
+
diff --git a/crates/workshop/ui/src/main.ts b/crates/workshop/ui/src/main.ts index bf97c1dfb..ed0257711 100644 --- a/crates/workshop/ui/src/main.ts +++ b/crates/workshop/ui/src/main.ts @@ -12,6 +12,10 @@ import { createToastStack } from "shared-ui/toast"; import { DisposableStore, toDisposable } from "./base/lifecycle"; import { ModelService, MODEL_SERVICE } from "./services/model-service"; +import { CLOSED_EDITORS } from "./services/closed-editors"; +import { EDITOR_SETTINGS_SERVICE } from "./services/editor-settings-service"; +import { QUICK_INPUT_SERVICE } from "./services/quick-input-service"; +import { STATUS_BAR } from "./services/status-bar"; import { RECENT_FILES_STORE, RecentFilesStore } from "./services/recent-files-store"; import { getService, registerService } from "./services/service-registry"; import { SpeechCaptureService, SPEECH_CAPTURE } from "./services/speech-capture"; @@ -22,16 +26,16 @@ import { UpdateService } from "./services/update-service"; import { WorkbenchService } from "./services/workbench-service"; import { WorkshopSocket } from "./services/workshop-socket"; import { CommandCenter } from "./parts/chrome/command-center"; -import { CLOSED_EDITORS, ClosedEditors } from "./parts/editor/closed-editors"; -import { EDITOR_SETTINGS_SERVICE, EditorSettingsService } from "./parts/editor/editor-settings-service"; +import { ClosedEditors } from "./parts/editor/closed-editors"; +import { EditorSettingsService } from "./parts/editor/editor-settings-service"; import { setupGatewayConfigBridge } from "./parts/gateway/gateway-config-bridge"; -import { StatusBar, STATUS_BAR } from "./parts/status/status-bar"; +import { StatusBar } from "./parts/status/status-bar"; import { UpdateView } from "./parts/chrome/update-view"; import { setupWindowChrome } from "./parts/chrome/window-chrome"; import { setupWindowMenus } from "./parts/menu/index"; import { KeybindingDispatcher } from "./parts/layout/keybinding-dispatcher"; import { COMMANDS_HISTORY, CommandsHistory } from "./parts/quickinput/commands-history"; -import { QuickInputService, QUICK_INPUT_SERVICE } from "./parts/quickinput/quick-input"; +import { QuickInputService } from "./parts/quickinput/quick-input"; import { setupWorkspaceDrops } from "./parts/workspace/workspace-drops"; import { register as registerWorkspaceFiles } from "./parts/workspace-files/index"; import { persistZoom, restoreZoom } from "./parts/chrome/zoom"; @@ -117,7 +121,7 @@ registerService( // updates the status bar renders as they arrive, catalog pushes, and // workbench snapshots. Chat goes over the agent panel's own /agents/ws // socket, composed inside the panel. The status bar builds its own -// shell (shared-ui) and appends it as the body's full-width footer. +// view (shared-ui) and appends it as the body's full-width footer. const statusBar = disposables.add(new StatusBar()); const updates = disposables.add(new UpdateService()); // The shared toast stack shows the update notifications; the workshop @@ -128,14 +132,14 @@ disposables.add(toDisposable(() => toasts.element.remove())); disposables.add(new UpdateView(updates, toasts)); updates.startAutoCheck(); // The custom title bar stays hidden in a plain browser; it only appears -// when the desktop shell sets its initialization flag. +// when the desktop app sets its initialization flag. disposables.add(setupWindowChrome()); // Native webview zoom does not persist across sessions, so the stored // factor is re-applied on every boot from the user bucket; the writer // installs after the restore so the restore never echoes the factor back. restoreZoom(storage.get("user", "zoom")); disposables.add(persistZoom((value) => storage.set("user", "zoom", value))); -// Native Explorer drops arrive as a typed event from the desktop shell; +// Native Explorer drops arrive as a typed event from the desktop app; // each path becomes a workspace grant. Inert in a plain browser. disposables.add(setupWorkspaceDrops(statusBar)); // The Gateway Config panel's postMessage bridge: API forwards go through diff --git a/crates/workshop/ui/src/parts/agent/index.ts b/crates/workshop/ui/src/parts/agent/index.ts index 956ff01cb..5554e8c9f 100644 --- a/crates/workshop/ui/src/parts/agent/index.ts +++ b/crates/workshop/ui/src/parts/agent/index.ts @@ -7,7 +7,7 @@ import { MODEL_SERVICE } from "../../services/model-service"; import { getServiceOrNull } from "../../services/service-registry"; import { SPEECH_CAPTURE } from "../../services/speech-capture"; import type { IDisposable } from "../../base/lifecycle"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { AgentPanel } from "./agent-panel"; import { markdownReady } from "./markdown-render"; diff --git a/crates/workshop/ui/src/parts/chrome/chrome.contribution.ts b/crates/workshop/ui/src/parts/chrome/chrome.contribution.ts index 7b04045c9..96be4fbd5 100644 --- a/crates/workshop/ui/src/parts/chrome/chrome.contribution.ts +++ b/crates/workshop/ui/src/parts/chrome/chrome.contribution.ts @@ -114,9 +114,9 @@ addAction({ }); // File > Exit (plan step 20's menu assembly; the catalog's chrome row). -// The run body invokes the shell's quit command - the same +// The run body invokes the desktop app's quit command - the same // gateway-shutdown-then-exit path the native menu's quit item runs - -// which the shell step lands in promptforge/crates/workshop; until then +// which the desktop app step lands in promptforge/crates/workshop; until then // an activation rejects and surfaces on the status bar, never // plugin-process exit(0), which would strand the sidecar gateway. // Desktop-only: the !isWeb precondition disables the row in a browser. diff --git a/crates/workshop/ui/src/parts/chrome/command-center.ts b/crates/workshop/ui/src/parts/chrome/command-center.ts index 068be0414..81a8ca58e 100644 --- a/crates/workshop/ui/src/parts/chrome/command-center.ts +++ b/crates/workshop/ui/src/parts/chrome/command-center.ts @@ -27,7 +27,7 @@ import { CommandRegistry, Commands } from "../../services/command-registry"; import { MenuId, Menus, type MenuItem, type MenuRegistry } from "../../services/menu-registry"; import { getService, getServiceOrNull } from "../../services/service-registry"; import { TREE_STATE } from "../../services/tree-state-service"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { WORKSPACE_CHANGED_EVENT } from "../workspace/workspace-drops"; /** The title shown when no workspace folder is granted. */ diff --git a/crates/workshop/ui/src/parts/chrome/window-chrome.css b/crates/workshop/ui/src/parts/chrome/window-chrome.css index f46ce5134..6b8820bc5 100644 --- a/crates/workshop/ui/src/parts/chrome/window-chrome.css +++ b/crates/workshop/ui/src/parts/chrome/window-chrome.css @@ -7,7 +7,7 @@ [hidden] rule must keep winning over the base rule's display:flex, which the attribute selector's extra specificity guarantees. The bar is a fixed-height row in the body's column flex layout - icon, menus, drag - region, then the three window controls in Windows order - and the shell + region, then the three window controls in Windows order - and the desk below absorbs it without overlap. Hover states flip instantly like native chrome, so nothing here animates. -------------------------------------------------------------------------- */ @@ -37,7 +37,7 @@ margin-inline: var(--space-lg) var(--space-xs); } -/* macOS overlay chrome (the shell's titleBarStyle Overlay + hiddenTitle): +/* macOS overlay chrome (the desktop app's titleBarStyle Overlay + hiddenTitle): the native traffic lights float over the bar's left edge, so the icon and menus shift right clear of them - VS Code's hiddenInset spacing. window-chrome.ts adds the modifier class only in the desktop app. */ diff --git a/crates/workshop/ui/src/parts/chrome/window-chrome.ts b/crates/workshop/ui/src/parts/chrome/window-chrome.ts index 64b44dec1..878085243 100644 --- a/crates/workshop/ui/src/parts/chrome/window-chrome.ts +++ b/crates/workshop/ui/src/parts/chrome/window-chrome.ts @@ -100,7 +100,7 @@ export function setupWindowChrome(): IDisposable { controls.hidden = true; return store; } - // macOS overlay chrome: the shell runs the window with titleBarStyle + // macOS overlay chrome: the desktop app runs the window with titleBarStyle // Overlay and a hidden title, so the native traffic lights float over // the bar's left edge and cover close/minimize/zoom. The custom // Windows-style cluster would double them, so it hides, and the bar diff --git a/crates/workshop/ui/src/parts/editor/closed-editors.ts b/crates/workshop/ui/src/parts/editor/closed-editors.ts index 36cf1d268..dcc7f4685 100644 --- a/crates/workshop/ui/src/parts/editor/closed-editors.ts +++ b/crates/workshop/ui/src/parts/editor/closed-editors.ts @@ -20,21 +20,12 @@ // from editor-lifecycle.ts, so main.ts can bind the token without pulling // the editor chunk into the initial bundle. -import { createServiceToken, registerService } from "../../services/service-registry"; +import { CLOSED_EDITORS, type ClosedEditor, type ClosedEditorsSnapshot } from "../../services/closed-editors"; +import { registerService } from "../../services/service-registry"; /** The most closed editors the stack retains; older entries drop. */ const MAX_CLOSED_EDITORS = 50; -/** One closed editor: a file to reopen by path, or an untitled buffer's text. */ -export type ClosedEditor = - | { readonly kind: "file"; readonly path: string } - | { readonly kind: "untitled"; readonly text: string }; - -/** The persisted shape: file paths only, most recent first. */ -export interface ClosedEditorsSnapshot { - readonly paths: string[]; -} - /** The writer the stack hands `{ paths: [...] }` to after a change. */ export type ClosedEditorsWriter = (value: unknown) => void; @@ -141,9 +132,6 @@ export class ClosedEditors { } } -/** The registry token for the closed-editor stack singleton. */ -export const CLOSED_EDITORS = createServiceToken("workshop.closedEditors"); - // Self-registration with an empty stack and a no-op writer: a consumer // that resolves the token before the composition root re-registers it // bound to the live adapter gets a working, unpersisted stack rather than diff --git a/crates/workshop/ui/src/parts/editor/editor-lifecycle.ts b/crates/workshop/ui/src/parts/editor/editor-lifecycle.ts index a8b59549b..0fab78eff 100644 --- a/crates/workshop/ui/src/parts/editor/editor-lifecycle.ts +++ b/crates/workshop/ui/src/parts/editor/editor-lifecycle.ts @@ -17,7 +17,7 @@ import { DisposableStore, type IDisposable } from "../../base/lifecycle"; import { CONTEXT_KEY_SERVICE } from "../../services/context-key-service"; import { getService } from "../../services/service-registry"; import { openInZone } from "../layout/zones"; -import { CLOSED_EDITORS } from "./closed-editors"; +import { CLOSED_EDITORS } from "../../services/closed-editors"; import { asEditor } from "./editor-commands"; import { onDidInitEditorPanel } from "./editor-panel"; diff --git a/crates/workshop/ui/src/parts/editor/editor-panel.ts b/crates/workshop/ui/src/parts/editor/editor-panel.ts index cf398732c..3019dbd08 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,15 @@ 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. The write itself + * routes through writeCurrent, the same path overwrite() uses, so both + * record the 408 and 409 outcomes identically. 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 +192,33 @@ 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); - this.token = written.token; - this.surface.markSaved(text); + // 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; + } + await this.writeCurrent(this.path, text, expectedToken); } 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 +250,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); @@ -287,10 +316,37 @@ export class EditorPanel extends WorkshopPart { return this.deps.writeFile ?? writeFile; } + /** + * Writes this panel's own file and records the outcome: the token, the + * saved baseline, the text a timed-out write may have landed, the 408 + * message, and the 409 conflict dialog. Any other error is rethrown. A + * 408 marks `this.path`'s token unknown, so a write to any other path + * (Save As) must not route through here. + */ + private async writeCurrent(path: string, text: string, expectedToken: string | null): Promise { + this.lastSentText = text; + try { + const written = await this.writer()(path, text, expectedToken); + this.token = written.token; + this.tokenUnknown = false; + this.surface.markSaved(text); + } catch (error: unknown) { + 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 { + throw error; + } + } + } + /** Loads the document into the surface and records its conflict token. */ 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 }); } @@ -400,8 +456,10 @@ export class EditorPanel extends WorkshopPart { /** * Overwrite path of the conflict dialog: re-read the file for its fresh - * token, then write the editor's text against it. A second conflict - * (the file changed again in between) reopens the dialog. + * token, then write the editor's text against it through writeCurrent, + * the same path save() uses. A second conflict (the file changed again + * in between) reopens the dialog, and a 408 leaves the token unknown + * for the next save to reconcile. */ private async overwrite(): Promise { // The saving guard cannot wedge the conflict flow: the dialog's @@ -414,9 +472,7 @@ export class EditorPanel extends WorkshopPart { try { const fresh = await this.reader()(this.path); const text = this.surface.text(); - const written = await this.writer()(this.path, text, fresh.token); - this.token = written.token; - this.surface.markSaved(text); + await this.writeCurrent(this.path, text, fresh.token); } catch (error: unknown) { if (isModifiedConflict(error)) { this.showConflictDialog(); diff --git a/crates/workshop/ui/src/parts/editor/editor-settings-service.ts b/crates/workshop/ui/src/parts/editor/editor-settings-service.ts index 0e65b8c35..e25659f85 100644 --- a/crates/workshop/ui/src/parts/editor/editor-settings-service.ts +++ b/crates/workshop/ui/src/parts/editor/editor-settings-service.ts @@ -24,29 +24,14 @@ import type { Event } from "../../base/event"; import type { IDisposable } from "../../base/lifecycle"; import { ContextKeyService, CONTEXT_KEY_SERVICE } from "../../services/context-key-service"; import type { ContextKey } from "../../services/context-key-service"; -import { createServiceToken, getServiceOrNull, registerService } from "../../services/service-registry"; - -/** The four editor settings, one boolean per toggle action. */ -export interface EditorSettings { - readonly wordWrap: boolean; - readonly renderWhitespace: boolean; - readonly renderControlCharacters: boolean; - readonly columnSelection: boolean; -} - -/** One setting's name. */ -export type EditorSettingName = keyof EditorSettings; - -/** - * The stock values. Render Control Characters is on by default, as in - * Cursor; the other three start off. - */ -export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { - wordWrap: false, - renderWhitespace: false, - renderControlCharacters: true, - columnSelection: false, -}; +import { + DEFAULT_EDITOR_SETTINGS, + EDITOR_SETTING_CONTEXT_KEYS, + EDITOR_SETTINGS_SERVICE, + type EditorSettingName, + type EditorSettings, +} from "../../services/editor-settings-service"; +import { getServiceOrNull, registerService } from "../../services/service-registry"; /** The setting names, in declaration order. */ const SETTING_NAMES = [ @@ -56,14 +41,6 @@ const SETTING_NAMES = [ "columnSelection", ] as const satisfies readonly EditorSettingName[]; -/** The context key each setting publishes to (the menus' `toggled` sources). */ -export const EDITOR_SETTING_CONTEXT_KEYS: { readonly [K in EditorSettingName]: `config.editor.${K}` } = { - wordWrap: "config.editor.wordWrap", - renderWhitespace: "config.editor.renderWhitespace", - renderControlCharacters: "config.editor.renderControlCharacters", - columnSelection: "config.editor.columnSelection", -}; - /** The writer the service hands each new settings object to. */ export type EditorSettingsWriter = (value: unknown) => void; @@ -161,9 +138,6 @@ export class EditorSettingsService implements IDisposable { } } -/** The registry token for the editor-settings singleton. */ -export const EDITOR_SETTINGS_SERVICE = createServiceToken("workshop.editorSettings"); - // Self-registration with the defaults and a no-op writer: a consumer that // resolves the token before the composition root re-registers it bound to // the live adapter gets working, unpersisted settings rather than a wrong diff --git a/crates/workshop/ui/src/parts/editor/editor-surface.ts b/crates/workshop/ui/src/parts/editor/editor-surface.ts index f2cf4b51f..232f478b6 100644 --- a/crates/workshop/ui/src/parts/editor/editor-surface.ts +++ b/crates/workshop/ui/src/parts/editor/editor-surface.ts @@ -58,7 +58,7 @@ import { EDITOR_SETTINGS_SERVICE, type EditorSettings, type EditorSettingsService, -} from "./editor-settings-service"; +} from "../../services/editor-settings-service"; /** A document handed to the surface: the path it came from and its text. */ export interface EditorDocument { diff --git a/crates/workshop/ui/src/parts/editor/editor.contribution.ts b/crates/workshop/ui/src/parts/editor/editor.contribution.ts index 0df7f6944..b60b3f7a4 100644 --- a/crates/workshop/ui/src/parts/editor/editor.contribution.ts +++ b/crates/workshop/ui/src/parts/editor/editor.contribution.ts @@ -20,8 +20,8 @@ import { KeybindingsRegistry, KeybindingWeight } from "../../services/keybinding import { MenuId } from "../../services/menu-registry"; import { QuickAccessRegistry } from "../../services/quick-access-registry"; import { getService } from "../../services/service-registry"; -import { QUICK_INPUT_SERVICE } from "../quickinput/quick-input"; -import { EDITOR_SETTINGS_SERVICE, type EditorSettingName } from "./editor-settings-service"; +import { QUICK_INPUT_SERVICE } from "../../services/quick-input-service"; +import { EDITOR_SETTINGS_SERVICE, type EditorSettingName } from "../../services/editor-settings-service"; import { createGotoLineProvider } from "./goto-line"; /** The editor-commands module as a type only; the runtime import stays lazy. */ diff --git a/crates/workshop/ui/src/parts/editor/goto-line.ts b/crates/workshop/ui/src/parts/editor/goto-line.ts index 4a4606686..e968ac370 100644 --- a/crates/workshop/ui/src/parts/editor/goto-line.ts +++ b/crates/workshop/ui/src/parts/editor/goto-line.ts @@ -5,7 +5,7 @@ // VS Code's: a 1-based line number with an optional column after a // colon or comma (":12", ":12:5", ":12,5"). -import type { QuickAccessProvider, QuickInputItem } from "../quickinput/quick-input"; +import type { QuickAccessProvider, QuickInputItem } from "../../services/quick-input-service"; /** A parsed go-to target: a 1-based line and an optional 1-based column. */ export interface LineColumnTarget { diff --git a/crates/workshop/ui/src/parts/layout/index.ts b/crates/workshop/ui/src/parts/layout/index.ts index f988b1b3b..ec6f4725f 100644 --- a/crates/workshop/ui/src/parts/layout/index.ts +++ b/crates/workshop/ui/src/parts/layout/index.ts @@ -6,7 +6,7 @@ import { DisposableStore, type IDisposable } from "../../base/lifecycle"; import { CONTEXT_KEY_SERVICE } from "../../services/context-key-service"; import { registerPanelFactory } from "../../services/panel-registry"; import { getService, getServiceOrNull } from "../../services/service-registry"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { WorkshopTreePanel } from "./workshop-panel"; /** diff --git a/crates/workshop/ui/src/parts/layout/keybinding-dispatcher.ts b/crates/workshop/ui/src/parts/layout/keybinding-dispatcher.ts index 3e992afee..c389c961f 100644 --- a/crates/workshop/ui/src/parts/layout/keybinding-dispatcher.ts +++ b/crates/workshop/ui/src/parts/layout/keybinding-dispatcher.ts @@ -26,7 +26,7 @@ import { CONTEXT_KEY_SERVICE, type ContextKey, type ContextKeyService } from ".. import { chordFromKeyboardEvent, formatChord, formatKeybinding, type Chord } from "../../services/keybinding-parser"; import { KeybindingsRegistry } from "../../services/keybinding-registry"; import { getService, getServiceOrNull } from "../../services/service-registry"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; /** How long a chord prefix waits for its second key. */ const CHORD_TIMEOUT_MS = 5000; diff --git a/crates/workshop/ui/src/parts/layout/panel-types.ts b/crates/workshop/ui/src/parts/layout/panel-types.ts index f11949509..de954c7fa 100644 --- a/crates/workshop/ui/src/parts/layout/panel-types.ts +++ b/crates/workshop/ui/src/parts/layout/panel-types.ts @@ -40,11 +40,11 @@ export type { PanelFeatureModule, PanelType, PanelTypeEntry } from "../../servic /** * A dockview content renderer standing in for a panel whose feature * chunk is still loading. The element mounts into the dock immediately - * (an empty shell keeps the layout stable); when the thunk resolves, the + * (a placeholder keeps the layout stable); when the thunk resolves, the * directory's register() has run and the real panel's element swaps in, * receiving the init parameters dockview delivered at mount, plus the - * last dimensions the dock laid the shell out at. Disposing before the - * load resolves cancels the swap. The shell's sizing (a full-height flex + * last dimensions the dock laid the placeholder out at. Disposing before the + * load resolves cancels the swap. The placeholder's sizing (a full-height flex * column, .ws-panel-lazy in zones.css) is what lets the real panel's * `height: 100%` resolve against the dock's content container. */ diff --git a/crates/workshop/ui/src/parts/layout/zones.css b/crates/workshop/ui/src/parts/layout/zones.css index b51ed93da..9695fe9d1 100644 --- a/crates/workshop/ui/src/parts/layout/zones.css +++ b/crates/workshop/ui/src/parts/layout/zones.css @@ -5,13 +5,13 @@ panel-types.ts). Themed values come from the :root tokens in shared-ui/tokens.css. */ -.ws-shell { +.ws-desk { display: flex; flex: 1; min-height: 0; } -/* The dockview column fills the shell: the dock fills it. */ +/* The dockview column fills the desk: the dock fills it. */ .ws-dock-column { flex: 1; display: flex; @@ -269,7 +269,7 @@ background: var(--bg-hover); } -/* The lazy-load shell sits between dockview's content container and the +/* The placeholder sits between dockview's content container and the real panel, so it must hand the container's height straight through: every panel root sizes itself with `height: 100%` and needs a definite-height parent, or its feed grows with content instead of diff --git a/crates/workshop/ui/src/parts/menu/menu.ts b/crates/workshop/ui/src/parts/menu/menu.ts index ffd613ba1..8be2ddcc4 100644 --- a/crates/workshop/ui/src/parts/menu/menu.ts +++ b/crates/workshop/ui/src/parts/menu/menu.ts @@ -29,7 +29,7 @@ import { ContextKeyExpr } from "../../services/context-key-expr"; import { KeybindingsRegistry } from "../../services/keybinding-registry"; import { Menus, type MenuId, type MenuItem, type MenuRegistry, type MenuRow, type SubmenuItem } from "../../services/menu-registry"; import { getService, getServiceOrNull } from "../../services/service-registry"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; /** Where the popover opens: below an element, or at a pointer position. */ export type MenuAnchor = HTMLElement | { readonly x: number; readonly y: number }; diff --git a/crates/workshop/ui/src/parts/quickinput/quick-access-providers.ts b/crates/workshop/ui/src/parts/quickinput/quick-access-providers.ts index 967e2c293..324e6a4f3 100644 --- a/crates/workshop/ui/src/parts/quickinput/quick-access-providers.ts +++ b/crates/workshop/ui/src/parts/quickinput/quick-access-providers.ts @@ -27,9 +27,9 @@ import { KeybindingsRegistry } from "../../services/keybinding-registry"; import { MenuId, Menus, type MenuRegistry } from "../../services/menu-registry"; import { QuickAccessRegistry, type QuickAccessProviderDescriptor } from "../../services/quick-access-registry"; import { getService, getServiceOrNull } from "../../services/service-registry"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { COMMANDS_HISTORY, type CommandsHistory } from "./commands-history"; -import { QUICK_INPUT_SERVICE, type QuickAccessProvider, type QuickInputItem } from "./quick-input"; +import { QUICK_INPUT_SERVICE, type QuickAccessProvider, type QuickInputItem } from "../../services/quick-input-service"; /** The registries the palette provider reads; tests inject their own. */ export interface CommandPaletteProviderDeps { diff --git a/crates/workshop/ui/src/parts/quickinput/quick-input.ts b/crates/workshop/ui/src/parts/quickinput/quick-input.ts index c6f6a7bf2..80f804f60 100644 --- a/crates/workshop/ui/src/parts/quickinput/quick-input.ts +++ b/crates/workshop/ui/src/parts/quickinput/quick-input.ts @@ -21,29 +21,7 @@ import "./quick-input.css"; import { Disposable, toDisposable } from "../../base/lifecycle"; import { QuickAccessRegistry } from "../../services/quick-access-registry"; -import { createServiceToken, type ServiceToken } from "../../services/service-registry"; - -/** One row in the quick input list. */ -export interface QuickInputItem { - /** The row's primary text. */ - readonly label: string; - /** Secondary muted text, e.g. a path or a category. */ - readonly description?: string; - /** The keybinding hint shown at the row's right edge. */ - readonly keybinding?: string; - /** Runs the row's action. The panel has already closed. */ - accept(): void; -} - -/** - * The provider shape the widget expects a descriptor's factory to - * produce. The registry never calls the factory; this interface is the - * widget's side of the contract. - */ -export interface QuickAccessProvider { - /** The rows for `filter` (the input value minus the prefix). */ - getItems(filter: string): readonly QuickInputItem[]; -} +import { type QuickAccessProvider, type QuickInputItem, type QuickInputShowOptions } from "../../services/quick-input-service"; /** Narrows a factory's unknown product to the provider shape. */ function asProvider(value: unknown): QuickAccessProvider | undefined { @@ -56,15 +34,6 @@ function asProvider(value: unknown): QuickAccessProvider | undefined { return value as QuickAccessProvider; } -/** Options for one quick input showing. */ -export interface QuickInputShowOptions { - /** - * Render every provider's help entries above the active provider's - * rows while the input is empty - the modes list. - */ - readonly includeHelp?: boolean; -} - /** Registry override; tests inject their own instance. */ export interface QuickInputDependencies { readonly registry?: QuickAccessRegistry; @@ -303,6 +272,3 @@ export class QuickInputService extends Disposable { } } -/** The service token the composition root registers the widget under. */ -export const QUICK_INPUT_SERVICE: ServiceToken = - createServiceToken("workshop.quickInput"); diff --git a/crates/workshop/ui/src/parts/quickinput/quickinput.contribution.ts b/crates/workshop/ui/src/parts/quickinput/quickinput.contribution.ts index 47de2fc8d..dab91a90e 100644 --- a/crates/workshop/ui/src/parts/quickinput/quickinput.contribution.ts +++ b/crates/workshop/ui/src/parts/quickinput/quickinput.contribution.ts @@ -24,7 +24,7 @@ import { appendMenuItem, MenuId } from "../../services/menu-registry"; import { QuickAccessRegistry } from "../../services/quick-access-registry"; import { getService } from "../../services/service-registry"; import { createQuickAccessProviderDescriptors } from "./quick-access-providers"; -import { QUICK_INPUT_SERVICE, type QuickInputShowOptions } from "./quick-input"; +import { QUICK_INPUT_SERVICE, type QuickInputShowOptions } from "../../services/quick-input-service"; /** Opens quick input at `value`; resolves the widget at call time. */ function showQuickInput(value: string, options?: QuickInputShowOptions): void { diff --git a/crates/workshop/ui/src/parts/run/run-panel.ts b/crates/workshop/ui/src/parts/run/run-panel.ts index b4a92b090..b4b3a9aea 100644 --- a/crates/workshop/ui/src/parts/run/run-panel.ts +++ b/crates/workshop/ui/src/parts/run/run-panel.ts @@ -27,7 +27,7 @@ import { getServiceOrNull } from "../../services/service-registry"; import { fetchFile } from "../../services/workspace-api"; import { showPanelDialog } from "../editor/editor-dialog"; import { setRunTabLoading } from "../layout/run-tab"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { grantPath, WORKSPACE_FILE_DROP_EVENT } from "../workspace/workspace-drops"; import { renderContractRows } from "./run-rows"; import "./run-panel.css"; diff --git a/crates/workshop/ui/src/parts/status/status-bar.ts b/crates/workshop/ui/src/parts/status/status-bar.ts index abada4ffc..954f3baed 100644 --- a/crates/workshop/ui/src/parts/status/status-bar.ts +++ b/crates/workshop/ui/src/parts/status/status-bar.ts @@ -1,19 +1,19 @@ // The status bar renderer: consumes the observer's status frames off the -// persistent socket and paints them into the shared status bar shell +// persistent socket and paints them into the shared status bar view // (shared-ui/status-bar), which owns the bar, the text region, and the // busy barberpole beside the indicators. Info and error frames set the // text (the description shows as the tooltip) and drive the barberpole; // debug frames are internal instrumentation: they never touch the text // or the barberpole, but they do pulse the LED. The workshop's -// indicators group holds the recording and activity LEDs; the shell's +// indicators group holds the recording and activity LEDs; the view's // extras region stays empty. -import { createStatusBarShell, type StatusBarShell } from "shared-ui/status-bar"; +import { createStatusBarView, type StatusBarView } from "shared-ui/status-bar"; import { Disposable, toDisposable } from "../../base/lifecycle"; import { CONTEXT_KEY_SERVICE, type ContextKey } from "../../services/context-key-service"; import type { StatusFrame } from "../../services/protocol"; -import { createServiceToken, getService, type ServiceToken } from "../../services/service-registry"; +import { getService } from "../../services/service-registry"; type PulseActivity = "thinking" | "generating"; @@ -22,7 +22,7 @@ type PulseActivity = "thinking" | "generating"; const DEFAULT_LED_PULSE_MS = 250; export class StatusBar extends Disposable { - private readonly shell: StatusBarShell; + private readonly view: StatusBarView; private readonly led: HTMLElement; private readonly rec: HTMLElement; private readonly lit = new Set(); @@ -36,7 +36,7 @@ export class StatusBar extends Disposable { constructor() { super(); this.visibleKey = getService(CONTEXT_KEY_SERVICE).createKey("statusBarVisible", true); - this.shell = createStatusBarShell(); + this.view = createStatusBarView(); // The workshop's indicators: the recording LED has the --rec // marker; the activity LED is the unmarked one. this.rec = document.createElement("span"); @@ -45,11 +45,11 @@ export class StatusBar extends Disposable { this.led = document.createElement("span"); this.led.className = "status-bar__led"; this.led.setAttribute("aria-hidden", "true"); - this.shell.indicators.append(this.rec, this.led); - this.shell.setText("Ready"); - // The bar is the body's full-width footer, below the shell. - document.body.append(this.shell.element); - this._register(toDisposable(() => this.shell.element.remove())); + this.view.indicators.append(this.rec, this.led); + this.view.setText("Ready"); + // The bar is the body's full-width footer, below the desk. + document.body.append(this.view.element); + this._register(toDisposable(() => this.view.element.remove())); // The pulse decay timer is the bar's only other owned resource. this._register( toDisposable(() => { @@ -83,11 +83,11 @@ export class StatusBar extends Disposable { if (this.sustained) this.lit.add(this.sustained); this.applyLed(); } - this.shell.setText(frame.label, { + this.view.setText(frame.label, { tooltip: frame.description, error: frame.severity === "error", }); - this.shell.setBusy(frame.busy); + this.view.setBusy(frame.busy); } /** @@ -114,12 +114,12 @@ export class StatusBar extends Disposable { /** Shows a locally-originated message (e.g. dictation errors). The next observer frame overwrites it. */ showLocal(label: string, severity: "info" | "error"): void { - this.shell.setText(label, { error: severity === "error" }); + this.view.setText(label, { error: severity === "error" }); } /** Whether the bar is currently shown. */ get isVisible(): boolean { - return !this.shell.element.hidden; + return !this.view.element.hidden; } /** @@ -128,7 +128,7 @@ export class StatusBar extends Disposable { * row's checkbox follows. */ setVisible(visible: boolean): void { - this.shell.element.hidden = !visible; + this.view.element.hidden = !visible; this.visibleKey.set(visible); } @@ -161,8 +161,8 @@ export class StatusBar extends Disposable { */ reset(): void { this.sustained = null; - this.shell.setText("Reconnecting..."); - this.shell.setBusy(false); + this.view.setText("Reconnecting..."); + this.view.setBusy(false); } /** Applies the lit set: green wins while generating and thinking coincide. */ @@ -186,13 +186,3 @@ export class StatusBar extends Disposable { return match[2] === "s" ? value * 1000 : value; } } - -/** - * The registry token for the composition root's StatusBar, resolved by - * panels that paint action outcomes onto it (the Workshop tree's grant - * flows, the agent session's dictation reports). Registered by the - * composition root at boot; unregistered in tests that drive panels - * standalone, where the panels stay silent. - */ -export const STATUS_BAR: ServiceToken = - createServiceToken("workshop.statusBar"); diff --git a/crates/workshop/ui/src/parts/status/status.contribution.ts b/crates/workshop/ui/src/parts/status/status.contribution.ts index bb99ecf98..1e483336a 100644 --- a/crates/workshop/ui/src/parts/status/status.contribution.ts +++ b/crates/workshop/ui/src/parts/status/status.contribution.ts @@ -12,7 +12,7 @@ import type { ParseError } from "../../services/context-key-expr"; import type { Result } from "../../services/error-catalog"; import { MenuId } from "../../services/menu-registry"; import { getServiceOrNull } from "../../services/service-registry"; -import { STATUS_BAR } from "./status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; /** The Appearance flyout's id; the menubar contribution declares the submenu. */ const APPEARANCE_MENU: MenuId = "menubar/view/appearance"; diff --git a/crates/workshop/ui/src/parts/workspace-files/workspace-files.contribution.ts b/crates/workshop/ui/src/parts/workspace-files/workspace-files.contribution.ts index a9f98d34e..ced04e542 100644 --- a/crates/workshop/ui/src/parts/workspace-files/workspace-files.contribution.ts +++ b/crates/workshop/ui/src/parts/workspace-files/workspace-files.contribution.ts @@ -15,8 +15,8 @@ // open dropped the roots before re-creating the tree, save as and // duplicate keep the grants) so the window title and the tree refresh // without a second roots fetch, emits the promptforge:workspace-opened -// Tauri event so the shell can fetch and apply the file's window -// geometry (native geometry is the shell's to apply, never the page's), +// Tauri event so the desktop app can fetch and apply the file's window +// geometry (native geometry is the desktop app's to apply, never the page's), // and records the file in the recent-files store. Failures paint the // status bar, exactly as the other file actions do; success is silent, // the refreshed tree being its own confirmation; a cancelled picker is @@ -52,13 +52,13 @@ import { saveWorkspaceFileAs, type WorkspaceFileResponse, } from "../../services/workspace-file-client"; -import { CLOSED_EDITORS } from "../editor/closed-editors"; +import { CLOSED_EDITORS } from "../../services/closed-editors"; import { applyLayoutOrDefault } from "../layout/layout-boot"; import { buildLayoutEnvelope } from "../layout/layout-persistence"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { WORKSPACE_CHANGED_EVENT, type WorkspaceChangedDetail } from "../workspace/workspace-drops"; -/** The Tauri event the shell listens for to re-apply window geometry. */ +/** The Tauri event the desktop app listens for to re-apply window geometry. */ export const WORKSPACE_OPENED_EVENT = "promptforge:workspace-opened"; /** The workspace file extension, as the pickers filter and the save paths end. */ @@ -93,7 +93,7 @@ function addAction(action: ActionDescriptor): void { } /** - * Tells the shell a workspace file is now current. The event only + * Tells the desktop app a workspace file is now current. The event only * matters for geometry, which the open already committed, so a failed * emit is logged and never undoes the open. */ @@ -109,7 +109,7 @@ async function announceOpened(path: string): Promise { /** * The page's side of a committed switch, shared by every action: the * workspace-changed event for its other listeners (the window title, - * the tree panel), the shell event, and the recent entry. Runs only + * the tree panel), the desktop app event, and the recent entry. Runs only * after the server has answered success, so nothing here can undo it. * The tree invalidation is not its job: Open drops the roots before it * applies the file's state (applyOpenedWorkspaceState), and Save As and @@ -200,7 +200,7 @@ async function pickWorkspaceFile(): Promise { /** * Open Workspace from File...: the open on the server, the file's UI - * state applied to the live stores, then the invalidation, the shell + * state applied to the live stores, then the invalidation, the desktop app * event, and the recent entry. With a string `path` argument (an Open * Recent row or a Ctrl+P hit) the picker is skipped and the argument is * the file; otherwise the native picker filtered to .pfwork supplies it. diff --git a/crates/workshop/ui/src/parts/workspace/add-folder.ts b/crates/workshop/ui/src/parts/workspace/add-folder.ts index 4583e7187..8a1e3e670 100644 --- a/crates/workshop/ui/src/parts/workspace/add-folder.ts +++ b/crates/workshop/ui/src/parts/workspace/add-folder.ts @@ -13,7 +13,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import type { IDisposable } from "../../base/lifecycle"; import { getServiceOrNull } from "../../services/service-registry"; import { showPanelDialog } from "../editor/editor-dialog"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { grantPath, WORKSPACE_CHANGED_EVENT } from "./workspace-drops"; /** The status-bar surface the flow paints action outcomes onto. */ diff --git a/crates/workshop/ui/src/parts/workspace/file-actions.ts b/crates/workshop/ui/src/parts/workspace/file-actions.ts index 646b51618..59c646a0d 100644 --- a/crates/workshop/ui/src/parts/workspace/file-actions.ts +++ b/crates/workshop/ui/src/parts/workspace/file-actions.ts @@ -16,7 +16,7 @@ import { fetchTree } from "../../services/workspace-api"; import { asEditor } from "../editor/editor-commands"; import { focusWorkshopTree } from "../layout/workshop-panel"; import { openInZone } from "../layout/zones"; -import { STATUS_BAR } from "../status/status-bar"; +import { STATUS_BAR } from "../../services/status-bar"; import { addFolderToWorkspace } from "./add-folder"; import { grantPath, WORKSPACE_CHANGED_EVENT } from "./workspace-drops"; diff --git a/crates/workshop/ui/src/parts/workspace/files.contribution.ts b/crates/workshop/ui/src/parts/workspace/files.contribution.ts index 7fb9325c3..ddf9887c3 100644 --- a/crates/workshop/ui/src/parts/workspace/files.contribution.ts +++ b/crates/workshop/ui/src/parts/workspace/files.contribution.ts @@ -24,7 +24,7 @@ import { MenuId, Menus } from "../../services/menu-registry"; import { QuickAccessRegistry } from "../../services/quick-access-registry"; import { RECENT_FILES_STORE } from "../../services/recent-files-store"; import { getService } from "../../services/service-registry"; -import { QUICK_INPUT_SERVICE } from "../quickinput/quick-input"; +import { QUICK_INPUT_SERVICE } from "../../services/quick-input-service"; import { createFileQuickAccessProvider, createRecentMenuProvider } from "./open-recent"; /** The file-actions module as a type only; the runtime import stays lazy. */ diff --git a/crates/workshop/ui/src/parts/workspace/open-recent.ts b/crates/workshop/ui/src/parts/workspace/open-recent.ts index 1d864b6d8..8446a527e 100644 --- a/crates/workshop/ui/src/parts/workspace/open-recent.ts +++ b/crates/workshop/ui/src/parts/workspace/open-recent.ts @@ -27,8 +27,8 @@ import type { MenuItem, MenuItemsProvider } from "../../services/menu-registry"; import { RECENT_FILES_STORE, type RecentFilesStore } from "../../services/recent-files-store"; import { getService, getServiceOrNull } from "../../services/service-registry"; import { ROOTS_KEY, TREE_STATE, type TreeStateService } from "../../services/tree-state-service"; -import { STATUS_BAR } from "../status/status-bar"; -import type { QuickAccessProvider, QuickInputItem } from "../quickinput/quick-input"; +import { STATUS_BAR } from "../../services/status-bar"; +import type { QuickAccessProvider, QuickInputItem } from "../../services/quick-input-service"; /** The stores the providers read; tests inject their own. */ export interface RecentProviderDeps { diff --git a/crates/workshop/ui/src/parts/workspace/workspace-drops.ts b/crates/workshop/ui/src/parts/workspace/workspace-drops.ts index f4cd388d3..a159a7481 100644 --- a/crates/workshop/ui/src/parts/workspace/workspace-drops.ts +++ b/crates/workshop/ui/src/parts/workspace/workspace-drops.ts @@ -9,7 +9,7 @@ // window. In a plain browser neither the bridge nor the event exists and // normal HTML drag/drop of file contents keeps working untouched. // -// The shell never touches the OS drop itself (WebView2's own drop target +// The desktop app never touches the OS drop itself (WebView2's own drop target // is what keeps HTML5 drag-and-drop alive for Dockview), so the page must // suppress the browser's default file-drop action - navigating away to // the dropped file - itself. Only drags of OS files are suppressed; diff --git a/crates/workshop/ui/src/services/agent-socket.ts b/crates/workshop/ui/src/services/agent-socket.ts index 551f2cbda..d8e877613 100644 --- a/crates/workshop/ui/src/services/agent-socket.ts +++ b/crates/workshop/ui/src/services/agent-socket.ts @@ -2,7 +2,7 @@ // session - agent windows are modal, so the socket's whole life is one // session plus the agent list that precedes it. The frame shapes live in // protocol.ts; the Rust half of the routing is -// crates/workshop/sessions/src/agents/socket.rs. +// crates/workshop/server/src/agents/socket.rs. // // Routing follows the SPA's delivery discipline, one class per frame: // @@ -40,17 +40,12 @@ import type { InputResponseFrame, LaunchFrame, } from "./protocol"; +import { ReconnectBackoff } from "./reconnect-backoff"; function defaultUrl(): string { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/agents/ws`; } -// Reconnect backoff, matching the workshop socket's: the first retry waits -// a second, each failure doubles it, and the cap keeps a down server from -// pushing the wait past 30 s. -const RECONNECT_INITIAL_MS = 1000; -const RECONNECT_MAX_MS = 30_000; - /** * The loosely-typed inbound frame: exactly the fields routing reads, * narrowed per `type` before delivery. The full payloads are delivered as @@ -78,8 +73,7 @@ interface AgentServerFrame { */ export class AgentSocket extends Disposable { private socket: WebSocket | null = null; - private reconnectDelayMs = RECONNECT_INITIAL_MS; - private reconnectTimer: ReturnType | null = null; + private readonly backoff = new ReconnectBackoff(); /** The acknowledged session, retained so a reconnect reattaches. */ private acknowledged: AgentSessionFrame | null = null; /** The next event-log index to deliver; everything below it already was. */ @@ -132,10 +126,7 @@ export class AgentSocket extends Disposable { // fan-out, no reconnect backoff. this._register( toDisposable(() => { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } + this.backoff.cancel(); const socket = this.socket; if (socket) { socket.onclose = null; @@ -154,11 +145,7 @@ export class AgentSocket extends Disposable { const socket = new WebSocket(this.url); this.socket = socket; socket.onopen = () => { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.reconnectDelayMs = RECONNECT_INITIAL_MS; + this.backoff.reset(); // Sessions outlive sockets: a fresh connection reattaches to the // acknowledged session. The replay from index zero that follows is // deduplicated by the event cursor. @@ -231,21 +218,9 @@ export class AgentSocket extends Disposable { } } - /** - * Schedules the next reconnect attempt with exponential backoff. One - * timer at a time: a close while an attempt is already waiting does not - * stack a second. - */ + /** Schedules the next reconnect attempt with exponential backoff. */ private scheduleReconnect(): void { - if (this.reconnectTimer !== null) { - return; - } - const delay = this.reconnectDelayMs; - this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_MS); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - this.connect(); - }, delay); + this.backoff.schedule(() => this.connect()); } private route(event: MessageEvent): void { diff --git a/crates/workshop/ui/src/services/closed-editors.ts b/crates/workshop/ui/src/services/closed-editors.ts new file mode 100644 index 000000000..6b2259900 --- /dev/null +++ b/crates/workshop/ui/src/services/closed-editors.ts @@ -0,0 +1,36 @@ +// The closed-editor service contract behind Reopen Closed Editor. The +// implementation (parts/editor/closed-editors.ts) is workspace-persisted +// and self-registers with a default factory, but it stays in parts because +// the composition root binds it to the live adapter beside the other +// workspace-scoped stores; only the token and the stack's public shapes +// live here, in the DOM-free services layer. + +import { createServiceToken, type ServiceToken } from "./service-registry"; + +/** One closed editor: a file to reopen by path, or an untitled buffer's text. */ +export type ClosedEditor = + | { readonly kind: "file"; readonly path: string } + | { readonly kind: "untitled"; readonly text: string }; + +/** The persisted shape: file paths only, most recent first. */ +export interface ClosedEditorsSnapshot { + readonly paths: string[]; +} + +/** The closed-editor stack consumers resolve from the registry. */ +export interface ClosedEditors { + /** Records a closing editor; the oldest entry drops past the cap. */ + push(entry: ClosedEditor): void; + /** Removes and returns the most recently closed editor; undefined when none. */ + pop(): ClosedEditor | undefined; + /** The persisted view: file paths, most recent first; Save As writes it. */ + snapshot(): ClosedEditorsSnapshot; + /** + * Replaces the stack wholesale with a workspace's stored paths (Open + * Workspace from File), most recent first, without a write. + */ + replaceClosedEditors(paths: readonly string[]): void; +} + +/** The registry token for the closed-editor stack singleton. */ +export const CLOSED_EDITORS = createServiceToken("workshop.closedEditors"); diff --git a/crates/workshop/ui/src/services/editor-settings-service.ts b/crates/workshop/ui/src/services/editor-settings-service.ts new file mode 100644 index 000000000..ac1cd0172 --- /dev/null +++ b/crates/workshop/ui/src/services/editor-settings-service.ts @@ -0,0 +1,55 @@ +// The editor settings vocabulary and service contract. The implementation +// (parts/editor/editor-settings-service.ts) stays CodeMirror-free and +// self-registers with a default factory, but it lives in parts beside the +// surfaces that consume it; the settings shape, the context-key mapping, +// the defaults, the service interface, and the token live here, in the +// DOM-free services layer. + +import type { Event } from "../base/event"; +import type { IDisposable } from "../base/lifecycle"; +import { createServiceToken, type ServiceToken } from "./service-registry"; + +/** The four editor settings, one boolean per toggle action. */ +export interface EditorSettings { + readonly wordWrap: boolean; + readonly renderWhitespace: boolean; + readonly renderControlCharacters: boolean; + readonly columnSelection: boolean; +} + +/** One setting's name. */ +export type EditorSettingName = keyof EditorSettings; + +/** + * The stock values. Render Control Characters is on by default, as in + * Cursor; the other three start off. + */ +export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { + wordWrap: false, + renderWhitespace: false, + renderControlCharacters: true, + columnSelection: false, +}; + +/** The context key each setting publishes to (the menus' `toggled` sources). */ +export const EDITOR_SETTING_CONTEXT_KEYS: { readonly [K in EditorSettingName]: `config.editor.${K}` } = { + wordWrap: "config.editor.wordWrap", + renderWhitespace: "config.editor.renderWhitespace", + renderControlCharacters: "config.editor.renderControlCharacters", + columnSelection: "config.editor.columnSelection", +}; + +/** The editor-settings service consumers resolve from the registry. */ +export interface EditorSettingsService extends IDisposable { + /** The current settings. */ + readonly settings: EditorSettings; + /** Fires when a setting changes; the editor surfaces hook it. */ + readonly onDidChange: Event; + /** Writes one setting; a write of the current value is a no-op. */ + set(name: EditorSettingName, value: boolean): void; + /** Flips one setting - the toggle actions' entire run body. */ + toggle(name: EditorSettingName): void; +} + +/** The registry token for the editor-settings singleton. */ +export const EDITOR_SETTINGS_SERVICE = createServiceToken("workshop.editorSettings"); diff --git a/crates/workshop/ui/src/services/error-catalog.ts b/crates/workshop/ui/src/services/error-catalog.ts index 602abd761..ce478e027 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/json-request.ts b/crates/workshop/ui/src/services/json-request.ts index 3207e8e07..d0c622f68 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/src/services/protocol.ts b/crates/workshop/ui/src/services/protocol.ts index 20ec78cd5..eeff688d6 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,10 +64,21 @@ 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 -// crates/workshop/sessions/src/agents/socket.rs. Delivery classes mirror the Rust docs: +// crates/workshop/server/src/agents/socket.rs. Delivery classes mirror the Rust docs: // durable frames deliver exactly (the event log's per-client cursor and the // wait registry's resend-on-attach are the repair paths), ephemeral frames // may drop under lag and repair from a complete snapshot or a superseding diff --git a/crates/workshop/ui/src/services/quick-input-service.ts b/crates/workshop/ui/src/services/quick-input-service.ts new file mode 100644 index 000000000..1b2314af3 --- /dev/null +++ b/crates/workshop/ui/src/services/quick-input-service.ts @@ -0,0 +1,51 @@ +// The quick input service contract and its provider vocabulary. The +// implementation (parts/quickinput/quick-input.ts) is a DOM widget - the +// floating panel under the title bar - so it stays in parts; the row, +// provider, and show-options shapes, the service interface, and the token +// live here, in the DOM-free services layer, so menu and quick-access +// contributions can name the contract without pulling the widget chunk. + +import { createServiceToken, type ServiceToken } from "./service-registry"; + +/** One row in the quick input list. */ +export interface QuickInputItem { + /** The row's primary text. */ + readonly label: string; + /** Secondary muted text, e.g. a path or a category. */ + readonly description?: string; + /** The keybinding hint shown at the row's right edge. */ + readonly keybinding?: string; + /** Runs the row's action. The panel has already closed. */ + accept(): void; +} + +/** + * The provider shape the widget expects a descriptor's factory to + * produce. The registry never calls the factory; this interface is the + * widget's side of the contract. + */ +export interface QuickAccessProvider { + /** The rows for `filter` (the input value minus the prefix). */ + getItems(filter: string): readonly QuickInputItem[]; +} + +/** Options for one quick input showing. */ +export interface QuickInputShowOptions { + /** + * Render every provider's help entries above the active provider's + * rows while the input is empty - the modes list. + */ + readonly includeHelp?: boolean; +} + +/** The quick input service consumers resolve from the registry. */ +export interface QuickInputService { + /** The quick-access surface the menu actions and command center call. */ + readonly quickAccess: { + show(value: string, options?: QuickInputShowOptions): void; + }; +} + +/** The service token the composition root registers the widget under. */ +export const QUICK_INPUT_SERVICE: ServiceToken = + createServiceToken("workshop.quickInput"); diff --git a/crates/workshop/ui/src/services/reconnect-backoff.ts b/crates/workshop/ui/src/services/reconnect-backoff.ts new file mode 100644 index 000000000..54de872ee --- /dev/null +++ b/crates/workshop/ui/src/services/reconnect-backoff.ts @@ -0,0 +1,63 @@ +// Exponential reconnect backoff, shared by the persistent workshop socket +// (workshop-socket.ts) and the agent-session socket (agent-socket.ts). The +// first retry waits `initialMs`, each failure doubles the wait, and `maxMs` +// keeps a down server from pushing the wait out of bounds. One timer at a +// time: scheduling while an attempt is already waiting stacks nothing. A +// successful open resets the delay to `initialMs`; disposal cancels any +// pending attempt. + +export interface ReconnectBackoffOptions { + readonly initialMs?: number; + readonly maxMs?: number; +} + +const DEFAULT_INITIAL_MS = 1000; +const DEFAULT_MAX_MS = 30_000; + +export class ReconnectBackoff { + private readonly initialMs: number; + private readonly maxMs: number; + private delayMs: number; + private timer: ReturnType | null = null; + + constructor(options: ReconnectBackoffOptions = {}) { + this.initialMs = options.initialMs ?? DEFAULT_INITIAL_MS; + this.maxMs = options.maxMs ?? DEFAULT_MAX_MS; + this.delayMs = this.initialMs; + } + + /** + * Cancels any pending attempt and restores the initial delay - the + * successful-open path, so the next dropout starts over at `initialMs`. + */ + reset(): void { + this.cancel(); + this.delayMs = this.initialMs; + } + + /** Cancels any pending attempt; the disposal teardown path. */ + cancel(): void { + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + } + + /** + * Schedules the next attempt with exponential backoff: `retry` runs + * after the current delay, which doubles per failed attempt up to + * `maxMs`. One timer at a time - a call while an attempt is already + * waiting stacks nothing. + */ + schedule(retry: () => void): void { + if (this.timer !== null) { + return; + } + const delay = this.delayMs; + this.delayMs = Math.min(delay * 2, this.maxMs); + this.timer = setTimeout(() => { + this.timer = null; + retry(); + }, delay); + } +} diff --git a/crates/workshop/ui/src/services/status-bar.ts b/crates/workshop/ui/src/services/status-bar.ts new file mode 100644 index 000000000..32a207caf --- /dev/null +++ b/crates/workshop/ui/src/services/status-bar.ts @@ -0,0 +1,29 @@ +// The status bar service contract: the shape panels resolve through the +// registry to paint action outcomes onto the bar. The implementation +// (parts/status/status-bar.ts) is DOM-bound - it builds the shared status +// bar view and appends it to the body - so only this interface and the +// token live here, in the DOM-free services layer. + +import { createServiceToken, type ServiceToken } from "./service-registry"; + +/** The status-bar surface consumers resolve from the registry. */ +export interface StatusBar { + /** Shows a locally-originated message; the next observer frame overwrites it. */ + showLocal(label: string, severity: "info" | "error"): void; + /** Whether the bar is currently shown. */ + readonly isVisible: boolean; + /** Shows or hides the bar. */ + setVisible(visible: boolean): void; + /** Lights or dims the recording LED with the mic's recording state. */ + setRecording(on: boolean): void; +} + +/** + * The registry token for the composition root's StatusBar, resolved by + * panels that paint action outcomes onto it (the Workshop tree's grant + * flows, the agent session's dictation reports). Registered by the + * composition root at boot; unregistered in tests that drive panels + * standalone, where the panels stay silent. + */ +export const STATUS_BAR: ServiceToken = + createServiceToken("workshop.statusBar"); diff --git a/crates/workshop/ui/src/services/ui-storage.ts b/crates/workshop/ui/src/services/ui-storage.ts index 74bed1174..a1d2535e8 100644 --- a/crates/workshop/ui/src/services/ui-storage.ts +++ b/crates/workshop/ui/src/services/ui-storage.ts @@ -1,4 +1,4 @@ -// The UI-state adapter: browser storage over HTTP. The shell binds the +// The UI-state adapter: browser storage over HTTP. The desktop app binds the // server to an OS-assigned loopback port, so the page origin - and with it // every origin-scoped storage entry - changes on each launch. This module // replaces that storage with two server-backed buckets of opaque JSON diff --git a/crates/workshop/ui/src/services/workshop-socket.ts b/crates/workshop/ui/src/services/workshop-socket.ts index 723d38aea..fcc341851 100644 --- a/crates/workshop/ui/src/services/workshop-socket.ts +++ b/crates/workshop/ui/src/services/workshop-socket.ts @@ -7,7 +7,8 @@ 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"; +import { ReconnectBackoff } from "./reconnect-backoff"; interface ServerFrame { type?: unknown; @@ -18,11 +19,6 @@ function defaultUrl(): string { return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; } -// Reconnect backoff: the first retry waits a second, each failure doubles -// it, and the cap keeps a down server from pushing the wait past 30 s. -const RECONNECT_INITIAL_MS = 1000; -const RECONNECT_MAX_MS = 30_000; - /** * Most pushes a boot queue will ever hold before `ready()` releases it. * When full, the oldest push is dropped: a newer status or catalog frame @@ -47,8 +43,7 @@ type QueuedPush = export class WorkshopSocket extends Disposable { private socket: WebSocket | null = null; private opening: { socket: WebSocket; promise: Promise } | null = null; - private reconnectDelayMs = RECONNECT_INITIAL_MS; - private reconnectTimer: ReturnType | null = null; + private readonly backoff = new ReconnectBackoff(); private isReady = false; private readonly bootQueue: QueuedPush[] = []; @@ -75,10 +70,7 @@ export class WorkshopSocket extends Disposable { // fan-out, no reconnect backoff. this._register( toDisposable(() => { - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } + this.backoff.cancel(); const socket = this.socket; if (socket) { socket.onclose = null; @@ -127,11 +119,7 @@ export class WorkshopSocket extends Disposable { entry.promise = new Promise((resolve, reject) => { socket.onopen = () => { if (this.opening === entry) this.opening = null; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.reconnectDelayMs = RECONNECT_INITIAL_MS; + this.backoff.reset(); resolve(); }; // A failure while opening rejects the waiters; a failure on an @@ -156,22 +144,12 @@ export class WorkshopSocket extends Disposable { return entry.promise; } - /** - * Schedules the next reconnect attempt with exponential backoff. One - * timer at a time: a close while an attempt is already waiting does not - * stack a second. - */ + /** Schedules the next reconnect attempt with exponential backoff. */ private scheduleReconnect(): void { - if (this.reconnectTimer !== null) { - return; - } - const delay = this.reconnectDelayMs; - this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_MS); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; + this.backoff.schedule(() => { // A failed attempt ends in onclose, which schedules the next one. void this.ensureOpen().catch(() => {}); - }, delay); + }); } /** @@ -181,7 +159,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/src/services/workspace-api.ts b/crates/workshop/ui/src/services/workspace-api.ts index b45c4ae72..db355ad06 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/src/services/workspace-file-client.ts b/crates/workshop/ui/src/services/workspace-file-client.ts index 9e85d9080..45d095d39 100644 --- a/crates/workshop/ui/src/services/workspace-file-client.ts +++ b/crates/workshop/ui/src/services/workspace-file-client.ts @@ -20,7 +20,7 @@ export interface WorkspaceGrant { readonly exists: boolean; } -/** The shell's saved window geometry, in logical pixels. */ +/** The desktop app's saved window geometry, in logical pixels. */ export interface WindowState { readonly width: number; readonly height: number; @@ -164,7 +164,7 @@ export function duplicateWorkspaceFile(path: string): Promise { diff --git a/crates/workshop/ui/test/closed-editors.mjs b/crates/workshop/ui/test/closed-editors.mjs index bf79d1996..72054031d 100644 --- a/crates/workshop/ui/test/closed-editors.mjs +++ b/crates/workshop/ui/test/closed-editors.mjs @@ -24,7 +24,8 @@ const uiDir = path.dirname(fileURLToPath(import.meta.url)); const bundle = await esbuild.build({ stdin: { contents: ` - export { ClosedEditors, CLOSED_EDITORS } from "./src/parts/editor/closed-editors.ts"; + export { ClosedEditors } from "./src/parts/editor/closed-editors.ts"; + export { CLOSED_EDITORS } from "./src/services/closed-editors.ts"; export { getService } from "./src/services/service-registry.ts"; `, resolveDir: path.join(uiDir, ".."), diff --git a/crates/workshop/ui/test/docs-claims.mjs b/crates/workshop/ui/test/docs-claims.mjs index 61e6becba..fbc7e6edd 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/ui/test/editor-commands.mjs b/crates/workshop/ui/test/editor-commands.mjs index ae498da4e..23ed8591a 100644 --- a/crates/workshop/ui/test/editor-commands.mjs +++ b/crates/workshop/ui/test/editor-commands.mjs @@ -30,7 +30,8 @@ const bundle = await esbuild.build({ import "./src/parts/editor/editor.contribution.ts"; export * as editorCommands from "./src/parts/editor/editor-commands.ts"; export * as editorLifecycle from "./src/parts/editor/editor-lifecycle.ts"; - export { ClosedEditors, CLOSED_EDITORS } from "./src/parts/editor/closed-editors.ts"; + export { ClosedEditors } from "./src/parts/editor/closed-editors.ts"; + export { CLOSED_EDITORS } from "./src/services/closed-editors.ts"; export { parseLineColumn, createGotoLineProvider } from "./src/parts/editor/goto-line.ts"; export { EditorState, EditorSelection } from "@codemirror/state"; export { EditorView } from "@codemirror/view"; 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 000000000..8046395bb --- /dev/null +++ b/crates/workshop/ui/test/editor-save-timeout.mjs @@ -0,0 +1,399 @@ +// 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 six 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; a +// late write that lands after the re-read surfaces the conflict dialog, +// not a raw error; an Overwrite 408 marks the token unknown the same way +// a save 408 does; and an Overwrite entered with the token already +// unknown, whose timed-out write landed, lets the next save adopt the +// fresh token. 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"); +const overwriteButton = (panel) => + [...panel.element.querySelectorAll(".ws-editor-conflict__button")].find( + (button) => button.textContent === "Overwrite", + ); + +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(); + } + + // --- An Overwrite 408 leaves the token unknown ------------------------------ + { + 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 }); + if (puts.length === 1) { + // The file changed externally since the load: the save conflicts. + disk = { text: "external edit", token: "t500" }; + return Promise.reject(conflictError()); + } + if (puts.length === 2) { + // The Overwrite never lands: the server answered 408 before it wrote. + return Promise.reject(deadlineError()); + } + return Promise.resolve({ path: filePath, size: text.length, token: "t900", text }); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + overwriteButton(panel).click(); + await flush(); + check( + "an Overwrite 408 tells the user the save may not have landed", + errorBar(panel)?.textContent.includes("may or may not"), + ); + + await panel.save(); + check( + "an Overwrite 408 leaves the token unknown, so the next save re-reads instead of writing", + puts.length === 2 && conflictOverlay(panel) !== null, + ); + panel.dispose(); + } + + // --- An Overwrite whose timed-out write landed adopts the fresh 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 }); + if (puts.length === 1) { + // The save never lands: the server answered 408 before it wrote. + return Promise.reject(deadlineError()); + } + if (puts.length === 2) { + // The late Overwrite lands: the disk now holds what was sent. + disk = { text, token: "t600" }; + return Promise.reject(deadlineError()); + } + return Promise.resolve({ path: filePath, size: text.length, token: "t700", text }); + }, + }); + panel.init(fakeParameters(FILE_PATH)); + await flush(); + + stub.type("new"); + await panel.save(); + // The file changed externally while the write was unknown, so the + // reconcile read mismatches and the dialog opens with the token still + // unknown. + disk = { text: "external edit", token: "t500" }; + await panel.save(); + check( + "an unknown-token mismatch opens the conflict dialog before the Overwrite", + puts.length === 1 && conflictOverlay(panel) !== null, + ); + // Typed after the mismatched save, so the Overwrite sends different + // text than the timed-out save did and reconciliation must match the + // Overwrite's. + stub.type("newer"); + overwriteButton(panel).click(); + await flush(); + await panel.save(); + check( + "an Overwrite whose timed-out write landed lets the next save adopt the fresh token", + puts.length === 3 && puts[2].expectedToken === "t600" && puts[2].text === "newer", + ); + check( + "the adopted-token save after an Overwrite does not reopen the conflict dialog", + conflictOverlay(panel) === null, + ); + check("the adopted-token save after an Overwrite clears the dirty state", !panel.isDirty()); + 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/editor-settings.mjs b/crates/workshop/ui/test/editor-settings.mjs index 7431cc462..573d5183c 100644 --- a/crates/workshop/ui/test/editor-settings.mjs +++ b/crates/workshop/ui/test/editor-settings.mjs @@ -25,11 +25,8 @@ const bundle = await esbuild.build({ contents: ` import "./src/parts/editor/editor.contribution.ts"; export { CodeMirrorSurface } from "./src/parts/editor/editor-surface.ts"; - export { - DEFAULT_EDITOR_SETTINGS, - EDITOR_SETTINGS_SERVICE, - EditorSettingsService, - } from "./src/parts/editor/editor-settings-service.ts"; + export { EditorSettingsService } from "./src/parts/editor/editor-settings-service.ts"; + export { DEFAULT_EDITOR_SETTINGS, EDITOR_SETTINGS_SERVICE } from "./src/services/editor-settings-service.ts"; export { ContextKeyService } from "./src/services/context-key-service.ts"; export { getService } from "./src/services/service-registry.ts"; export { Commands } from "./src/services/command-registry.ts"; diff --git a/crates/workshop/ui/test/files-actions.mjs b/crates/workshop/ui/test/files-actions.mjs index bb05ebce7..d2c7f4613 100644 --- a/crates/workshop/ui/test/files-actions.mjs +++ b/crates/workshop/ui/test/files-actions.mjs @@ -42,10 +42,10 @@ const bundle = await esbuild.build({ export { TREE_STATE, TreeStateService } from "./src/services/tree-state-service.ts"; export { registerService } from "./src/services/service-registry.ts"; export { DOCK } from "./src/services/panel-registry.ts"; - export { QUICK_INPUT_SERVICE } from "./src/parts/quickinput/quick-input.ts"; + export { QUICK_INPUT_SERVICE } from "./src/services/quick-input-service.ts"; export { EditorPanel } from "./src/parts/editor/editor-panel.ts"; export { initZones } from "./src/parts/layout/zones.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; `, resolveDir: path.join(uiDir, ".."), loader: "ts", diff --git a/crates/workshop/ui/test/gateway-config-menu.mjs b/crates/workshop/ui/test/gateway-config-menu.mjs index 1614fa9d3..6d4ba6640 100644 --- a/crates/workshop/ui/test/gateway-config-menu.mjs +++ b/crates/workshop/ui/test/gateway-config-menu.mjs @@ -35,7 +35,7 @@ const bundle = await esbuild.build({ export { KeybindingsRegistry } from "./src/services/keybinding-registry.ts"; export { CONTEXT_KEY_SERVICE } from "./src/services/context-key-service.ts"; export { getService, registerService } from "./src/services/service-registry.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; export { Menu } from "./src/parts/menu/menu.ts"; export { KeybindingDispatcher } from "./src/parts/layout/keybinding-dispatcher.ts"; export { initZones } from "./src/parts/layout/zones.ts"; diff --git a/crates/workshop/ui/test/helpers/tauri-event-stub.mjs b/crates/workshop/ui/test/helpers/tauri-event-stub.mjs index f66eb402c..99f8dfcde 100644 --- a/crates/workshop/ui/test/helpers/tauri-event-stub.mjs +++ b/crates/workshop/ui/test/helpers/tauri-event-stub.mjs @@ -2,7 +2,7 @@ // esbuild's `alias` in the workspace-files unit test. Only `emit` is // doubled: it is the one export the bundled contributions import. Every // emit records its event name and payload on window.__TAURI_EVENTS__ so -// the test can assert what the page told the shell; a scripted failure +// the test can assert what the page told the desktop app; a scripted failure // (window.__TAURI_EVENTS__.fail = true) rejects the emit the way a page // without event permissions would. // Export-only module: the node --test runner discovers every file under 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 000000000..7446e1766 --- /dev/null +++ b/crates/workshop/ui/test/json-request-timeout.mjs @@ -0,0 +1,137 @@ +// 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"; + export { 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", +}); +const code = bundle.outputFiles[0].text; +const { jsonRequest, workspace, ErrorCatalog } = 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); + check( + "a write's 408 is typed as DeadlineElapsed, not HttpStatus", + caught !== null && caught.code === ErrorCatalog.DeadlineElapsed, + ); +}); + +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/crates/workshop/ui/test/lazy-panel-sizing.mjs b/crates/workshop/ui/test/lazy-panel-sizing.mjs index 43b347381..a3d2ce7ec 100644 --- a/crates/workshop/ui/test/lazy-panel-sizing.mjs +++ b/crates/workshop/ui/test/lazy-panel-sizing.mjs @@ -1,14 +1,14 @@ -// Regression test for the lazy panel shell's sizing contract +// Regression test for the lazy panel placeholder's sizing contract // (src/parts/layout/panel-types.ts LazyPanel, .ws-panel-lazy in // src/parts/layout/zones.css, and the agent session's feed/input split in // src/parts/agent/agent-session.css). Dockview mounts the LazyPanel element // as the content of a leaf; the real panel swaps in underneath it. Every -// panel root sizes itself with `height: 100%`, so the shell between it +// panel root sizes itself with `height: 100%`, so the placeholder between it // and dockview's content container must pass the container's height // through - otherwise the agent feed grows with its transcript instead of // scrolling, pushes the prompt input below the window, and softlocks the // panel. jsdom has no layout engine, so the test pins the structural -// contract: the declared styles the cascade assigns to the shell, the +// contract: the declared styles the cascade assigns to the placeholder, the // panel root, and the feed; the feed-then-input DOM order; the autoscroll // landing on the feed element when a message appends; and LazyPanel // forwarding dockview's layout(width, height) to the real panel. @@ -69,7 +69,7 @@ const html = await readFile(path.join(uiDir, "index.html"), "utf8"); const dom = new JSDOM(html, { url: "http://127.0.0.1:7910/", pretendToBeVisual: true }); const { window } = dom; -// The sheets whose declarations size the shell, the panel root, and the +// The sheets whose declarations size the placeholder, the panel root, and the // feed. jsdom resolves declared values through the cascade (it applies // no layout), so the assertions read the declared contract, not pixels. const style = window.document.createElement("style"); @@ -201,7 +201,7 @@ async function flush() { const computed = (element) => window.getComputedStyle(element); -// --- The agent session mounts through the lazy shell into the dock ---------- +// --- The agent session mounts through the lazy placeholder into the dock ---------- window.localStorage.clear(); const dock = createDockview(window.document.getElementById("dock"), { @@ -219,23 +219,23 @@ await flush(); const panelRoot = window.document.querySelector("#dock .ws-agent-panel"); check("the agent panel mounted into the dock", panelRoot !== null); -const shell = panelRoot?.parentElement ?? null; -check("the agent panel mounts inside the lazy shell", shell?.classList.contains("ws-panel-lazy") === true); +const placeholder = panelRoot?.parentElement ?? null; +check("the agent panel mounts inside the lazy placeholder", placeholder?.classList.contains("ws-panel-lazy") === true); check( - "the lazy shell is a direct child of dockview's content container", - shell?.parentElement?.classList.contains("dv-content-container") === true, + "the lazy placeholder is a direct child of dockview's content container", + placeholder?.parentElement?.classList.contains("dv-content-container") === true, ); -// The sizing chain: the shell hands the container's height through, the -// panel root fills the shell, the feed is the flexible scroll region. -if (shell !== null) { - const shellStyle = computed(shell); - check("the lazy shell is full height", shellStyle.height === "100%"); +// The sizing chain: the placeholder hands the container's height through, the +// panel root fills the placeholder, the feed is the flexible scroll region. +if (placeholder !== null) { + const placeholderStyle = computed(placeholder); + check("the lazy placeholder is full height", placeholderStyle.height === "100%"); check( - "the lazy shell is a flex column", - shellStyle.display === "flex" && shellStyle.flexDirection === "column", + "the lazy placeholder is a flex column", + placeholderStyle.display === "flex" && placeholderStyle.flexDirection === "column", ); - check("the lazy shell may shrink below its content", shellStyle.minHeight === "0px"); + check("the lazy placeholder may shrink below its content", placeholderStyle.minHeight === "0px"); } if (panelRoot !== null) { const rootStyle = computed(panelRoot); @@ -320,8 +320,8 @@ globalThis.__makeSizedPanel = () => ({ }); const lazy = createPanelComponent({ id: "sized", name: "sized" }); -check("the lazy shell has its sizing class", lazy.element.className === "ws-panel-lazy"); -check("the lazy shell implements layout", typeof lazy.layout === "function"); +check("the lazy placeholder has its sizing class", lazy.element.className === "ws-panel-lazy"); +check("the lazy placeholder implements layout", typeof lazy.layout === "function"); // A resize before the chunk resolves replays at the swap. lazy.layout?.(640, 480); lazy.init({ params: {}, api: {} }); diff --git a/crates/workshop/ui/test/menubar-submenu.mjs b/crates/workshop/ui/test/menubar-submenu.mjs index 0ad62a824..98ed77935 100644 --- a/crates/workshop/ui/test/menubar-submenu.mjs +++ b/crates/workshop/ui/test/menubar-submenu.mjs @@ -36,7 +36,7 @@ const bundle = await esbuild.build({ export { ContextKeyService } from "./src/services/context-key-service.ts"; export { createKeybindingsRegistry } from "./src/services/keybinding-registry.ts"; export { registerService } from "./src/services/service-registry.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; `, resolveDir: path.join(uiDir, ".."), loader: "ts", diff --git a/crates/workshop/ui/test/no-local-storage.mjs b/crates/workshop/ui/test/no-local-storage.mjs index 13569c2f5..252cfc8b6 100644 --- a/crates/workshop/ui/test/no-local-storage.mjs +++ b/crates/workshop/ui/test/no-local-storage.mjs @@ -1,5 +1,5 @@ // Guard for the UI-state migration (plan step 13): the SPA keeps no -// browser-storage state. The shell binds the server to an OS-assigned +// browser-storage state. The desktop app binds the server to an OS-assigned // loopback port, so the page origin - and every origin-scoped storage // entry with it - changes on each launch; every store now reads and // writes through the server-backed UI-state adapter instead. Walks every diff --git a/crates/workshop/ui/test/open-recent.mjs b/crates/workshop/ui/test/open-recent.mjs index 2d4e6059e..304bd10b0 100644 --- a/crates/workshop/ui/test/open-recent.mjs +++ b/crates/workshop/ui/test/open-recent.mjs @@ -22,7 +22,7 @@ const bundle = await esbuild.build({ contents: ` export { createRecentMenuProvider, createFileQuickAccessProvider, isWorkspaceFilePath } from "./src/parts/workspace/open-recent.ts"; export { registerService } from "./src/services/service-registry.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; `, resolveDir: path.join(uiDir, ".."), loader: "ts", diff --git a/crates/workshop/ui/test/reconnect-backoff.mjs b/crates/workshop/ui/test/reconnect-backoff.mjs new file mode 100644 index 000000000..dba8558b5 --- /dev/null +++ b/crates/workshop/ui/test/reconnect-backoff.mjs @@ -0,0 +1,125 @@ +// Unit test for the shared reconnect backoff (src/services/reconnect-backoff.ts, +// consumed by workshop-socket.ts and agent-socket.ts): exponential growth, +// the cap, and the reset that a successful open triggers. Bundles the module +// with esbuild and drives it against scripted fake timers, so the growth, +// cap, and reset are pinned deterministically without waiting on a real +// clock: the delay argument each schedule hands to setTimeout is captured, +// and the queued callback is fired by hand. +// Run: node --test test/reconnect-backoff.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"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); + +const bundle = await esbuild.build({ + stdin: { + contents: `export { ReconnectBackoff } from "./src/services/reconnect-backoff.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-reconnect-backoff-test.mjs"); +await writeFile(bundlePath, bundle.outputFiles[0].text); +const { ReconnectBackoff } = await import(pathToFileURL(bundlePath).href); + +const failures = []; +function check(name, condition) { + if (!condition) failures.push(name); +} + +// Scripted timers: capture the delay and callback without waiting, so the +// growth, cap, and reset are asserted on the exact delay values the module +// hands to setTimeout. +const pending = []; +let nextId = 1; +globalThis.setTimeout = (fn, delay) => { + const id = nextId++; + pending.push({ id, fn, delay, cleared: false, fired: false }); + return id; +}; +globalThis.clearTimeout = (id) => { + const timer = pending.find((entry) => entry.id === id); + if (timer !== undefined) timer.cleared = true; +}; + +/** The delay of the most recently scheduled, still-pending attempt. */ +function lastDelay() { + const live = pending.filter((entry) => !entry.cleared && !entry.fired); + return live.length === 0 ? undefined : live[live.length - 1].delay; +} + +/** Fires the oldest pending attempt, as the real timer would. */ +function fireNext() { + const timer = pending.find((entry) => !entry.cleared && !entry.fired); + if (timer === undefined) throw new Error("no pending timer to fire"); + timer.fired = true; + timer.fn(); +} + +// --- Growth, the cap, and the reset ----------------------------------------- + +{ + const backoff = new ReconnectBackoff({ initialMs: 1000, maxMs: 3000 }); + let retries = 0; + const retry = () => { + retries += 1; + }; + + backoff.schedule(retry); + check("the first retry waits the initial delay", lastDelay() === 1000); + backoff.schedule(retry); + check( + "a second schedule while one is waiting stacks nothing", + pending.filter((entry) => !entry.cleared && !entry.fired).length === 1, + ); + + fireNext(); + check("the first attempt's retry fires", retries === 1); + backoff.schedule(retry); + check("the delay doubles after a failed attempt", lastDelay() === 2000); + + fireNext(); + backoff.schedule(retry); + check("the delay caps at the maximum instead of doubling past it", lastDelay() === 3000); + + fireNext(); + backoff.schedule(retry); + check("the delay stays capped at the maximum", lastDelay() === 3000); + + fireNext(); + backoff.reset(); + backoff.schedule(retry); + check("reset restores the initial delay", lastDelay() === 1000); + fireNext(); +} + +// --- The defaults ----------------------------------------------------------- + +{ + const backoff = new ReconnectBackoff(); + backoff.schedule(() => {}); + check("the default initial delay is one second", lastDelay() === 1000); + backoff.cancel(); + check( + "cancel clears the pending timer", + pending.filter((entry) => !entry.cleared && !entry.fired).length === 0, + ); +} + +if (failures.length > 0) { + console.error(`reconnect-backoff: ${failures.length} failure(s)`); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log("reconnect-backoff: all assertions passed"); +process.exit(0); diff --git a/crates/workshop/ui/test/shared-status-bar.mjs b/crates/workshop/ui/test/shared-status-bar.mjs index e03594f91..efd585f0b 100644 --- a/crates/workshop/ui/test/shared-status-bar.mjs +++ b/crates/workshop/ui/test/shared-status-bar.mjs @@ -1,4 +1,4 @@ -// Unit test for the shared status bar shell (shared-ui/status-bar.ts): +// Unit test for the shared status bar view (shared-ui/status-bar.ts): // the barberpole beside the consumer's indicators group (setBusy shows // and hides the barberpole, the group stays visible throughout and keeps // its contents, the barberpole precedes the group in DOM order), the @@ -33,7 +33,7 @@ const bundle = await esbuild.build({ // and jsdom applies no stylesheets anyway. loader: { ".css": "empty" }, }); -const { createStatusBarShell } = await import( +const { createStatusBarView } = await import( `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` ); @@ -42,71 +42,71 @@ function check(name, condition) { if (!condition) failures.push(name); } -const shell = createStatusBarShell(); -window.document.body.append(shell.element); +const view = createStatusBarView(); +window.document.body.append(view.element); // A consumer's indicator: busy toggling must never touch its contents. const led = window.document.createElement("span"); led.className = "status-bar__led"; -shell.indicators.append(led); +view.indicators.append(led); -// --- The shell's structure ---------------------------------------------------- +// --- The view's structure ---------------------------------------------------- -check("the element is the status-bar footer", shell.element.matches("footer.status-bar")); -check("the bar is a polite live region", shell.element.getAttribute("aria-live") === "polite"); +check("the element is the status-bar footer", view.element.matches("footer.status-bar")); +check("the bar is a polite live region", view.element.getAttribute("aria-live") === "polite"); check( - "the barberpole is the shell's element of that class", - shell.barberpole === shell.element.querySelector(".status-bar__barberpole"), + "the barberpole is the view's element of that class", + view.barberpole === view.element.querySelector(".status-bar__barberpole"), ); -check("the barberpole starts hidden", shell.barberpole.hidden === true); -check("the indicators group starts visible", shell.indicators.hidden === false); +check("the barberpole starts hidden", view.barberpole.hidden === true); +check("the indicators group starts visible", view.indicators.hidden === false); check( "the barberpole sits in the right group", - shell.barberpole.parentElement?.matches(".status-bar__right") === true, + view.barberpole.parentElement?.matches(".status-bar__right") === true, ); check( "the barberpole precedes the indicators group in DOM order", - (shell.barberpole.compareDocumentPosition(shell.indicators) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, + (view.barberpole.compareDocumentPosition(view.indicators) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, ); check( "the barberpole is an indeterminate progressbar to assistive tech", - shell.barberpole.getAttribute("role") === "progressbar" && - !shell.barberpole.hasAttribute("aria-valuenow"), + view.barberpole.getAttribute("role") === "progressbar" && + !view.barberpole.hasAttribute("aria-valuenow"), ); -check("no element remains in the shell", shell.element.querySelector("progress") === null); -check("the text region starts empty", shell.text.textContent === ""); -check("the extras region is empty until the consumer fills it", shell.extras.childElementCount === 0); +check("no element remains in the view", view.element.querySelector("progress") === null); +check("the text region starts empty", view.text.textContent === ""); +check("the extras region is empty until the consumer fills it", view.extras.childElementCount === 0); // --- The busy toggle -------------------------------------------------------------- -shell.setBusy(true); -check("setBusy(true) shows the barberpole", shell.barberpole.hidden === false); -check("setBusy(true) leaves the indicators group visible", shell.indicators.hidden === false); -check("setBusy(true) kept the consumer's LED in the group", shell.indicators.contains(led)); +view.setBusy(true); +check("setBusy(true) shows the barberpole", view.barberpole.hidden === false); +check("setBusy(true) leaves the indicators group visible", view.indicators.hidden === false); +check("setBusy(true) kept the consumer's LED in the group", view.indicators.contains(led)); -shell.setBusy(true); -check("a repeated setBusy(true) keeps the barberpole shown", shell.barberpole.hidden === false); +view.setBusy(true); +check("a repeated setBusy(true) keeps the barberpole shown", view.barberpole.hidden === false); -shell.setBusy(false); -check("setBusy(false) hides the barberpole", shell.barberpole.hidden === true); -check("setBusy(false) leaves the indicators group visible", shell.indicators.hidden === false); -check("the group still holds the consumer's LED", shell.indicators.contains(led)); -check("the shell exposes no renderSlot", typeof shell.renderSlot === "undefined"); -check("the shell exposes no progress element", typeof shell.progress === "undefined"); +view.setBusy(false); +check("setBusy(false) hides the barberpole", view.barberpole.hidden === true); +check("setBusy(false) leaves the indicators group visible", view.indicators.hidden === false); +check("the group still holds the consumer's LED", view.indicators.contains(led)); +check("the view exposes no renderSlot", typeof view.renderSlot === "undefined"); +check("the view exposes no progress element", typeof view.progress === "undefined"); // --- The text region -------------------------------------------------------------- -shell.setText("Downloading model", { tooltip: "1 of 2" }); -check("setText sets the label", shell.text.textContent === "Downloading model"); -check("setText sets the tooltip on the bar", shell.element.title === "1 of 2"); -check("the error styling starts off", !shell.text.classList.contains("status-bar__text--error")); +view.setText("Downloading model", { tooltip: "1 of 2" }); +check("setText sets the label", view.text.textContent === "Downloading model"); +check("setText sets the tooltip on the bar", view.element.title === "1 of 2"); +check("the error styling starts off", !view.text.classList.contains("status-bar__text--error")); -shell.setText("The download failed", { error: true }); -check("an error label takes the error styling", shell.text.classList.contains("status-bar__text--error")); -check("a missing tooltip clears the bar's title", shell.element.title === ""); +view.setText("The download failed", { error: true }); +check("an error label takes the error styling", view.text.classList.contains("status-bar__text--error")); +check("a missing tooltip clears the bar's title", view.element.title === ""); -shell.setText("Ready", { error: false }); -check("a later setText clears the error styling", !shell.text.classList.contains("status-bar__text--error")); +view.setText("Ready", { error: false }); +check("a later setText clears the error styling", !view.text.classList.contains("status-bar__text--error")); if (failures.length > 0) { console.error(`shared-status-bar: ${failures.length} failure(s)`); diff --git a/crates/workshop/ui/test/titlebar-macos.mjs b/crates/workshop/ui/test/titlebar-macos.mjs index 0baf7ece8..368b96a51 100644 --- a/crates/workshop/ui/test/titlebar-macos.mjs +++ b/crates/workshop/ui/test/titlebar-macos.mjs @@ -1,4 +1,4 @@ -// Title bar under macOS overlay chrome (plan step 22): the shell runs the +// Title bar under macOS overlay chrome (plan step 22): the desktop app runs the // window with titleBarStyle Overlay and a hidden title, so the native // traffic lights float over the bar's left edge and cover // close/minimize/zoom. window-chrome.ts detects the platform through the diff --git a/crates/workshop/ui/test/workshop-layout.mjs b/crates/workshop/ui/test/workshop-layout.mjs index 0b4b15280..f657b33b3 100644 --- a/crates/workshop/ui/test/workshop-layout.mjs +++ b/crates/workshop/ui/test/workshop-layout.mjs @@ -346,8 +346,8 @@ check("the envelope records the zone groups", new StatusBar(); check("the status bar is a direct child of body", !!window.document.querySelector("body > .status-bar")); -check("the status bar is outside the shell and the dock", - window.document.querySelector(".ws-shell .status-bar") === null && +check("the status bar is outside the desk and the dock", + window.document.querySelector(".ws-desk .status-bar") === null && window.document.querySelector("#dock .status-bar") === null); check("the status bar never enters the serialized layout", !JSON.stringify(envelope.layout).includes("status-bar")); 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 000000000..220873c96 --- /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/crates/workshop/ui/test/workshop-zones.mjs b/crates/workshop/ui/test/workshop-zones.mjs index 80720dd44..51fcc9c64 100644 --- a/crates/workshop/ui/test/workshop-zones.mjs +++ b/crates/workshop/ui/test/workshop-zones.mjs @@ -24,6 +24,10 @@ const uiDir = path.dirname(fileURLToPath(import.meta.url)); const bundle = await esbuild.build({ stdin: { contents: ` + // The dock lifecycle reads the closed-editor stack through its token; + // the implementation's module scope self-registers the empty default, + // so importing it here keeps close/empty-group handling working. + import "./src/parts/editor/closed-editors.ts"; export { createDockview, themeDark } from "dockview"; export { initZones, diff --git a/crates/workshop/ui/test/workspace-drops.mjs b/crates/workshop/ui/test/workspace-drops.mjs index 00ef4b154..b1d4bdd68 100644 --- a/crates/workshop/ui/test/workspace-drops.mjs +++ b/crates/workshop/ui/test/workspace-drops.mjs @@ -185,7 +185,7 @@ for (const desktop of [false, true]) { // --- The WebView2 bridge receives a drop's File objects ---------------------- -// A drop holding files posts them to the shell under the workspace-drop +// A drop holding files posts them to the desktop app under the workspace-drop // message; without the bridge (plain browser) the same drop is only // default-suppressed. jsdom lacks DragEvent and File, so plain markers // stand in for the File objects - the module hands them over untouched. diff --git a/crates/workshop/ui/test/workspace-files.mjs b/crates/workshop/ui/test/workspace-files.mjs index 1dcb06e47..4d869fa15 100644 --- a/crates/workshop/ui/test/workspace-files.mjs +++ b/crates/workshop/ui/test/workspace-files.mjs @@ -15,7 +15,7 @@ // the path in the recent-files store, with nothing painted on the status // bar; a run with a path argument (Open Recent, Ctrl+P; TWF-003) posting // that path without ever reaching the picker, a non-string or absent -// argument still reaching it; a shell that rejects the emit leaving the open committed (the +// argument still reaching it; a desktop app that rejects the emit leaving the open committed (the // recent recorded, the invalidation fired, a console.warn and no // unhandled rejection); a server refusal painting the error on the // status bar while emitting nothing, recording nothing, and @@ -44,13 +44,18 @@ const bundle = await esbuild.build({ stdin: { contents: ` import "./src/parts/workspace-files/workspace-files.contribution.ts"; + // The contribution reads the closed-editor stack through its token; the + // implementation's module scope self-registers the empty default, so + // importing it here keeps the switch's state snapshot working without a + // live adapter. + import "./src/parts/editor/closed-editors.ts"; export { register } from "./src/parts/workspace-files/index.ts"; export { Commands } from "./src/services/command-registry.ts"; export { Menus } from "./src/services/menu-registry.ts"; export { RECENT_FILES_STORE, RecentFilesStore } from "./src/services/recent-files-store.ts"; export { registerService } from "./src/services/service-registry.ts"; export { currentWorkspaceFile, putWindowState } from "./src/services/workspace-file-client.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; export { initZones } from "./src/parts/layout/zones.ts"; `, resolveDir: path.join(uiDir, ".."), diff --git a/crates/workshop/ui/test/workspace-switch.mjs b/crates/workshop/ui/test/workspace-switch.mjs index ff2e7137c..9a3c9b9a8 100644 --- a/crates/workshop/ui/test/workspace-switch.mjs +++ b/crates/workshop/ui/test/workspace-switch.mjs @@ -48,10 +48,11 @@ const bundle = await esbuild.build({ export { registerService } from "./src/services/service-registry.ts"; export { UI_STORAGE } from "./src/services/ui-storage.ts"; export { TREE_STATE, TreeStateService } from "./src/services/tree-state-service.ts"; - export { CLOSED_EDITORS, ClosedEditors } from "./src/parts/editor/closed-editors.ts"; + export { ClosedEditors } from "./src/parts/editor/closed-editors.ts"; + export { CLOSED_EDITORS } from "./src/services/closed-editors.ts"; export { initZones } from "./src/parts/layout/zones.ts"; export { LAYOUT_SCHEMA_VERSION, startLayoutPersistence } from "./src/parts/layout/layout-persistence.ts"; - export { STATUS_BAR } from "./src/parts/status/status-bar.ts"; + export { STATUS_BAR } from "./src/services/status-bar.ts"; export { WorkshopTreePanel } from "./src/parts/layout/workshop-panel.ts"; export { WindowTitle } from "./src/parts/chrome/command-center.ts"; `, diff --git a/crates/workshop/ui/test/zone-stability.mjs b/crates/workshop/ui/test/zone-stability.mjs index 353d32a1a..468227f6e 100644 --- a/crates/workshop/ui/test/zone-stability.mjs +++ b/crates/workshop/ui/test/zone-stability.mjs @@ -25,6 +25,10 @@ const uiDir = path.dirname(fileURLToPath(import.meta.url)); const bundle = await esbuild.build({ stdin: { contents: ` + // The dock lifecycle reads the closed-editor stack through its token; + // the implementation's module scope self-registers the empty default, + // so importing it here keeps close/empty-group handling working. + import "./src/parts/editor/closed-editors.ts"; export { createDockview, themeDark } from "dockview"; export { initZones, diff --git a/crates/workshop/user-state/README.md b/crates/workshop/user-state/README.md deleted file mode 100644 index fb43a0cdb..000000000 --- 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/user-state/src/error.rs b/crates/workshop/user-state/src/error.rs index fbf05ac9a..bdd7cf587 100644 --- a/crates/workshop/user-state/src/error.rs +++ b/crates/workshop/user-state/src/error.rs @@ -9,7 +9,6 @@ //! the response body in debug builds only; production bodies stay at //! each variant's own message. -use std::fmt::Write as _; use std::io; use axum::http::{StatusCode, header}; @@ -21,14 +20,10 @@ use axum::response::{IntoResponse, Response}; // cause across the workspace rather than one per crate. use shared_error_source::JsonSource; use workshop_protocol::ErrorEnvelope; +use workshop_support::{LEAK_DETAIL, StateBucketError, render_message}; use crate::store::USER_STATE_KEYS; -/// Whether wire bodies include internal failure detail. Debug builds append -/// the source chain to the envelope message; production bodies stay at -/// the variant's own message. -const LEAK_DETAIL: bool = cfg!(debug_assertions); - /// A user-state operation failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -101,21 +96,18 @@ impl IntoResponse for UserStateError { } } -/// Renders the envelope message for `error`: its own `Display` text, with -/// the source chain appended as `: cause` segments when `leak_detail` is -/// set. -fn render_message(error: &UserStateError, leak_detail: bool) -> String { - let mut message = error.to_string(); - if leak_detail { - let mut source = std::error::Error::source(error); - while let Some(cause) = source { - // fmt::Write to a String cannot fail; the Result is a trait - // artifact. - let _ = write!(message, ": {cause}"); - source = cause.source(); +impl From for UserStateError { + /// Maps a shared state-bucket refusal onto this crate's wire error, + /// preserving each refusal's own message. + fn from(error: StateBucketError) -> Self { + match error { + StateBucketError::Key(key) => Self::Key(key), + StateBucketError::TooLarge { actual, cap } => Self::TooLarge { actual, cap }, + StateBucketError::NotJson { source } => Self::NotJson { + source: source.into(), + }, } } - message } #[cfg(test)] diff --git a/crates/workshop/user-state/src/handlers.rs b/crates/workshop/user-state/src/handlers.rs index 03b705617..3c9df9669 100644 --- a/crates/workshop/user-state/src/handlers.rs +++ b/crates/workshop/user-state/src/handlers.rs @@ -24,11 +24,11 @@ use serde_json::Value; use workshop_support::{DEFAULT_DEADLINE, with_deadline}; use crate::error::UserStateError; -use crate::store::{UserStateStore, check_text_cap, user_state_key}; +use crate::store::{USER_STATE_KEYS, USER_STATE_VALUE_CAP, UserStateStore}; /// 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( @@ -70,11 +70,8 @@ async fn store_value( key: &str, body: &[u8], ) -> Result { - let key = user_state_key(key)?; - check_text_cap(body.len())?; - let value: Value = serde_json::from_slice(body).map_err(|source| UserStateError::NotJson { - source: source.into(), - })?; + let value = + workshop_support::validate_bucket_body(key, &USER_STATE_KEYS, body, USER_STATE_VALUE_CAP)?; store.put(key, value).await?; Ok(serde_json::json!({ "saved": true })) } diff --git a/crates/workshop/user-state/src/handles.rs b/crates/workshop/user-state/src/handles.rs new file mode 100644 index 000000000..a1247f7cc --- /dev/null +++ b/crates/workshop/user-state/src/handles.rs @@ -0,0 +1,37 @@ +//! The user-state subsystem's registration: its `/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. + +use std::sync::Arc; + +use workshop_registry::{Registration, Registry, RouteRegistrarAdapter}; + +use crate::handlers; +use crate::store::UserStateStore; + +/// The user-state subsystem's registration guards: its routes and its +/// state handle. Dropping them deregisters the subsystem. +#[derive(Debug)] +#[must_use = "dropping the registrations deregisters the subsystem"] +pub struct UserStateRegistrations { + /// The `/user/state` route registrar. + pub routes: Registration, + /// The store as the subsystem's state handle. + pub state: Registration, +} + +/// Registers the user-state subsystem into the registry: its +/// `/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 +/// process lifetime. +pub fn register(registry: &Registry, store: Arc) -> UserStateRegistrations { + let routes = registry.register_routes(Arc::new(RouteRegistrarAdapter::new({ + let store = Arc::clone(&store); + move || handlers::routes(Arc::clone(&store)) + }))); + let state = registry.register_state::(store); + UserStateRegistrations { routes, state } +} diff --git a/crates/workshop/user-state/src/lib.rs b/crates/workshop/user-state/src/lib.rs index 9c49984d2..9a487443a 100644 --- a/crates/workshop/user-state/src/lib.rs +++ b/crates/workshop/user-state/src/lib.rs @@ -9,7 +9,7 @@ //! - Tier: feature; may depend on: `workshop-protocol`, //! `workshop-registry`, `workshop-support`. Never on //! `workshop-workspace`, `workshop-server`, or any `gateway-*` or -//! `promptforge-*` crate. Read `AGENTS.md` before adding an import. +//! `promptforge-*` crate. Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - The server stores each value verbatim and never interprets it @@ -22,31 +22,14 @@ //! 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; +mod handles; mod store; -use std::sync::Arc; - -use workshop_registry::{Registration, Registry, RouteRegistrarAdapter}; - pub use error::UserStateError; pub use handlers::routes; +pub use handles::{UserStateRegistrations, register}; 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 -/// 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 -/// process lifetime. -pub fn register(registry: &Registry, store: Arc) -> (Registration, Registration) { - let routes = registry.register_routes(Arc::new(RouteRegistrarAdapter::new({ - let store = Arc::clone(&store); - move || handlers::routes(Arc::clone(&store)) - }))); - let state = registry.register_state::(store); - (routes, state) -} diff --git a/crates/workshop/user-state/src/store.rs b/crates/workshop/user-state/src/store.rs index e9915cc28..62ffc0bca 100644 --- a/crates/workshop/user-state/src/store.rs +++ b/crates/workshop/user-state/src/store.rs @@ -79,10 +79,10 @@ impl UserStateStore { /// JSON text exceeds [`USER_STATE_VALUE_CAP`], and /// [`UserStateError::Io`] when the write fails. pub async fn put(&self, key: &str, value: Value) -> Result<(), UserStateError> { - let key = user_state_key(key)?; + let key = workshop_support::resolve_bucket_key(key, &USER_STATE_KEYS)?; // The compact serialization is what the document holds, so its // length is the size the cap governs. - check_text_cap(value.to_string().len())?; + workshop_support::check_bucket_cap(value.to_string().len(), USER_STATE_VALUE_CAP)?; let mut state = self.state.lock().await; state.insert(key.to_owned(), value); // Serializing a map of already-parsed values cannot fail; a @@ -107,35 +107,6 @@ impl UserStateStore { } } -/// Resolves `key` to its allow-list entry. -/// -/// # Errors -/// Returns [`UserStateError::Key`] when `key` is not one of -/// [`USER_STATE_KEYS`]. -pub(crate) fn user_state_key(key: &str) -> Result<&'static str, UserStateError> { - USER_STATE_KEYS - .iter() - .copied() - .find(|allowed| *allowed == key) - .ok_or_else(|| UserStateError::Key(key.to_owned())) -} - -/// Checks that a value's JSON text of `actual` bytes fits under the cap. -/// The route boundary judges the raw body's length with this before -/// parsing it, so an oversized body is refused without being parsed. -/// -/// # Errors -/// Returns [`UserStateError::TooLarge`] past the cap. -pub(crate) fn check_text_cap(actual: usize) -> Result<(), UserStateError> { - if actual > USER_STATE_VALUE_CAP { - return Err(UserStateError::TooLarge { - actual, - cap: USER_STATE_VALUE_CAP, - }); - } - Ok(()) -} - /// Loads the document at `path`. A missing file is the ordinary "no /// state yet" and is silent; an unreadable file, one that does not parse, /// or one whose top level is not an object is corrupt state: logged once diff --git a/crates/workshop/workspace/Cargo.toml b/crates/workshop/workspace/Cargo.toml index 0f6701046..67e16d3d2 100644 --- a/crates/workshop/workspace/Cargo.toml +++ b/crates/workshop/workspace/Cargo.toml @@ -19,8 +19,6 @@ axum.workspace = true dunce.workspace = true humantime.workspace = true percent-encoding.workspace = true -# The engine's public API: the parser behind the `/prompts/contract` route. -promptforge.workspace = true serde.workspace = true serde_json.workspace = true shared-error-source = { workspace = true, features = ["database", "json"] } diff --git a/crates/workshop/workspace/README.md b/crates/workshop/workspace/README.md deleted file mode 100644 index a682caa5b..000000000 --- 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/crates/workshop/workspace/src/error.rs b/crates/workshop/workspace/src/error.rs index f4bd21ddf..861c11766 100644 --- a/crates/workshop/workspace/src/error.rs +++ b/crates/workshop/workspace/src/error.rs @@ -8,7 +8,6 @@ //! in debug builds only; production bodies stay at each variant's own //! message. -use std::fmt::Write as _; use std::io; use axum::http::{StatusCode, header}; @@ -20,14 +19,10 @@ use axum::response::{IntoResponse, Response}; // the cause across the workspace rather than one per crate. use shared_error_source::JsonSource; use workshop_protocol::ErrorEnvelope; +use workshop_support::{LEAK_DETAIL, StateBucketError, render_message}; use crate::workspace_file::{UI_STATE_KEYS, WorkspaceFileError}; -/// Whether wire bodies include internal failure detail. Debug builds append -/// the source chain to the envelope message; production bodies stay at -/// the variant's own message. -const LEAK_DETAIL: bool = cfg!(debug_assertions); - /// A workspace operation failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -282,21 +277,18 @@ impl IntoResponse for WorkspaceError { } } -/// Renders the envelope message for `error`: its own `Display` text, with -/// the source chain appended as `: cause` segments when `leak_detail` is -/// set. -fn render_message(error: &WorkspaceError, leak_detail: bool) -> String { - let mut message = error.to_string(); - if leak_detail { - let mut source = std::error::Error::source(error); - while let Some(cause) = source { - // fmt::Write to a String cannot fail; the Result is a trait - // artifact. - let _ = write!(message, ": {cause}"); - source = cause.source(); +impl From for WorkspaceError { + /// Maps a shared state-bucket refusal onto this crate's wire error, + /// preserving each refusal's own message. + fn from(error: StateBucketError) -> Self { + match error { + StateBucketError::Key(key) => Self::UiStateKey(key), + StateBucketError::TooLarge { actual, cap } => Self::UiStateTooLarge { actual, cap }, + StateBucketError::NotJson { source } => Self::UiStateNotJson { + source: source.into(), + }, } } - message } #[cfg(test)] diff --git a/crates/workshop/workspace/src/handlers.rs b/crates/workshop/workspace/src/handlers.rs index 2eebe432c..5a7804df2 100644 --- a/crates/workshop/workspace/src/handlers.rs +++ b/crates/workshop/workspace/src/handlers.rs @@ -17,12 +17,8 @@ use crate::blocking::try_blocking; use crate::error::WorkspaceError; use crate::workspace::Workspace; -#[path = "handlers-file.rs"] mod file; -#[path = "handlers-file-state.rs"] mod file_state; -#[path = "handlers-prompts.rs"] -mod prompts; /// The workspace routes, narrowed to the [`Workspace`] service - the only /// state their handlers use: the confined filesystem routes here, the @@ -30,6 +26,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)) @@ -38,9 +47,8 @@ pub fn routes(state: Workspace) -> axum::Router { .route("/workspace/revoke", post(revoke)) .merge(file::routes()) .merge(file_state::routes()) - .merge(prompts::routes()) .with_state(state), - DEFAULT_DEADLINE, + limit, ) } @@ -203,5 +211,4 @@ fn respond(result: Result) -> Response { } #[cfg(test)] -#[path = "handlers-tests.rs"] mod tests; diff --git a/crates/workshop/workspace/src/handlers-file.rs b/crates/workshop/workspace/src/handlers/file.rs similarity index 96% rename from crates/workshop/workspace/src/handlers-file.rs rename to crates/workshop/workspace/src/handlers/file.rs index 85b2fe07d..b3ea47e83 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( @@ -142,5 +142,4 @@ async fn after_switch(workspace: &Workspace, result: Result<(), WorkspaceError>) } #[cfg(test)] -#[path = "handlers-file-tests.rs"] mod tests; diff --git a/crates/workshop/workspace/src/handlers-file-tests.rs b/crates/workshop/workspace/src/handlers/file/tests.rs similarity index 99% rename from crates/workshop/workspace/src/handlers-file-tests.rs rename to crates/workshop/workspace/src/handlers/file/tests.rs index 8ba6a9376..bc88127a4 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-state.rs b/crates/workshop/workspace/src/handlers/file_state.rs similarity index 87% rename from crates/workshop/workspace/src/handlers-file-state.rs rename to crates/workshop/workspace/src/handlers/file_state.rs index c9e40626b..52339df41 100644 --- a/crates/workshop/workspace/src/handlers-file-state.rs +++ b/crates/workshop/workspace/src/handlers/file_state.rs @@ -18,11 +18,10 @@ use axum::body::Bytes; use axum::extract::{Path, State}; use axum::response::Response; use axum::routing::{get, put}; -use serde_json::Value; use crate::error::WorkspaceError; use crate::workspace::Workspace; -use crate::workspace_file::{check_ui_state_cap, ui_state_key}; +use crate::workspace_file::ui_state_kv::{UI_STATE_KEYS, UI_STATE_VALUE_CAP}; use super::file::SavedResponse; use super::respond; @@ -61,16 +60,11 @@ async fn store( key: &str, body: &[u8], ) -> Result { - let key = ui_state_key(key)?; - check_ui_state_cap(body.len())?; - let value: Value = - serde_json::from_slice(body).map_err(|source| WorkspaceError::UiStateNotJson { - source: source.into(), - })?; + let value = + workshop_support::validate_bucket_body(key, &UI_STATE_KEYS, body, UI_STATE_VALUE_CAP)?; let saved = workspace.put_ui_state(key, value).await?; Ok(SavedResponse { saved }) } #[cfg(test)] -#[path = "handlers-file-state-tests.rs"] mod tests; diff --git a/crates/workshop/workspace/src/handlers-file-state-tests.rs b/crates/workshop/workspace/src/handlers/file_state/tests.rs similarity index 99% rename from crates/workshop/workspace/src/handlers-file-state-tests.rs rename to crates/workshop/workspace/src/handlers/file_state/tests.rs index 46fb63197..3e0bfb78a 100644 --- a/crates/workshop/workspace/src/handlers-file-state-tests.rs +++ b/crates/workshop/workspace/src/handlers/file_state/tests.rs @@ -11,7 +11,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt as _; use crate::handlers::routes; -use crate::workspace_file::ui_state::UI_STATE_VALUE_CAP; +use crate::workspace_file::ui_state_kv::UI_STATE_VALUE_CAP; /// Collects a response body already buffered in memory and parses it. async fn json_body(response: Response) -> serde_json::Value { diff --git a/crates/workshop/workspace/src/handlers-tests.rs b/crates/workshop/workspace/src/handlers/tests.rs similarity index 100% rename from crates/workshop/workspace/src/handlers-tests.rs rename to crates/workshop/workspace/src/handlers/tests.rs diff --git a/crates/workshop/workspace/src/handles.rs b/crates/workshop/workspace/src/handles.rs index 651bb2c0b..dfd7d50aa 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; @@ -14,17 +14,28 @@ use workshop_registry::{ use crate::handlers; use crate::workspace::Workspace; +/// The workspace subsystem's registration guards: its routes, its state +/// handle, and its granted-roots view. Dropping them deregisters the +/// subsystem. +#[derive(Debug)] +#[must_use = "dropping the registrations deregisters the subsystem"] +pub struct WorkspaceRegistrations { + /// The `/workspace/*` route registrar. + pub routes: Registration, + /// The workspace itself as its state handle. + pub state: Registration, + /// The granted-roots view same-tier subsystems read. + pub roots: Registration, +} + /// 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 /// root holds them for the process lifetime. -pub fn register( - registry: &Registry, - workspace: &Workspace, -) -> (Registration, Registration, Registration) { +pub fn register(registry: &Registry, workspace: &Workspace) -> WorkspaceRegistrations { let routes = registry.register_routes(Arc::new(RouteRegistrarAdapter::new({ let workspace = workspace.clone(); move || handlers::routes(workspace.clone()) @@ -35,17 +46,21 @@ pub fn register( let workspace = workspace.clone(); move || workspace.granted_roots() }))); - (routes, state, roots) + WorkspaceRegistrations { + routes, + state, + roots, + } } /// Registers the subsystem's one background task: the shutdown lever /// 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 22d24c43c..5f11ce84b 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -9,10 +9,8 @@ //! ## Invariants //! //! - Tier: feature; may depend on: `workshop-protocol`, -//! `workshop-registry`, `workshop-support`, the service crates, and -//! `promptforge` (the promptforge product's public API, -//! for the `/prompts/contract` parse route). Read `AGENTS.md` before -//! adding an import. +//! `workshop-registry`, `workshop-support`, and the service crates. +//! Read the repository-root `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - Every request path is checked lexically (no `..`, and on Windows no @@ -25,7 +23,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; @@ -33,13 +31,20 @@ 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; -pub use handles::{register, register_tasks}; +#[cfg(feature = "test-fixtures")] +pub use handlers::routes_with_deadline; +pub use handles::{WorkspaceRegistrations, register, register_tasks}; pub use workspace::{ EntryKind, FileContents, GrantEntry, TreeEntry, TreeListing, Workspace, WorkspaceSummary, }; #[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 000000000..70604b6ff --- /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 a374e05d6..915368345 100644 --- a/crates/workshop/workspace/src/workspace.rs +++ b/crates/workshop/workspace/src/workspace.rs @@ -25,13 +25,9 @@ use serde::Serialize; use crate::error::WorkspaceError; use crate::workspace_file::{WindowState, now_rfc3339}; -#[path = "workspace-backing.rs"] mod backing; -#[path = "workspace-confine.rs"] mod confine; -#[path = "workspace-pointer.rs"] mod pointer; -#[path = "workspace-token.rs"] mod token; use backing::Backing; @@ -175,6 +171,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 +185,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 +390,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 = @@ -480,14 +482,10 @@ impl Workspace { } #[cfg(test)] -#[path = "workspace-tests.rs"] mod tests; #[cfg(test)] -#[path = "workspace-tests-close.rs"] mod tests_close; #[cfg(test)] -#[path = "workspace-tests-reopen.rs"] mod tests_reopen; #[cfg(test)] -#[path = "workspace-tests-switch.rs"] mod tests_switch; diff --git a/crates/workshop/workspace/src/workspace-backing.rs b/crates/workshop/workspace/src/workspace/backing.rs similarity index 99% rename from crates/workshop/workspace/src/workspace-backing.rs rename to crates/workshop/workspace/src/workspace/backing.rs index d24f1abcb..f280291ae 100644 --- a/crates/workshop/workspace/src/workspace-backing.rs +++ b/crates/workshop/workspace/src/workspace/backing.rs @@ -21,8 +21,7 @@ use crate::workspace_file::{ use super::confine::names_same_file; use super::{GrantEntry, GrantMeta, Workspace, WorkspaceSummary}; -#[path = "workspace-ui-state.rs"] -mod ui_state; +mod ui_state_memory; /// The display name of a workspace that has no file yet. pub(crate) const EPHEMERAL_NAME: &str = "Untitled"; diff --git a/crates/workshop/workspace/src/workspace-ui-state.rs b/crates/workshop/workspace/src/workspace/backing/ui_state_memory.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-ui-state.rs rename to crates/workshop/workspace/src/workspace/backing/ui_state_memory.rs diff --git a/crates/workshop/workspace/src/workspace-confine.rs b/crates/workshop/workspace/src/workspace/confine.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-confine.rs rename to crates/workshop/workspace/src/workspace/confine.rs diff --git a/crates/workshop/workspace/src/workspace-pointer.rs b/crates/workshop/workspace/src/workspace/pointer.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-pointer.rs rename to crates/workshop/workspace/src/workspace/pointer.rs diff --git a/crates/workshop/workspace/src/workspace-tests.rs b/crates/workshop/workspace/src/workspace/tests.rs similarity index 98% rename from crates/workshop/workspace/src/workspace-tests.rs rename to crates/workshop/workspace/src/workspace/tests.rs index 9d97d05d9..511588473 100644 --- a/crates/workshop/workspace/src/workspace-tests.rs +++ b/crates/workshop/workspace/src/workspace/tests.rs @@ -2,14 +2,11 @@ use super::*; -#[path = "workspace-tests-backing.rs"] mod backing; -#[path = "workspace-tests-grants.rs"] mod grants; -#[path = "workspace-tests-pointer.rs"] +mod jail; mod pointer; -#[path = "workspace-tests-ui-state.rs"] -mod ui_state; +mod ui_state_memory_tests; /// A workspace with one granted tempdir, returned alongside so the /// directory outlives the test. @@ -140,7 +137,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 +161,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/crates/workshop/workspace/src/workspace-tests-backing.rs b/crates/workshop/workspace/src/workspace/tests/backing.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-tests-backing.rs rename to crates/workshop/workspace/src/workspace/tests/backing.rs diff --git a/crates/workshop/workspace/src/workspace-tests-grants.rs b/crates/workshop/workspace/src/workspace/tests/grants.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-tests-grants.rs rename to crates/workshop/workspace/src/workspace/tests/grants.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 000000000..5292ebcc5 --- /dev/null +++ b/crates/workshop/workspace/src/workspace/tests/jail.rs @@ -0,0 +1,164 @@ +//! 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. + +#[cfg(windows)] +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-pointer.rs b/crates/workshop/workspace/src/workspace/tests/pointer.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-tests-pointer.rs rename to crates/workshop/workspace/src/workspace/tests/pointer.rs diff --git a/crates/workshop/workspace/src/workspace-tests-ui-state.rs b/crates/workshop/workspace/src/workspace/tests/ui_state_memory_tests.rs similarity index 99% rename from crates/workshop/workspace/src/workspace-tests-ui-state.rs rename to crates/workshop/workspace/src/workspace/tests/ui_state_memory_tests.rs index e61b19a15..6c1273438 100644 --- a/crates/workshop/workspace/src/workspace-tests-ui-state.rs +++ b/crates/workshop/workspace/src/workspace/tests/ui_state_memory_tests.rs @@ -271,7 +271,7 @@ async fn an_over_cap_value_is_refused_without_touching_memory_or_file() { let path = home.path().join("cap.pfwork"); let workspace = Workspace::new(); workspace.save_as(&path).await.expect("save as creates"); - let cap = crate::workspace_file::ui_state::UI_STATE_VALUE_CAP; + let cap = crate::workspace_file::ui_state_kv::UI_STATE_VALUE_CAP; let oversized = string_of_serialized_len(cap + 1); assert_eq!(oversized.to_string().len(), cap + 1); diff --git a/crates/workshop/workspace/src/workspace-tests-close.rs b/crates/workshop/workspace/src/workspace/tests_close.rs similarity index 96% rename from crates/workshop/workspace/src/workspace-tests-close.rs rename to crates/workshop/workspace/src/workspace/tests_close.rs index d7322e399..1198d7f44 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-tests-reopen.rs b/crates/workshop/workspace/src/workspace/tests_reopen.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-tests-reopen.rs rename to crates/workshop/workspace/src/workspace/tests_reopen.rs diff --git a/crates/workshop/workspace/src/workspace-tests-switch.rs b/crates/workshop/workspace/src/workspace/tests_switch.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-tests-switch.rs rename to crates/workshop/workspace/src/workspace/tests_switch.rs diff --git a/crates/workshop/workspace/src/workspace-token.rs b/crates/workshop/workspace/src/workspace/token.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-token.rs rename to crates/workshop/workspace/src/workspace/token.rs diff --git a/crates/workshop/workspace/src/workspace_file.rs b/crates/workshop/workspace/src/workspace_file.rs index b0059c36c..aed7eef6e 100644 --- a/crates/workshop/workspace/src/workspace_file.rs +++ b/crates/workshop/workspace/src/workspace_file.rs @@ -9,7 +9,7 @@ //! //! Schema v1 holds three tables: `meta`, `grants`, and `kv`. The `kv` //! table holds the window geometry and the opaque ui-state values the -//! SPA owns ([`ui_state`]). These table names are reserved for +//! SPA owns ([`ui_state_kv`]). These table names are reserved for //! follow-on projects and unused in v1: `agent_windows`, `run_presets`, //! `runs`, `run_events`, `agents`, `documents`. @@ -26,17 +26,14 @@ use serde::{Deserialize, Serialize}; use shared_error_source::DatabaseSource; use tokio::sync::{mpsc, oneshot}; -#[path = "workspace_file-actor.rs"] mod actor; -#[path = "workspace_file-siblings.rs"] mod siblings; -#[path = "workspace_file-ui-state.rs"] -pub(crate) mod ui_state; +pub(crate) mod ui_state_kv; pub(crate) use actor::now_rfc3339; use actor::{COMMAND_QUEUE_DEPTH, Command, SCHEMA_V1}; use siblings::{already_taken, copy_siblings_or_clean_up, plan_siblings}; -pub(crate) use ui_state::{UI_STATE_KEYS, check_ui_state_cap, empty_ui_state, ui_state_key}; +pub(crate) use ui_state_kv::{UI_STATE_KEYS, check_ui_state_cap, empty_ui_state, ui_state_key}; use crate::blocking::{blocking, try_blocking}; @@ -49,7 +46,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 +141,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. @@ -490,5 +487,4 @@ pub(crate) fn stem_of(path: &Path) -> String { } #[cfg(test)] -#[path = "workspace-file-tests.rs"] mod tests; diff --git a/crates/workshop/workspace/src/workspace_file-actor.rs b/crates/workshop/workspace/src/workspace_file/actor.rs similarity index 99% rename from crates/workshop/workspace/src/workspace_file-actor.rs rename to crates/workshop/workspace/src/workspace_file/actor.rs index 568525e2c..f3e16495d 100644 --- a/crates/workshop/workspace/src/workspace_file-actor.rs +++ b/crates/workshop/workspace/src/workspace_file/actor.rs @@ -7,7 +7,7 @@ use std::time::SystemTime; use tokio::sync::{mpsc, oneshot}; -use super::ui_state::{put_ui_state_row, read_ui_state_rows}; +use super::ui_state_kv::{put_ui_state_row, read_ui_state_rows}; use super::{ FORMAT_NAME, GrantRow, KV_WINDOW, META_CREATED_AT, META_FORMAT, META_NAME, META_VERSION, SUPPORTED_VERSION, WindowState, WorkspaceContents, WorkspaceFileError, io_failure, @@ -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-siblings.rs b/crates/workshop/workspace/src/workspace_file/siblings.rs similarity index 100% rename from crates/workshop/workspace/src/workspace_file-siblings.rs rename to crates/workshop/workspace/src/workspace_file/siblings.rs diff --git a/crates/workshop/workspace/src/workspace-file-tests.rs b/crates/workshop/workspace/src/workspace_file/tests.rs similarity index 99% rename from crates/workshop/workspace/src/workspace-file-tests.rs rename to crates/workshop/workspace/src/workspace_file/tests.rs index 7d3f6647c..a0c60d41a 100644 --- a/crates/workshop/workspace/src/workspace-file-tests.rs +++ b/crates/workshop/workspace/src/workspace_file/tests.rs @@ -6,10 +6,8 @@ use std::path::PathBuf; use super::actor::SCHEMA_V1; use super::*; -#[path = "workspace-file-tests-mutations.rs"] mod mutations; -#[path = "workspace-file-tests-ui-state.rs"] -mod ui_state; +mod ui_state_kv_tests; #[test] fn the_database_variant_reaches_the_engine_error_through_the_shared_wrapper() { diff --git a/crates/workshop/workspace/src/workspace-file-tests-mutations.rs b/crates/workshop/workspace/src/workspace_file/tests/mutations.rs similarity index 100% rename from crates/workshop/workspace/src/workspace-file-tests-mutations.rs rename to crates/workshop/workspace/src/workspace_file/tests/mutations.rs diff --git a/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs b/crates/workshop/workspace/src/workspace_file/tests/ui_state_kv_tests.rs similarity index 99% rename from crates/workshop/workspace/src/workspace-file-tests-ui-state.rs rename to crates/workshop/workspace/src/workspace_file/tests/ui_state_kv_tests.rs index a934a9b6e..6f13a4da9 100644 --- a/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs +++ b/crates/workshop/workspace/src/workspace_file/tests/ui_state_kv_tests.rs @@ -8,7 +8,7 @@ use serde_json::json; use super::*; use crate::Workspace; use crate::error::WorkspaceError; -use crate::workspace_file::ui_state::{UI_STATE_KEYS, UI_STATE_VALUE_CAP}; +use crate::workspace_file::ui_state_kv::{UI_STATE_KEYS, UI_STATE_VALUE_CAP}; /// A contents value with no grants, no window, and no ui state. fn bare_contents(name: &str) -> WorkspaceContents { diff --git a/crates/workshop/workspace/src/workspace_file-ui-state.rs b/crates/workshop/workspace/src/workspace_file/ui_state_kv.rs similarity index 90% rename from crates/workshop/workspace/src/workspace_file-ui-state.rs rename to crates/workshop/workspace/src/workspace_file/ui_state_kv.rs index e72362526..d8d4e3fa7 100644 --- a/crates/workshop/workspace/src/workspace_file-ui-state.rs +++ b/crates/workshop/workspace/src/workspace_file/ui_state_kv.rs @@ -32,11 +32,7 @@ pub(crate) fn empty_ui_state() -> BTreeMap<&'static str, Option> { /// Returns [`WorkspaceError::UiStateKey`] when `key` is not one of /// [`UI_STATE_KEYS`]. pub(crate) fn ui_state_key(key: &str) -> Result<&'static str, WorkspaceError> { - UI_STATE_KEYS - .iter() - .copied() - .find(|allowed| *allowed == key) - .ok_or_else(|| WorkspaceError::UiStateKey(key.to_string())) + workshop_support::resolve_bucket_key(key, &UI_STATE_KEYS).map_err(WorkspaceError::from) } /// Checks that a value whose JSON text is `len` bytes fits under the cap. @@ -44,13 +40,7 @@ pub(crate) fn ui_state_key(key: &str) -> Result<&'static str, WorkspaceError> { /// # Errors /// Returns [`WorkspaceError::UiStateTooLarge`] past the cap. pub(crate) fn check_ui_state_cap(len: usize) -> Result<(), WorkspaceError> { - if len > UI_STATE_VALUE_CAP { - return Err(WorkspaceError::UiStateTooLarge { - actual: len, - cap: UI_STATE_VALUE_CAP, - }); - } - Ok(()) + workshop_support::check_bucket_cap(len, UI_STATE_VALUE_CAP).map_err(WorkspaceError::from) } /// Checks that `json_text` fits under the cap and parses as JSON. The @@ -61,11 +51,7 @@ pub(crate) fn check_ui_state_cap(len: usize) -> Result<(), WorkspaceError> { /// [`WorkspaceError::UiStateNotJson`] for text that does not parse. pub(crate) fn check_ui_state_text(json_text: &str) -> Result<(), WorkspaceError> { check_ui_state_cap(json_text.len())?; - serde_json::from_str::(json_text) - .map(|_| ()) - .map_err(|source| WorkspaceError::UiStateNotJson { - source: source.into(), - }) + workshop_support::check_bucket_text(json_text).map_err(WorkspaceError::from) } impl WorkspaceFile { diff --git a/guide/promptforge-agent-guide.md b/guide/promptforge-agent-guide.md index aaf159256..752330ebc 100644 --- a/guide/promptforge-agent-guide.md +++ b/guide/promptforge-agent-guide.md @@ -170,7 +170,7 @@ local text = models.infer(handle, 'Write a haiku about rain.') `models.get` addresses a catalog model by name and gives you a bound handle. `models.infer(handle, prompt)` runs the same kind of round as `models.infer(prompt)`: one direct, tool-free completion on a fresh conversation, using the handle's frozen binding. Handles are plain inspectable values with no methods; every operation that accepts one takes it as the leading argument. -The handle's fields are read-only. `name` is the prompt-local alias. `model_id` is the caller-facing catalog model id. `description` is the capability description given at bind time. `context` is the catalog context window size in tokens. `thinking`, `temperature`, and `max_tokens` expose the frozen invocation settings, and they read nil when the bind declared none. +The handle's fields are read-only. `name` is the prompt-local alias. `model_id` is the caller-facing catalog model id. `description` is the model's capability description. `context` is the catalog context window size in tokens. `thinking`, `temperature`, and `max_tokens` expose the invocation settings the handle's rounds send, and each reads nil when nothing sets it. Only the options table of `models.use` sets `temperature` and `max_tokens`, so they read nil on a `models.get` handle. ## Send an image diff --git a/guide/promptforge-language-guide.md b/guide/promptforge-language-guide.md index b0523d755..2d0ebb276 100644 --- a/guide/promptforge-language-guide.md +++ b/guide/promptforge-language-guide.md @@ -375,7 +375,7 @@ models: Each key is a prompt-local label. A role declares a keyword set, an optional `min_context` token floor, and a description. -The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. +The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. `no-thinking` is satisfied by a model that never thinks or by one whose thinking is switchable, and only a model that always thinks is refused. On a switchable model every round under the role asks for thinking off. The switch is forwarded as `chat_template_kwargs.enable_thinking`, so an upstream that ignores that field keeps its own default. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. Today's fill is deliberately trivial: every declared role binds to the host's current model (in the Workshop, the dropdown's selection). The declaration is written for the full contract - roles, requirements, checks - so the same prompt runs unchanged when a smarter fill arrives; only the binding decisions change. @@ -393,9 +393,19 @@ The label names a role declared in the frontmatter `models` key, and an unknown Inside a section, `models.use('analyst')` selects a bound role by its label for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. +An optional second argument sets sampling options for the selection: + +````lua +models.use('analyst', { temperature = 0, max_tokens = 1024 }) +```` + +The table accepts two fields. `temperature` is a number from 0 to 2, written as an integer or a decimal. `max_tokens` is a positive integer that caps how many tokens the model generates. An unknown key, a value that breaks those rules, a second argument that is not a table, or a third argument fails the call with an error naming the option, required versus actual. + +The options apply to the rounds that run on this selection - `models.infer(prose)`, `models.loop(msgs)`, and any round on the handle this `models.use` call returns. Rounds on the prompt-wide default or on a `models.get` handle do not see them. A later `models.use` replaces the options along with the selection, so a plain `models.use('analyst')` clears them. Leaving a field out keeps the model's default. The value passes through to the provider as given, so a provider that refuses a temperature fails the round with its own error. + ## Inspecting a binding -Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. +Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. On the handle `models.use` returns, `temperature` and `max_tokens` show the section's options; on every other handle, and for a field the options leave out, they read nil. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. ## Direct inference @@ -460,7 +470,7 @@ models: models.default('analyst') ```` -The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements move into `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. +The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements move into `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. The old `models.bind` options `temperature` and `max_tokens` now go in the `models.use` options table, as in `models.use('analyst', { temperature = 0.2 })`. --- @@ -666,7 +676,7 @@ A run ships with these default limits: - a 16 MiB model response cap - 64 MiB of Lua memory per section state - 1024 Lua log events per section state -- a 120 second request timeout +- a model request limit of 120 seconds without progress: the wait for the response, and then for each next piece of the stream, restarts whenever data arrives, so a long reply that keeps streaming is never cut off A Lua block that exhausts a host resource quota fails with a typed quota error naming the exhausted resource: log events, log bytes, or instructions. diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md deleted file mode 100644 index 176908ed9..000000000 --- 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 9cbc74024..189e37d22 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/agent/03-chat-rounds.md b/guide/src/agent/03-chat-rounds.md index da940afbf..f3b6e5cb4 100644 --- a/guide/src/agent/03-chat-rounds.md +++ b/guide/src/agent/03-chat-rounds.md @@ -73,7 +73,7 @@ local text = models.infer(handle, 'Write a haiku about rain.') `models.get` addresses a catalog model by name and gives you a bound handle. `models.infer(handle, prompt)` runs the same kind of round as `models.infer(prompt)`: one direct, tool-free completion on a fresh conversation, using the handle's frozen binding. Handles are plain inspectable values with no methods; every operation that accepts one takes it as the leading argument. -The handle's fields are read-only. `name` is the prompt-local alias. `model_id` is the caller-facing catalog model id. `description` is the capability description given at bind time. `context` is the catalog context window size in tokens. `thinking`, `temperature`, and `max_tokens` expose the frozen invocation settings, and they read nil when the bind declared none. +The handle's fields are read-only. `name` is the prompt-local alias. `model_id` is the caller-facing catalog model id. `description` is the model's capability description. `context` is the catalog context window size in tokens. `thinking`, `temperature`, and `max_tokens` expose the invocation settings the handle's rounds send, and each reads nil when nothing sets it. Only the options table of `models.use` sets `temperature` and `max_tokens`, so they read nil on a `models.get` handle. ## Send an image diff --git a/guide/src/introduction.md b/guide/src/introduction.md index 51b5b2ad5..d17eba276 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/language/06-models.md b/guide/src/language/06-models.md index 7f554bf51..23afac7e0 100644 --- a/guide/src/language/06-models.md +++ b/guide/src/language/06-models.md @@ -16,7 +16,7 @@ models: Each key is a prompt-local label. A role declares a keyword set, an optional `min_context` token floor, and a description. -The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. +The keyword vocabulary is closed, and split in two. The hard keywords, `thinking` and `no-thinking`, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring `min_context: 200000` filled with a 32k model, or requiring `thinking` filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. `no-thinking` is satisfied by a model that never thinks or by one whose thinking is switchable, and only a model that always thinks is refused. On a switchable model every round under the role asks for thinking off. The switch is forwarded as `chat_template_kwargs.enable_thinking`, so an upstream that ignores that field keeps its own default. The soft keywords - `frontier`, `fast`, `small`, `creative`, and `chat` - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change. Today's fill is deliberately trivial: every declared role binds to the host's current model (in the Workshop, the dropdown's selection). The declaration is written for the full contract - roles, requirements, checks - so the same prompt runs unchanged when a smarter fill arrives; only the binding decisions change. @@ -34,9 +34,19 @@ The label names a role declared in the frontmatter `models` key, and an unknown Inside a section, `models.use('analyst')` selects a bound role by its label for that section. The selection is read when a model round starts, so a later `models.use` call in the same section replaces it and steers the next round. A section that runs a model round needs a model from `models.use` or from the prompt-wide default; with neither, the call fails with a model-required error. +An optional second argument sets sampling options for the selection: + +````lua +models.use('analyst', { temperature = 0, max_tokens = 1024 }) +```` + +The table accepts two fields. `temperature` is a number from 0 to 2, written as an integer or a decimal. `max_tokens` is a positive integer that caps how many tokens the model generates. An unknown key, a value that breaks those rules, a second argument that is not a table, or a third argument fails the call with an error naming the option, required versus actual. + +The options apply to the rounds that run on this selection - `models.infer(prose)`, `models.loop(msgs)`, and any round on the handle this `models.use` call returns. Rounds on the prompt-wide default or on a `models.get` handle do not see them. A later `models.use` replaces the options along with the selection, so a plain `models.use('analyst')` clears them. Leaving a field out keeps the model's default. The value passes through to the provider as given, so a provider that refuses a temperature fails the round with its own error. + ## Inspecting a binding -Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. +Every bound role is also a bare global holding an inspectable handle, and `models.get(label)` returns the same handle, with `name`, `label`, `capabilities`, `model_id`, `description`, `context`, `thinking`, `temperature`, and `max_tokens` fields. On the handle `models.use` returns, `temperature` and `max_tokens` show the section's options; on every other handle, and for a field the options leave out, they read nil. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument. ## Direct inference @@ -101,4 +111,4 @@ models: models.default('analyst') ```` -The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements move into `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. +The `models.bind` call is removed. What was its prose description now documents the role, the hard requirements move into `keywords` and `min_context`, and `models.default` and `models.use` name declared labels only. The old `models.bind` options `temperature` and `max_tokens` now go in the `models.use` options table, as in `models.use('analyst', { temperature = 0.2 })`. diff --git a/guide/src/language/09-limits-and-errors.md b/guide/src/language/09-limits-and-errors.md index adc3d46b6..123938ea2 100644 --- a/guide/src/language/09-limits-and-errors.md +++ b/guide/src/language/09-limits-and-errors.md @@ -21,7 +21,7 @@ A run ships with these default limits: - a 16 MiB model response cap - 64 MiB of Lua memory per section state - 1024 Lua log events per section state -- a 120 second request timeout +- a model request limit of 120 seconds without progress: the wait for the response, and then for each next piece of the stream, restarts whenever data arrives, so a long reply that keeps streaming is never cut off A Lua block that exhausts a host resource quota fails with a typed quota error naming the exhausted resource: log events, log bytes, or instructions. diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md deleted file mode 100644 index c37cbcece..000000000 --- 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 85ff23680..000000000 --- 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 79b925aab..000000000 --- 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 df571575e..000000000 --- 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 1b9bc48fa..000000000 --- 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 05b6a3502..000000000 --- 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 9f872e1e6..000000000 --- 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 bb561ed86..000000000 --- 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 e33e7e30c..000000000 --- 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 4449fa3ae..000000000 --- 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 60b99ceb0..000000000 --- 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 f51238ca2..000000000 --- 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 feb4cdbd2..8d121c6c4 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/tools/stage-gateway-sidecar.mjs b/tools/stage-gateway-sidecar.mjs index 21e929321..66879d0ca 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 e2e08e445..0418ae0b0 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-issues-69-59-70.md b/vibe/2026-09-24-2-issues-69-59-70.md new file mode 100644 index 000000000..adfa41512 --- /dev/null +++ b/vibe/2026-09-24-2-issues-69-59-70.md @@ -0,0 +1,354 @@ +--- +name: Fix issues 69, 59, and 70 +overview: "Fix three promptforge issues, one commit each on vibe2. Issue 69: a section sets `temperature` and `max_tokens` through an optional options table on `models.use`, applied to that selection, with an end-to-end test that the values reach the request body; the frontmatter stays the prompt's contract. Issue 59: gate the gateway tray's icon-tint helpers to Windows, Linux, and tests, so macOS builds stop warning. Issue 70: `no-thinking` binds a Switchable model, and the harness's whole-stream 120s cap becomes a deadline that every receive resets." +todos: + - id: step-1 + content: "Step 1 (models-use-options, issue 69): before the run, add the close-line convention to .cursor/rules/commit.mdc in the workspace; ModelBinding::with_invocation and re-bless public-api.txt; models.use options table decoded and validated in lua/src/models.rs, applied in resolve_model_binding, handle over the adjusted binding; Lua-crate and engine tests; 06-models.md and 03-chat-rounds.md edits and guide regeneration; draft PR text; commit with `close #69`" + status: pending + - id: step-2 + content: "Step 2 (tray-tint-gating, issue 59): cfg(any(windows, linux, test)) on grayed, error_tint, and tint in crates/gateway/app/src/tray/logic.rs with a doc note naming the callers; gateway tests, clippy, and headless check; commit with `close #59`; the user runs the Mac clippy check" + status: pending + - id: step-3 + content: "Step 3 (thinking-and-stream-deadlines, issue 70): no-thinking fails only on Always in fill.rs; per-receive tokio deadline in crates/harness/models/src/transport.rs keeping the ClientTimeout marker; RunLimits and guide docs; prepare and timing tests; full exit gates; commit with `close #70`" + status: pending +isProject: false +--- + +# Fix issues #69, #59, and #70 + + + +## Product Requirements + +Three open issues in the promptforge repository are fixed together, one commit each. Prompt authors can no longer set a sampling temperature or a generation cap (#69). macOS gateway builds emit two dead-code warnings (#59). Thinking-model turns die at a fixed 120-second timeout, and a prompt cannot ask a switchable model to stop thinking (#70). + +- Problem and users: + - #69: prompt authors, such as wg21-paperflow's papergate, cannot pin temperature or `max_tokens`, so repeated runs give different verdicts. Both were removed with `models.bind` in commit `0ac3e277`. + - #59: maintainers building the gateway on macOS get `dead_code` warnings for `grayed` and `error_tint`, so a macOS `clippy -D warnings` run fails. + - #70: harness hosts running thinking models hit the harness's 120-second cap on the whole stream, and `no-thinking` is reported unmet on every `Switchable` model. +- Goals: + - A section can set `temperature` and `max_tokens` for the model it selects. + - macOS gateway builds no longer warn about the two tray helpers. + - `no-thinking` binds to a `Switchable` model and asks it to stop thinking. + - A model stream times out only after a period in which no bytes arrive. +- Non-goals: + - Sampling or generation-cap fields in the frontmatter. + - A macOS CI job. + - Gateway changes for #70. + - Pushing or opening pull requests. +- Success criteria: + - Values set through `models.use` reach the request body, pinned by a test. + - `cargo clippy -p gateway --target aarch64-apple-darwin -- -D warnings` passes on a Mac. + - A stream that outlasts the timeout while still sending bytes succeeds, and a silent one fails as a timeout. +- Constraints: + - The frontmatter stays the prompt's contract: what the prompt requires from its environment. + - Commits go directly on `vibe2`, and each has `close #N` as the second non-empty line of its message. +- Open questions: None + +## Functional Specification + +A prompt author sets the two invocation values as an optional table on `models.use`. They apply to rounds on that selection and are strictly validated. A `no-thinking` role binds to a model that never thinks or one that can switch thinking off. Model streams time out on silence, not on total duration. + +- Actors and workflows: + - A prompt author calls `models.use(label, { temperature = t, max_tokens = n })` in a section. Every round on that selection sends both values. + - A prompt author gives a role `keywords: [no-thinking]`. Prepare accepts a `Never` or `Switchable` model, and rounds under the role ask for thinking off. + - A harness host runs a long thinking turn. It completes as long as bytes keep arriving. +- Inputs and outputs: + - Input: the optional second argument of `models.use`, a table with the keys `temperature` (a number from 0.0 to 2.0) and `max_tokens` (a positive integer). + - Output: `temperature` and `max_tokens` in the chat request body and in the Chat effect's log record. The handle `models.use` returns shows both values. +- States and validation: + - The options belong to the section's selection. A later `models.use` replaces both the selection and its options. + - Rounds on the prompt-wide default, or on a `models.get` handle, send neither field. + - Leaving a field out leaves the model's default in place. +- Errors and recovery: + - The `models.use` call fails on a non-table second argument, a third argument, a non-string key, an unknown key, or an invalid value. The message names the option and gives required versus actual. + - A provider that refuses a temperature fails that round with the provider's error. + - `no-thinking` against an `Always` model is still refused at prepare. + - A stream that receives no bytes for the timeout period fails as a `Transport` error, and `is_timeout()` and `is_retryable()` hold. +- Security and privacy behavior: No change. +- Acceptance criteria: + - Every case in the Testing Plan passes, and every verification gate is clean. + + + + +## Technical Design + +For #69, the Lua `models.use` call gains an optional argument. The options are applied to the binding in the single function both request paths use to resolve it, so the request and the run log agree. #59 is a per-platform compile gate. #70 changes one prepare check and the meaning of the harness timeout, without adding a setting. The frontmatter, the parser, and the gateway are unchanged. + +- Architecture: + +```mermaid +flowchart LR + Use["models.use opts"] --> Runtime[ModelRuntime] + Runtime --> Resolve[resolve binding] + Resolve -->|"options applied"| Binding[ModelBinding] + Binding --> Handle[Lua handle] + Binding --> Effect[Chat effect] + Effect --> Body[Request body] + Effect --> Record[Run log record] +``` + +- Modules and interfaces: + - `models.use` in `crates/promptforge-internal/lua/src/models.rs` records the validated options with the section's selection in `ModelRuntime`. + - `resolve_model_binding` (`crates/promptforge-internal/lua/src/vm.rs:1350`) applies the options when a round runs on the selection. Both places that build Chat effects already call it: `crates/promptforge-internal/engine/src/execute/scheduler/chat.rs:160` and `scheduler/dispatch.rs:256`. + - Handles hold their own copy of the binding (`crates/promptforge-internal/lua/src/protocol/parse.rs:240-264`). So the handle `models.use` returns sends the same values through `models.infer` and `models.loop`. + - `fill_model_bindings` (`crates/promptforge-internal/engine/src/execute/fill.rs:84-94`) fails `no-thinking` only on `ThinkingMode::Always`. + - `GatewayClient::complete` (`crates/harness/models/src/transport.rs:300-356`) bounds each receive instead of the whole request. +- File and public API changes: + - New public method `ModelBinding::with_invocation` in `crates/promptforge-internal/model-client/src/model/options.rs`. The facade re-exports it, and it is listed in `crates/promptforge/public-api.txt`. + - `models.use` gains an optional second argument, which is additive under `promptforge: 0`. + - `RunLimits::request_timeout` keeps its signature. Its meaning becomes the longest wait for the next receive. + - `grayed`, `error_tint`, and `tint` in `crates/gateway/app/src/tray/logic.rs` compile only on Windows, on Linux, and in tests. +- Data, persistence, failure, security, and privacy constraints: + - The Chat effect's log record reads the binding's invocation (`crates/promptforge-internal/engine/src/execute/run-effect.rs:149-167`). The options must therefore be on the binding, so that the run log and replay match the request. + - A timeout failure keeps the `ClientTimeout` marker. + + + + +## Testing Plan + +Each fix ships with its tests in the same commit. #69 is covered by Lua-crate unit tests and by an engine test that runs from Lua source to the request body. #70 is covered by prepare tests and by timing tests on the transport. #59 adds no tests; a clippy run on a Mac verifies it. + +- Unit: + - Lua crate (`crates/promptforge-internal/lua/src/models-tests.rs`): + - The options table is accepted, the returned handle reads both values, and an integer temperature such as `0` works. + - These are rejected: temperature `2.5`, `-0.1`, NaN (`0/0`), or a string; `max_tokens` of `0`, `-1`, `1.5`, or a string; an unknown key; a non-table second argument; and a third argument. + - A later plain `models.use(label)` clears the options, so the handle's fields read nil. + - Harness transport (`crates/harness/models/src/transport/tests/limits.rs`): + - Keep `a_request_past_the_timeout_is_a_timeout_transport_failure` (lines 103-129, headers never arrive) and update its comment. + - Steady stream: a 100ms budget, with chunks 50ms apart for about 250ms, then `[DONE]`. It must succeed. + - Trickled event: a single SSE event written in small pieces 30ms apart under a 100ms budget, so the one event takes about 300ms to arrive. It must succeed, which proves the deadline resets on every receive. + - Stall after the headers: one chunk, then silence. It must fail as `Transport` with `is_timeout()`. +- Integration and end-to-end: + - In `crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs`, change the fixture of `models_use_forwards_binding_completion_options_to_the_gateway` to call `models.use('analyst', { temperature = 0, max_tokens = 256 })`. + - Assert `body["temperature"] == 0.0` and `body["max_tokens"] == 256`, alongside the existing `enable_thinking` check. + - Replace the stale comment saying roles declare no sampling fields. + - This is the guard issue #69 asks for (its third ask). + - A following section that runs on the prompt-wide default sends neither field. Check this through the scripted gateway's full request history; if the gateway doesn't expose that history, make it a separate test. + - Prepare (`crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs`, beside the hard-keyword tests at lines 59-148): + - A `no-thinking` role on a `Switchable` model prepares with no unmet requirements, and its request body has `enable_thinking: false`. The test runs through prepare against a `ScriptedGateway`. If the prepare suite can't reach the gateway helpers, it goes in `model_and_reply.rs` instead. + - A `no-thinking` role on an `Always` model is still refused, with a notice naming `no-thinking` versus `Always`. +- Regression, security, and performance: + - `models_infer_rejects_a_third_argument` (`crates/promptforge-internal/engine/src/lua/tests/errors.rs:23`) stays unchanged. + - The existing `grayed` and `error_tint` tests (`crates/gateway/app/src/tray/logic.rs`, near lines 679 and 691) keep running on every platform. +- Exit criteria: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc` + - `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings` + - `cargo fmt --all --check` + - `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` with `RUSTDOCFLAGS="-D warnings"` (in PowerShell: `$env:RUSTDOCFLAGS="-D warnings"`) + - `cargo doc -p promptforge --no-deps` with the same `RUSTDOCFLAGS`, without `--all-features` + - `cargo +nightly-2026-09-05 xtask api --check` (the pinned nightly is named in `crates/build-xtask/src/api/toolchain.rs`) + - `cargo test -p build-xtask` + - `mdbook build guide` + - `cargo check -p gateway --no-default-features`, the headless build shape that clippy with `--all-features` doesn't cover + - On a Mac, run by the user: `cargo clippy -p gateway --target aarch64-apple-darwin -- -D warnings` + - Optional: `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. The workshop crates don't use the changed types. + + + + +## Decision Record + +- Decisions: + - `temperature` and `max_tokens` are set per section, through an optional options table on `models.use`, not in the frontmatter. They are invocation settings, not requirements on the environment, so the frontmatter stays a pure contract. The selection is already section-scoped state, read when each round starts. User: "for max_tokens this needs to be configurable on a per-section basis. did you plan to put it only in the YAML? it arguably is not part of the prompt's contract." The user then chose "An optional table on the selection, `models.use('writer', { max_tokens = 4096 })`", and for temperature, "Yes: out of the YAML, set per section through the same Lua mechanism". + - Both values are restored. Both were lost in the same commit, both already travel from the binding to the wire (`crates/promptforge-internal/model-client/src/client/request.rs:56-66`), and the Lua handle already exposes both fields (`crates/promptforge-internal/lua/src/models-userdata.rs:93-131`). User chose "Yes, restore both temperature and max_tokens". + - The options apply only to rounds on that selection, and to the handle `models.use` returns. Rounds on the default model and on `models.get` handles keep the model's defaults, so an option cannot leak to a different selection. The returned handle wraps the adjusted binding, so inspecting it shows the real values, and passing it to `models.infer` or `models.loop` behaves the same as the selection. This follows from the user's choice of the selection-bound form. + - A later `models.use` replaces the options along with the selection. The existing rule is that the latest selection steers the next round (`crates/promptforge-internal/lua/src/models.rs:95-100`). Merging options across calls would make a section's settings depend on its call history. + - The options go onto the binding in `resolve_model_binding`, not only onto the request options. The effect's log record reads `binding.invocation()` (`crates/promptforge-internal/engine/src/execute/run-effect.rs:149-167`), so the request and the run log agree, and replay compares correctly. One function feeds both effect sites, so there is exactly one place where the options are applied. + - The options table is validated strictly. Today the table is silently dropped (see the assumption below), which is the same kind of silent loss as #69 itself. Messages name the option and give required versus actual, because a model may read them as tool output. + - `Temperature::new` stays the only temperature validator (`crates/promptforge-internal/model-client/src/model/options.rs:14-51`). Its type already makes an invalid temperature unrepresentable. The decoding helpers are adapted from the ones deleted in `0ac3e277`. + - `ModelBinding::with_invocation` is the only public API addition. Rebuilding a binding from its getters through `ModelBinding::new` and `with_capabilities` would silently drop any field added later. The new builder mirrors `with_capabilities` (`options.rs:128-133`). + - The decoding stays in `models.rs`, which grows from about 220 lines to about 310. A third `models-*.rs` file beside `models-userdata.rs` and `models-tests.rs` would trigger the repository's convention of turning a three-file sibling group into a `models/` directory, and this fix doesn't need that churn. + - A declared temperature is sent as declared, and a provider that refuses it fails the round. Neither the gateway catalog nor the model descriptor records whether a model accepts temperature, so prepare has nothing to check against. Silently dropping the value would defeat pinning. User chose "Send it as declared; a provider that refuses it fails that round with the provider's error, documented in the guide". + - No language-version bump. The new argument is optional and additive, and `promptforge: 0` has no per-version field gating (`crates/promptforge-internal/parser/src/build.rs:317`). + - #59: the helpers compile only on Windows, on Linux, and in tests. macOS never calls them, by design: its template glyph is tinted by the system, and the phase shows only in the label and tooltip (`crates/gateway/app/src/tray/macos.rs:195-197`). The same file already gates `run_key_command` to its callers (`crates/gateway/app/src/tray/logic.rs:180-186`). Including `test` keeps the helper tests running everywhere. User: "can we fix this too". + - #59: no macOS CI job. User chose "No, just the cfg fix; I'll verify on a Mac myself". + - #70: `no-thinking` fails only on `Always`. A `Switchable` model can honor it, and the binding already asks for thinking off (`crates/promptforge-internal/engine/src/execute/context-bound.rs:59-66`) through `chat_template_kwargs.enable_thinking`. That is the switch the gateway contract names for switchable models (`crates/gateway-api-types/src/metadata.rs:13-18`). User chose "fix the no-thinking check (A) and switch the harness to first-byte plus idle deadlines (E)". + - #70: every receive of one or more bytes resets the deadline. The same `request_timeout` value bounds the wait for headers and each body read, and there is no new setting. The harness always streams, so arriving bytes are the sign that a turn is alive. The gap before the first chunk includes prompt processing, which can be long for big local prompts, so the wait between chunks must not be shorter than the wait for headers. User: "for the timeout, each individual receive of 1 or more bytes should reset the timeout." + - #70: the gateway stays unchanged. Streamed chat through it uses a client with only a connect timeout (`crates/gateway/protocol/src/http_util.rs:32-44`, `guide/src/gateway/11-serving-and-observing.md:57`). The 120-second 502 in the issue came from a non-streaming request, which the harness never sends. `FIRST_RESPONSE_TIMEOUT` (`crates/gateway/protocol/src/upstream.rs:487-499`) applies only to speech, and the 120-second client in `crates/gateway/cloud-providers/src/main.rs` belongs to the tool that builds the model catalog. + - Commits: one per issue, directly on `vibe2`, with `close #N` as the message's second non-empty line, and no push or pull request unless asked. The convention is also recorded in the workspace commit rule, so future issue fixes follow it. User: "Every commit that fixes an issue should have as the 2nd non-empty line of the commit message "close #N" where N is the issue number". The user also chose "Commit both directly on vibe2, one commit per issue". + - Decomposition: at most five steps, and one step per issue. Each issue is one commit with its own `close` line, so splitting an issue across steps would give it two commits. User: "keep steps tight (5 or less if possible)". +- Rejected alternatives: + - Per-role `temperature` and `max_tokens` fields in the frontmatter, which is what the issue suggested and what an earlier plan did. That design needed `Temperature` to deserialize via `serde(try_from = "f64")` and a parser dependency on `promptforge-model-client`. It also needed `Eq` dropped from `ModelRole`, `ModelRoles`, `Frontmatter`, and `Prompt`. The alternative of `impl Eq for Temperature` is sound, because the value is always finite, but it contradicts the NaN tests in `options.rs`. Rejected because these are invocation settings, not contract. Revisit if roles need a default that every section inherits without calling `models.use`. + - A per-role default in the frontmatter plus a per-section override. Rejected because it gives one value two sources. Revisit under the same condition as above. + - A section-scoped setter such as `models.options({ ... })` that applies to every round in the section. Rejected because the user chose the selection-bound form. Revisit if authors need the settings on default-model or `models.get` handle rounds without calling `models.use`. + - A per-call option on `models.infer` and `models.loop`. Rejected because it must be repeated at every call, and `models.infer` deliberately rejects a third argument. Revisit if a section needs different values from one round to the next. + - Declaring one role per temperature and switching roles with `models.use`, the only way to vary temperature by section in the old design. Rejected because roles are contract, and this multiplies them. + - An "accepts temperature" catalog capability, checked at prepare. Rejected as a large change across the gateway config, the catalog, and harness discovery. Revisit if provider refusals prove confusing in practice. + - Silently dropping temperature for models that refuse it. Rejected because it defeats reproducibility without telling anyone. + - #59: calling the helpers on macOS, or deleting them. Rejected because the system owns macOS tray tinting, while Windows and Linux need the helpers. Revisit if macOS moves off template glyphs. + - #59: a gateway-only macOS clippy job (`cargo clippy --locked -p gateway --all-targets -- -D warnings` on `macos-latest`). The user declined it, and macOS runner minutes cost more. CI currently runs clippy on Linux (`.github/workflows/ci.yml:25-67`) and on Windows for the workshop crates only (`ci.yml:149-186`). Revisit if macOS-only warnings recur. + - One topic branch per issue. Rejected because the user chose `vibe2`. Revisit if the fixes need separate pull requests. + - #70: the engine sending both thinking spellings (`chat_template_kwargs` plus a provider-specific field). Rejected because some providers reject unknown fields, and it would put provider knowledge in the engine. + - #70: separate budgets for headers and for gaps between chunks, like the gateway's speech path (`crates/gateway/protocol/src/upstream.rs:571-600`). Rejected because prompt processing happens before the first chunk, so a shorter between-chunk budget could kill a live turn, and one value is simpler. Revisit if a host needs faster stall detection mid-stream. +- Assumptions, risks, and notes: + - Assumption: mlua ignores arguments beyond a callback's declared parameters, so `models.use('writer', { temperature = 0 })` is silently ignored today. The new rejection tests pin the new behavior either way. + - Risk: OpenRouter may ignore `chat_template_kwargs.enable_thinking`. If it does, `no-thinking` passes prepare but Qwen keeps thinking. One real request through the gateway settles it. + - Risk: macOS can't be checked from the Windows development machine, because a macOS-target clippy run fails building the C code in `aws-lc-sys` and `ring` (both in `Cargo.lock`). Other macOS-only warnings may also have appeared since #59 was filed. + - Note: even with no timeout, the issue's thinking runs spent almost all of an 8,192-token budget on reasoning. `qwen3.8-flash` would return an empty reply and `qwen3.8-27b` a truncated one. Thinking-model turns need thinking off or a larger per-section `max_tokens`, which is why the `no-thinking` fix matters most. + - Note: per-section temperature never existed as its own feature. It always lived on the binding (`git show 0ac3e27^:crates/promptforge-lua/src/models/decode.rs`). + - Note: the existing gateway-body test binds the model directly (`crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs:21-30`), so it never exercised the prepare check. + - Note: the combined guides (`guide/promptforge-*-guide.md`) are generated from `guide/src` by `cargo run -p build-user-guide` (`crates/build-user-guide/src/main.rs:1-81`). + - Note: harness crates enforce a 500-line limit per file, checked by `cargo test -p build-xtask`. `transport.rs` is about 385 lines. + - Note: paths are relative to the promptforge repository root, which is checked out locally at `promptforge2/`. The commit rule lives outside that repository, at `.cursor/rules/commit.mdc` in the enclosing workspace. + - Note: the issues are https://github.com/cppalliance/promptforge/issues/69, https://github.com/cppalliance/promptforge/issues/59, and https://github.com/cppalliance/promptforge/issues/70. + - Note: at planning time the tree was clean on `vibe2` at `1fd82c62`, 19 commits ahead of `upstream/master`, and no `vibe/ACTIVE` file existed. Every line number in this plan refers to that commit. When a line has moved, the named symbol, section heading, or quoted text is authoritative. + - Note: `vibe2` has diverged from `origin/vibe2`: 1 commit exists only on the remote, and 156 exist only locally. This plan neither pulls nor pushes, so the divergence doesn't affect the run, but a later push needs a rebase or merge first. + - Note: the pinned nightly `nightly-2026-09-05` needed by `xtask api` is installed on the development machine, as are `cargo-nextest` and `mdbook`. + - Note: the commit-rule edit is in the enclosing workspace, not in the promptforge repository. It is done before the run, outside every step's commit. + - Note: the run's commit-message stage writes a subject, one paragraph, optional bullets, and trailers. Its template has no slot for the `close #N` line. So after each message is written, and before it is amended into the commit, confirm that the second non-empty line is exactly `close #N` for that step's issue, and insert it between the subject and the paragraph if it's missing. In the same pass, replace the first line with the subject that the step's Commit bullet names. + - Note: the run adds bookkeeping to the history. It first commits a copy of this plan under `vibe/` plus a `vibe/ACTIVE` marker, and Step 1 folds its changes into that commit, which therefore gets `close #69`. When all steps are done, it adds a final plan-closing commit. That commit fixes no issue, so it has no `close` line. + +### Deferred and Out of Scope + +- Deferred: having the gateway translate the thinking switch for remote upstreams, through a per-model setting modeled on `tool_dialect` (`crates/gateway/config/src/config.rs:500-547`). Revisit when a real OpenRouter request shows it ignores `chat_template_kwargs`. +- Deferred: exposing the request timeout through `harness-api`. The session currently hardcodes `RunLimits::new()` (`crates/harness/sessions/src/session/run.rs:86-87`). Revisit when a host needs a hard wall-clock ceiling. +- Deferred: making the gateway's 120-second non-streaming cap configurable (`crates/gateway/protocol/src/http_util.rs:20`), and adding an idle deadline to its streaming relay. Revisit when non-streaming clients hit the cap, or when stalled upstreams tie up concurrency slots. +- Deferred: any other macOS-only warnings the Mac clippy run finds. Revisit when that run reports them. +- Out of scope: a macOS CI job. +- Out of scope: pushing or opening pull requests. +- Out of scope: thinking control through the `models.use` options, since thinking stays with the role's keywords. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (plain `cargo build` builds only the gateway, the sole default member); desktop app via `cargo workshop` (alias for `run -p build-workshop --`, stages the gateway sidecar then builds `workshop`); never bare `cargo build -p workshop` without a staged sidecar. +- Focused test command pattern: `cargo nextest run --locked -p --all-features `; drop `--all-features` for `workshop`, `workshop-server`, `workshop-server-api`; doctests are not run by nextest, use `cargo test --locked -p --all-features --doc `; gateway fixture tests use `cargo test --locked -p gateway --no-default-features --features test-fixtures --test it `. +- Component test command pattern: `cargo nextest run --locked -p --all-features` then `cargo test --locked -p --all-features --doc`; for the three workshop crates omit `--all-features`, and `workshop-server` also needs `cargo nextest run --locked -p workshop-server --features headless`; UI packages: `npm test` in `crates/workshop/ui` or `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` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api` (the workspace run includes `build-xtask`, the boundary and structural harness). +- 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 `cargo check -p gateway --no-default-features` (the only permitted standalone check); UI: `npm run typecheck` in each UI package; facade surface (when the `promptforge` facade changes): `cargo + xtask api --check`. +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`; also the pre-commit hook). +- Docs command: with `RUSTDOCFLAGS="-D warnings"` (PowerShell: `$env:RUSTDOCFLAGS="-D warnings"`), `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` and `cargo doc -p promptforge --no-deps` (default features); user guide: `mdbook build guide`. +- Test placement and naming conventions: unit tests live beside the module as `foo-tests.rs` wired by `#[cfg(test)] #[path = "foo-tests.rs"] mod tests;`, or as `foo/tests.rs` plus `foo/tests/*.rs` once a module's tests span three or more files (for example `engine/src/execute/tests/`); integration tests are one binary per crate at `tests/it/main.rs` (`tests/suite/main.rs` in `promptforge`) with topic submodules and a `support.rs` or `common/mod.rs` helper; prompt fixtures are Markdown under `tests/prompts/execution/` and `tests/prompts/invalid/`; benches in `benches/` (engine, lua); test functions are snake_case behavior sentences such as `a_direct_launch_recovers_the_lease_from_a_terminated_owner`; test files open with a `//!` scope line; `unwrap`/`expect` are allowed in tests only; UI tests are `node --test` files at `test/**/*.mjs` and `src/**/*.test.mjs`, and `tools/*.test.mjs` sit beside their scripts; behavior changes ship tests in the same change. +- Directory map: `crates/` root is the public layer (`promptforge` facade, `harness-api`, `gateway-api-types`, `gateway-api-discovery`, `shared-error-source`, `shared-loopback`, `shared-ui` TypeScript+CSS package, `workspace-hack` from cargo-hakari, and `build-xtask`, `build-workshop`, `build-ui`, `build-user-guide`, `build-llama-cuda` tooling); manifestless family containers `crates/promptforge-internal/` (engine, types, vfs, lua, parser, store, model-client), `crates/harness/` (runner, models, capabilities, log, sessions, web, webfetch, web-search), `crates/gateway/` (app is the gateway binary, cloud-providers, config, config-ui with its `ui/` SPA, local, logging, progress, protocol, routing, web-search, `stt/` api, engine, backend-whisper, whisper-ffi), `crates/workshop/` (shell is the Tauri `workshop` crate, server, server-api, gateway, menu, protocol, registry, status, support, user-state, workspace, `ui/` TypeScript SPA); `guide/` mdbook user guide and product guides; `prompts/` example prompt files; `tools/` Node scripts for sidecar staging and live TTS; `vibe/` architecture doc and dated plan logs; `.github/workflows/` CI (`ci.yml`) and release, nightly, guide, miri, CUDA workflows; `.config/` nextest and hakari config; `.githooks/` pre-commit fmt and pre-push clippy/deny; `.cargo/config.toml` rust-lld on Windows plus `cargo workshop` and `cargo xtask` aliases; `images/` README art. +- Component boundaries: executor (`promptforge-engine`) is sans-I/O and reached only through the `promptforge` facade, and promptforge crates never depend on gateway, workshop, or harness; harness is the executor's only production host, reached only through `harness-api`, and may depend on `promptforge`, the gateway public pair, and shared-*; gateway is an independent process whose public surface is `gateway-api-types` and `gateway-api-discovery`, depending only on shared-*; workshop runs shell `workshop` to `workshop-server-api` to `workshop-server` and subsystems, may name the promptforge facade, `harness-api`, and the gateway public pair, never private gateway or harness crates; shared-* depend on no product crate; a container crate depends only on root crates and its siblings (build-* exempt); tiers flow shell to features to services to vocabulary; `cargo test -p build-xtask` enforces the graph, container privacy, the `//! ## Invariants` marker in workshop-* and harness-* `lib.rs`, and lint inheritance. +- Conventions summary: Rust 2024 on stable; every member sets `[lints] workspace = true` and depends on `workspace-hack`; workspace lints forbid unsafe outside owned boundaries (unsafe blocks have safety comments), warn on missing docs and `unreachable_pub`, and deny clippy all plus pedantic plus `unwrap_used`/`expect_used`; all versions pinned in `[workspace.dependencies]` with a comment explaining each non-obvious pin; files in marker crates stay at or under 500 lines; source directories are flat, with 1 or 2 related files as kebab siblings via `#[path]` and 3 or more rehydrated into a subdirectory; comments state only constraints, and workarounds cite an upstream issue URL; errors use thiserror with model-readable required-versus-actual messages and third-party causes wrapped through `shared-error-source`; run-log JSON is canonical (sorted keys, `float_roundtrip`, never `preserve_order`); Cargo features gate only real constraints (`test-fixtures`, `headless`); builds must not write into the repository (UI bundles build into `OUT_DIR`, CI checks a clean tree); no new structural checks without explicit user approval; SPA keeps CSS beside its TypeScript, uses `--ws-*` tokens, and never touches `localStorage`. + + + + +## Execution Instructions + + + +### Step 1: Add temperature and max_tokens options to models.use (#69) [completed] + +- Component: `models-use-options` + +- Placement: first of three components. It depends on neither of the others. It precedes Step 3 because both edit `guide/src/language/06-models.md` and regenerate the combined guides, so a fixed order keeps each commit's generated guides matching its own source edits. +- Pieces, built sequentially inside this one step: (1) the `ModelBinding::with_invocation` builder, (2) the `models.use` options in the Lua crate, (3) tests, (4) docs and the pull-request text. The builder comes first because `resolve_model_binding` calls it and the facade listing can only be regenerated once it exists. The pieces share one step because the engine end-to-end test passes only when the builder, the decoding, and the application all work, and the plan requires one commit per issue. +- Before this step, outside every step's commit: edit `.cursor/rules/commit.mdc` in the enclosing workspace (`c:\Users\Vinnie\cursor`, not the promptforge repository). All three commit messages in this plan depend on it. + - Add a `close #N` line to the message shape, where N is the issue number, after the subject and before the paragraph, present only when the commit fixes an issue. + - Note that the issue number comes from the request, since the diff can't show it. This is the one allowed exception to the rule that the message is written from the diff alone. + - Add a self-check item: when the commit fixes an issue, the second non-empty line is exactly `close #N`. +- Builder, in `crates/promptforge-internal/model-client/src/model/options.rs`: + - Add `ModelBinding::with_invocation(self, invocation: ModelInvocation) -> Self` beside `with_capabilities` (lines 128-133), mirroring it and returning the binding with its invocation replaced. It is the only public API addition, and the `promptforge` facade re-exports it. + - Regenerate `crates/promptforge/public-api.txt` with `cargo +nightly-2026-09-05 xtask api --bless`, then confirm with `cargo +nightly-2026-09-05 xtask api --check`. +- Lua options, in `crates/promptforge-internal/lua/src/models.rs`: + - Take the `models.use` arguments in a form that sees a third argument and a non-table second argument, such as `MultiValue`, because a typed parameter list drops extra arguments. + - Decode the optional table with helpers adapted from `value_as_temperature`, `decode_lua_number`, and `value_as_nonzero_u32` in `git show 0ac3e27^:crates/promptforge-lua/src/models/decode.rs`. `temperature` accepts a Lua integer or number and is validated only by `Temperature::new`. `max_tokens` is a positive integer stored as `NonZeroU32`. + - Reject a non-table second argument, a third argument, a non-string key, an unknown key, and an invalid value. Each message names the option and gives required versus actual, for example `models.use option temperature 3 is outside the supported range [0.0, 2.0]`. + - `ModelRuntime`'s selection (lines 80-101) becomes the label plus the validated options. A later `models.use` replaces both. + - `resolve_model_binding` (`crates/promptforge-internal/lua/src/vm.rs:1350`) applies the options through `ModelBinding::with_invocation` only when the round runs on the selection. Start from the binding's current `ModelInvocation` and override only the fields the options set, so an omitted field keeps the model's default. Rounds on the prompt-wide default or on a `models.get` handle are unchanged. Both Chat-effect sites already call this function (`crates/promptforge-internal/engine/src/execute/scheduler/chat.rs:160` and `scheduler/dispatch.rs:256`), so the request body and the run-log record (`crates/promptforge-internal/engine/src/execute/run-effect.rs:149-167`) agree. + - `models.use` returns `LuaModelHandle::from_binding` over the adjusted binding, so the handle sends the same values through `models.infer` and `models.loop`. + - Keep the module doc and the `install_models` doc accurate. Keep the decoding in `models.rs` (about 310 lines afterward); do not add a third `models-*.rs` sibling. +- Tests: + - `crates/promptforge-internal/lua/src/models-tests.rs`: the options table is accepted and the returned handle reads both values; an integer temperature such as `0` works; these are rejected: temperature `2.5`, `-0.1`, NaN (`0/0`), or a string, `max_tokens` of `0`, `-1`, `1.5`, or a string, an unknown key, a non-table second argument, and a third argument; a later plain `models.use(label)` clears the options, so the handle's fields read nil. + - `crates/promptforge-internal/engine/src/execute/tests/model_and_reply.rs`: in `models_use_forwards_binding_completion_options_to_the_gateway`, change the fixture to call `models.use('analyst', { temperature = 0, max_tokens = 256 })`. Assert `body["temperature"] == 0.0` and `body["max_tokens"] == 256` beside the existing `enable_thinking` check, and replace the stale comment saying roles declare no sampling fields. This is the guard the issue's third ask requests. + - A following section that runs on the prompt-wide default sends neither field. Check it through the scripted gateway's full request history, or as a separate test if the gateway doesn't expose that history. + - Regression: `models_infer_rejects_a_third_argument` (`crates/promptforge-internal/engine/src/lua/tests/errors.rs:23`) stays unchanged and passing. +- Docs: + - `guide/src/language/06-models.md`, "Selecting a model for a section" (around line 35): document the options table, the rules for both fields, and which rounds they apply to (the selection, plus the handle `models.use` returns). Say that a later `models.use` replaces them, that leaving a field out keeps the model's default, and that a provider that refuses a temperature fails the round with its own error. + - Same file, "Inspecting a binding" (line 39): `temperature` and `max_tokens` show the section's options on the handle `models.use` returns, and read nil otherwise. + - Same file, the migration note (line 104): the old `models.bind` options `temperature` and `max_tokens` now go in the `models.use` options table. + - `guide/src/agent/03-chat-rounds.md` (line 76): replace "given at bind time" and "read nil when the bind declared none". + - After the `guide/src` edits, regenerate the combined guides (`guide/promptforge-*-guide.md`) with `cargo run -p build-user-guide`. +- Pull-request text: the session running the plan drafts it, not the coding sub-agent, after this step's commit message is finalized. It is written as an **output** file outside the repository and summarized in the step's report; it is never written into the repository or committed. Do not open a pull request or post it. It answers the issue's four asks: + - First ask: temperature and `max_tokens` are set per section with `models.use(label, { temperature = 0 })`, not per role in the frontmatter, which stays the prompt's contract. papergate's writer pin becomes one line in each section that runs the writer. If every section runs the writer, the line can go once in the shared library, which replays into every section. + - Second ask: per-section temperature is exactly this mechanism. + - Third ask: the engine test pins both values all the way to the request body. + - Fourth ask: the value passes through to the provider, and a refusal becomes that round's error. This is documented. The new optional argument is additive under `promptforge: 0`, so no version bump is needed. +- Verification, all clean before committing: + - `cargo nextest run --locked -p promptforge-lua -p promptforge-engine -p promptforge-model-client --all-features` + - `cargo test --locked -p promptforge-lua -p promptforge-engine -p promptforge-model-client --all-features --doc` + - `cargo clippy -p promptforge-lua -p promptforge-engine -p promptforge-model-client --all-targets --all-features -- -D warnings` + - `cargo +nightly-2026-09-05 xtask api --check`, and `cargo doc -p promptforge --no-deps` with `$env:RUSTDOCFLAGS="-D warnings"` + - `cargo test -p build-xtask`, `cargo fmt --all --check`, and `mdbook build guide` +- Commit: one commit directly on `vibe2`, with no push or pull request. It contains the builder, `public-api.txt`, the Lua changes, the tests, the `guide/src` edits, and the regenerated combined guides. The message is the subject `Add temperature and max_tokens options to models.use`, a blank line, `close #69`, a blank line, then the body the commit rule prescribes: one paragraph, plus optional bullets. + + + + + +### Step 2: Gate tray icon tints to Windows and Linux (#59) [completed] + +- Component: `tray-tint-gating` + +- Placement: second of three components. It depends on neither of the others and shares no file with them, so its position is arbitrary. +- Pieces: a single piece, the compile gate with its doc note, so no sequential or joint choice arises. +- Work, in `crates/gateway/app/src/tray/logic.rs`: + - Add `#[cfg(any(target_os = "windows", target_os = "linux", test))]` to `grayed` (line 428), `error_tint` (line 438), and `tint` (line 443). This mirrors the existing gate on `run_key_command` (lines 180-186). macOS never calls these helpers, because the system tints its template glyph (`crates/gateway/app/src/tray/macos.rs:195-197`). + - Add a one-line doc note naming the callers: `crates/gateway/app/src/tray/windows.rs:127-129` and `crates/gateway/app/src/tray/linux.rs:324-326`. +- Tests: none added. The existing `grayed` and `error_tint` tests (near lines 679 and 691 of `logic.rs`) keep running on every platform, because the gate includes `test`. +- Verification, all clean before committing: + - `cargo nextest run --locked -p gateway --all-features tray` + - `cargo clippy -p gateway --all-targets --all-features -- -D warnings` + - `cargo check -p gateway --no-default-features` + - `cargo fmt --all --check` + - The macOS check, `cargo clippy -p gateway --target aarch64-apple-darwin -- -D warnings`, is run by the user on a Mac. It can't build on the Windows development machine because of the C code in `aws-lc-sys` and `ring`. +- Commit: one commit directly on `vibe2`, with no push or pull request, containing only the `logic.rs` change. The message is the subject `Gate tray icon tints to Windows and Linux`, a blank line, `close #59`, a blank line, then the body the commit rule prescribes: one paragraph, plus optional bullets. + + + + + +### Step 3: Bind no-thinking to switchable models and bound each stream receive (#70) [completed] + +- Component: `thinking-and-stream-deadlines` + +- Placement: third and last of three components. Its edit to `guide/src/language/06-models.md` (line 19) and its guide regeneration follow Step 1's edits to the same file. As the final step, it also runs the full exit criteria on the finished tree. +- Pieces, built sequentially inside this one step: (1) the `no-thinking` fill check, then (2) the transport's per-receive deadline. They don't depend on each other. They share one step because the plan requires one commit for #70, and the combined guides are regenerated once, after both pieces' guide edits. +- Fill check: + - In `crates/promptforge-internal/engine/src/execute/fill.rs` (line 91, inside `fill_model_bindings` at lines 84-94), change the `NoThinking` condition to `model.thinking() == ThinkingMode::Always`, so `no-thinking` fails only on a model that always thinks. + - No request change is needed: the binding already asks for thinking off (`crates/promptforge-internal/engine/src/execute/context-bound.rs:59-66`) through `chat_template_kwargs.enable_thinking`. +- Transport, in `crates/harness/models/src/transport.rs`, inside `GatewayClient::complete` (lines 300-356): + - Remove `.timeout(self.request_timeout)` (line 318). Wrap `request.send()` in `tokio::time::timeout(self.request_timeout, ...)`. + - Give `ResponseChunks` the timeout duration as a field, and wrap each read in `ResponseChunks::next_chunk` (lines 74-86) with it, so every receive of one or more bytes restarts the budget. The error-body path reads through the same chunks, so it is bounded too. + - Map an elapsed tokio timeout to the `ClientTimeout` marker with a helper beside `transport_source` (lines 57-72). A timeout stays a `Transport` error for which `is_timeout()` and `is_retryable()` hold. + - Update the comment above the removed call, and the doc comments on the `request_timeout` field (line 37), `DEFAULT_REQUEST_TIMEOUT` (lines 43-44), and `with_request_limits` (lines 207-235). The value stays 120 seconds, no new setting is added, and the gateway is unchanged. + - In `crates/promptforge-internal/engine/src/execute/config-limits.rs` (lines 55-117), update the docs of `RunLimits::request_timeout` and `RunLimits::new`: the signature is unchanged, and the value now means the longest wait for the next receive. + - `transport.rs` is about 385 lines and must stay at or under the harness 500-line limit. +- Tests: + - `crates/promptforge-internal/engine/src/execute/tests/suite/prepare.rs`, beside the hard-keyword tests at lines 59-148: a `no-thinking` role on a `Switchable` model prepares with no unmet requirements, and its request body has `enable_thinking: false`, run through prepare against a `ScriptedGateway`. If the prepare suite can't reach the gateway helpers, put this test in `model_and_reply.rs` instead. A `no-thinking` role on an `Always` model is still refused, with a notice naming `no-thinking` versus `Always`. + - `crates/harness/models/src/transport/tests/limits.rs`: keep `a_request_past_the_timeout_is_a_timeout_transport_failure` (lines 103-129, headers never arrive) and update its comment. Add three tests: + - Steady stream: a 100ms budget, with chunks 50ms apart for about 250ms, then `[DONE]`. It succeeds. + - Trickled event: a single SSE event written in small pieces 30ms apart under a 100ms budget, so the one event takes about 300ms to arrive. It succeeds, which proves the deadline resets on every receive. + - Stall after the headers: one chunk, then silence. It fails as `Transport` with `is_timeout()`. +- Docs: + - `guide/src/language/06-models.md` (line 19): `no-thinking` is satisfied by a model that never thinks or one whose thinking is switchable, and in the switchable case every round under the role asks for thinking off. The switch is forwarded as `chat_template_kwargs.enable_thinking`, so an upstream that ignores that field keeps its own default. + - `guide/src/language/09-limits-and-errors.md` (line 24): replace "a 120 second request timeout" with a description of a limit of 120 seconds without progress. + - After both edits, regenerate the combined guides with `cargo run -p build-user-guide`. +- Verification: first the focused runs, `cargo nextest run --locked -p promptforge-engine -p harness-models --all-features` and `cargo clippy -p promptforge-engine -p harness-models --all-targets --all-features -- -D warnings`. Then the full exit criteria on the finished tree, all clean: + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc` + - `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings` + - `cargo fmt --all --check` + - With `$env:RUSTDOCFLAGS="-D warnings"`: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, and `cargo doc -p promptforge --no-deps` without `--all-features` + - `cargo +nightly-2026-09-05 xtask api --check` (the pinned nightly is named in `crates/build-xtask/src/api/toolchain.rs`) + - `cargo test -p build-xtask` + - `mdbook build guide` + - `cargo check -p gateway --no-default-features` + - Optional: `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. The workshop crates don't use the changed types. + - Excluded: the Mac clippy run from Step 2, which the user performs. +- Commit: one commit directly on `vibe2`, with no push or pull request. It contains the fill change, the transport and `RunLimits` changes, the tests, the `guide/src` edits, and the regenerated combined guides. The message is the subject `Bind no-thinking to switchable models; idle model timeout`, a blank line, `close #70`, a blank line, then the body the commit rule prescribes: one paragraph, plus optional bullets. + + + + + 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 000000000..60ea3338b --- /dev/null +++ b/vibe/2026-09-24-2-workshop-crates-cleanup.md @@ -0,0 +1,1241 @@ +--- +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. +- Exit results (Step 34; all pass, each at least as green as the baseline above): + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`: pass (3915 passed, 54 skipped; baseline 3899) + - `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 (247 passed, 4 skipped; baseline 234) + - `cargo nextest run --locked -p workshop-server --features headless`: pass (143 passed, 2 skipped; baseline 130) + - `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` (main partition and workshop partition, `-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) + - `mdbook build guide`: pass + - `cargo run -p build-user-guide`: pass (no tracked file under `guide/` changed; no workshop export) + - workshop UI `npm run build`, `npm test` (139 passed), `npm run typecheck`: pass + - gateway config UI `npm run build`, `npm test` (178 passed), `npm run typecheck`: pass + - `cargo workshop`: pass (desktop app built from `crates/workshop/desktop`) + - Retired-name grep (workshop/shell, Tier: shell, StatusBarShell, createStatusBarShell, ws-shell, mountLiveShell, showShell, WorkshopObserver, "workbench socket", "lazy shell", "empty shell", "boot shell", and `-w SHELL` in `crates/build-xtask/src`): clean + - Remaining "shell" in scope: terminal command shell and third-party NSIS keywords only (two frame-sense shared-ui references fixed in this step) +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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 [completed] + +- 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/2026-09-25-1-vibe2-debt-removal.md b/vibe/2026-09-25-1-vibe2-debt-removal.md new file mode 100644 index 000000000..afc0d3ed3 --- /dev/null +++ b/vibe/2026-09-25-1-vibe2-debt-removal.md @@ -0,0 +1,423 @@ +--- +name: Debt collector vibe2 +overview: "Remove the four debts that the 39 vibe2 commits since upstream/master (1fd82c62..a6b9a747) added and that remain at HEAD: a heartbeat regression test that can no longer fail, an editor Overwrite path that skips the timed-out-save state machine, stale module names in the workshop-server crate docs with no mechanical check, and a Linux-dead import that fails the Linux clippy CI job." +todos: + - id: debt-04-linux-clippy + content: "DEBT-04: gate use super::*; in workspace/tests/jail.rs to Windows; verify workshop-workspace clippy and jail tests" + status: pending + - id: debt-01-heartbeat + content: "DEBT-01: restore real-time quiet window in startup_convergence.rs; prove with 5 runs and heartbeat mutation" + status: pending + - id: debt-02-overwrite + content: "DEBT-02: add writeCurrent helper in editor-panel.ts for save() and overwrite(); add cases (a) and (b) to editor-save-timeout.mjs; mutation proof" + status: pending + - id: debt-03-docs + content: "DEBT-03: intra-doc-link module inventories in workshop-server agents.rs and lib.rs; add private-items rustdoc step to check-workshop in ci.yml; link mutation proof" + status: pending + - id: exit-checks + content: Run workshop clippy, nextest, UI npm test, and the new docs step + status: pending +isProject: false +--- + +# Debt Removal Plan - promptforge vibe2 since upstream/master + + + +## Product Requirements + +**Scope and target work** +- Repository: `C:\Users\Vinnie\cursor\promptforge2`, branch `vibe2`. +- Baseline: `1fd82c62`. This is `upstream/master`, and it is also the merge base with `HEAD`. The local ref was not fetched. +- Endpoint: `a6b9a747` (`HEAD`). +- Target: 39 commits under two plans: + - `vibe/2026-09-24-2-workshop-crates-cleanup.md`: `b483266c` through `ce10a8eb`. + - `vibe/2026-09-24-2-issues-69-59-70.md`: `a7458096` through `a6b9a747`. +- Disposition: the worktree. It has no tracked edits; the only untracked files are stale build artifacts under `crates/workshop/shell/`. + +**Cleanup goals** +- DEBT-01: the heartbeat "refresh stops after restore" test can fail again. +- DEBT-02: no editor write path sends a token the editor knows is stale after a timed-out write. +- DEBT-03: the workshop-server module inventories name modules that exist, and a compiler check keeps them honest. +- DEBT-04: the Linux `clippy` CI job no longer fails on the Windows-only glob import in `jail.rs`. + +**Non-goals** +- The pre-existing abandoned-write race (see Debt Inventory). +- Prose drift in READMEs, AGENTS.md and the guide, which rustdoc cannot check. +- The residual candidates. +- Edits to `vibe/archdoc.md` or the plan files. + +**Success criteria** +- Each debt's check in the Testing Plan passes. +- The mutation proofs fail as described and then pass once reverted. +- The workshop CI partition passes, including the new docs step. +- The Linux `clippy` CI job passes the next time this code runs in CI. + +## Functional Specification + +### Debt Inventory + +**DEBT-01 - Heartbeat quiet-window assertion cannot detect a regression (introduced, `6b23dd75`)** +- Evidence: [crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs](crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs), lines 147-157. The pre-target `tokio::time::sleep(TEST_INTERVAL * 4)` became `pause(); advance(TEST_INTERVAL * 4).await; resume();` followed by a synchronous `assert_eq!` on `state.requests`. +- Why the check is blind: + - In tokio 1.53.1, `advance` yields exactly once. + - A regressed retry needs two real loopback HTTP round trips (`/health`, then `/v1/models`) on the same current-thread runtime before the counter moves. + - So the assertion passes whether or not retries continue. +- Impact: + - This is the only guard for heartbeat retry termination. `crates/workshop/gateway/src/heartbeat-tests.rs` defers to it. + - Plan step 23 later refactored `heartbeat.rs` (228 lines) and was certified against this blind check. + - It contradicts the cleanup plan's Step 4 contract that the tests "assert what they asserted before". +- Reversal cost: low, test-only. +- Target state: the assertion observes real time again. + +**DEBT-02 - `overwrite()` bypasses the unknown-token state (introduced, `6d44740d`)** +- Evidence: [crates/workshop/ui/src/parts/editor/editor-panel.ts](crates/workshop/ui/src/parts/editor/editor-panel.ts). + - `save()` (lines 182-230) sets `lastSentText` and moves to `tokenUnknown = true` on a 408. + - `overwrite()` (lines 437-461) does neither. `6d44740d` edited its success branch only. +- Reachable path: + 1. A 409 opens the conflict dialog while the editor still holds the stale token T0. + 2. The user clicks Overwrite. It reads T1, writes, and gets a 408. The editor still holds T0 and `tokenUnknown` stays false. + 3. The next `save()` sends T0. +- A second variant: a stale `lastSentText` skews the next reconcile. +- This contradicts the plan's acceptance criterion ("sends no stale token") and the commit body ("no save path forwards a token the editor does not currently know"). +- Recurrence: `f5cf157c` (2026-08-27, a different plan) already repaired `overwrite()` for lagging behind `save()`'s bookkeeping. +- Severity: low, because the server's token check fails closed and answers a 409 with a misleading dialog. +- Reversal cost: low, one private UI file. +- Target state: `save()` and `overwrite()` share one write path. + +**DEBT-03 - Stale module names in workshop-server crate docs (worsened, `4c0f465b`, `5a67ec1b`, `2c0cb1eb`)** +- Evidence: + - [crates/workshop/server/src/agents.rs](crates/workshop/server/src/agents.rs) lines 1-2 say "the `/ws` workshop socket (`session`)". No `session` module exists; `/ws` now lives in `crate::workshop_socket`. + - [crates/workshop/server/src/lib.rs](crates/workshop/server/src/lib.rs) lines 13-14 still place `/ws` inside `agents`. + - `lib.rs` line 18 says "assembled in `app.rs`", but the helpers now live in `app/compose.rs`. +- How it survived: the move, a second edit to the same line, and a dedicated doc sweep ("Correct the workshop's code-level docs") all missed it. +- Same cause in earlier plans: `2cee387a` (api-firewall), `f61d2570` (api-runtime-debt) and `b9ed2843` (workspace-debt-removal) each needed a manual prose sweep after a restructure. +- Enabling condition: the inventories use plain backticks, and CI never builds rustdoc for the workshop crates. The `docs` job in [.github/workflows/ci.yml](.github/workflows/ci.yml) line 141 excludes them, and `check-workshop` has no docs step. +- Reversal cost: low. +- Target state: the inventories use intra-doc links, and a rustdoc build with private items and denied warnings runs in CI. + +**DEBT-04 - Linux-dead glob import fails the clippy job (introduced, `8a17ee0b`)** +- Evidence: CI run [36110606135](https://github.com/cppalliance/promptforge/actions/runs/36110606135) at `ce10a8eb` failed only in `clippy`, which denies warnings. The error is `unused import: super::*` at [crates/workshop/workspace/src/workspace/tests/jail.rs](crates/workshop/workspace/src/workspace/tests/jail.rs) line 11. `ci-green` failed only because it aggregates the other jobs. The analysis pass missed this; CI found it. +- Cause: every item that uses the glob (`Path`, `PathBuf`, `fs`, `Workspace`, `WorkspaceError`, `granted_dir`, `simplified`) is `#[cfg(windows)]`. The ungated `symlink_unavailable` and its two tests use nothing from the parent. The local gates run only on Windows, so they could not see it. +- Scope: this is the only Linux-only error. + - The Linux `test` job compiled every crate in clippy's scope with all features and test targets, and emitted exactly this one rustc warning. + - Clippy stopped before 9 crates. In those crates the PR touched platform-gated files only through string and constant edits. +- Present at disposition: `jail.rs` has no post-target diff. +- Reversal cost: trivial. Target state: the import compiles only on Windows. + +**Exposed pre-existing debt (reported only, not debt added)** +- DEBT-X1: an abandoned write can land after a later successful write and silently revert a file the UI shows as saved. +- Mechanism: + - `with_deadline` in `crates/workshop/support/src/deadline.rs` abandons the request rather than cancelling it. + - `Workspace::write_file` checks the token before an unconditional rename, with no per-path lock. +- Origin: `f508f0f8` (2026-08-27). The target narrowed the path, so it now requires the user to click Overwrite. +- What the target added is statements only: + - The plan's claim that "the worst case is the conflict dialog" (plan lines 164, 488, 504). + - A mislabeled fourth case in `crates/workshop/ui/test/editor-save-timeout.mjs`. +- A real fix changes the workspace crate's write semantics. That is a separate data-integrity item. + +**Rejected candidates: 67 across three partitions, each challenged by a fresh reviewer** +- 22 residual-but-acceptable: real, but with no reachable consequence or already protected. Examples: + - Service tokens split from their default registration: production's single `main.ts` entry registers both. + - The engine's mock transport still applies a whole-request timeout: it is test-only, no present run reaches it, and harness behavior tests protect the contract. + - The chat-gate zero-deadline quiet check: later assertions in the same test still catch the regression. + - The relay's copy of the error renderer. + - The gateway icon copies, which sit under an existing sync rule. +- 19 weak/speculative: structural leads with no demonstrated consequence, such as parameter clusters, visibility widening and module size. +- 19 false: the refactors (compose split, supervisor split, socket framing split, run-loop phases, `/prompts/contract` move, renames) change no behavior, wire string or persisted key. The `saveAs()` half of DEBT-02 is also false: a 408 there leaves `this.path`'s token valid. +- 7 unrelated pre-existing, for example the harness writing a literal `flags: 0`. + + + + +## Technical Design + +**DEBT-01 (test only)** +- In `startup_convergence.rs`, replace the three lines `pause`/`advance`/`resume` with `tokio::time::sleep(TEST_INTERVAL * 4).await;`. That is 100 ms of real time; `TEST_INTERVAL` is 25 ms in `heartbeat_loop.rs`. +- Replace the comment above it. The new comment should say why the window must be real time: the probes are real loopback HTTP on the test runtime, and a paused-clock advance cannot drive them. + +**DEBT-02 (private UI change in `editor-panel.ts`)** +- Add one private method that performs the PUT for this panel's own file and owns all write-outcome bookkeeping: + +```ts +private async writeCurrent(path: string, text: string, expectedToken: string | null): Promise { + this.lastSentText = text; + try { + const written = await this.writer()(path, text, expectedToken); + this.token = written.token; + this.tokenUnknown = false; + this.surface.markSaved(text); + } catch (error: unknown) { + 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 { + throw error; + } + } +} +``` + +- `save()` keeps its guard, text capture and reconcile read, and replaces lines 214-217 with a call to `writeCurrent`. +- `overwrite()` keeps its guard and fresh read, then calls `writeCurrent(this.path, text, fresh.token)`. +- Both keep their existing outer `catch` that calls `showError` for read failures. +- `saveAs()` stays outside the helper. It writes a different path, so a 408 there must not mark `this.path`'s token unknown. +- Update the doc comments on `save()` and `overwrite()` to say that both route through `writeCurrent`. +- No wire, persisted or public change is involved. + +**DEBT-03 (docs and CI)** +- `agents.rs`: + - Drop `/ws` from the inventory. + - Link the children as intra-doc links ([`socket`], [`relay`], [`state`], [`bindings`]). + - Note that `/ws` is [`crate::workshop_socket`]. +- `lib.rs`: + - Lines 12-20: say that the sessions subsystem in [`agents`] serves `/agents/ws` and `/v1/models`, and that [`workshop_socket`] serves `/ws`. + - Change "assembled in `app.rs`" to [`app`] with the helpers in [`app::compose`]. +- `ci.yml`: add a step to the `check-workshop` job after "Doctests (workshop)", at line 200: + +```yaml + - name: Docs (workshop-server, private items) + env: + RUSTDOCFLAGS: -D warnings + run: cargo doc --locked --no-deps -p workshop-server --document-private-items +``` + +- Run this command locally first. Fix every warning it surfaces in workshop-server; all of them are doc-text fixes of the same cause. +- If a fix needs anything other than doc text, or the warnings exceed roughly 30 sites, stop and report before continuing. + +**DEBT-04 (test module only)** +- Gate the import at `jail.rs` line 11, matching the precedent in `crates/gateway/local/src/server-tests.rs:10-11`: + +```rust +#[cfg(windows)] +use super::*; +``` + + + + +## Testing Plan + +**DEBT-01** +- Focused: `cargo nextest run --locked -p workshop-server heartbeat_loop` passes 5 runs in a row. +- Mutation proof: + 1. Temporarily make the heartbeat keep calling `refresh_sources` after the selection is restored, for example by forcing the "source incomplete" condition true in `crates/workshop/gateway/src/heartbeat.rs`. + 2. Confirm "selection restoration ends refresh retries" fails. + 3. Revert. + +**DEBT-02** +- Add two cases to [crates/workshop/ui/test/editor-save-timeout.mjs](crates/workshop/ui/test/editor-save-timeout.mjs), following its existing injected `readFile`/`writeFile` sections: + - Case (a): save answers 409 on T0, then Overwrite (reads T1) answers 408, then save. Assert that no write carries T0, that the save performs a reconcile read, and that the 408 message appears. + - Case (b): an unknown-token mismatch opens the dialog, then Overwrite answers 408, then the disk holds the Overwrite text, then save. Assert that the save adopts the disk token and writes without reopening the dialog. +- Mutation proof: temporarily restore the direct `this.writer()` call in `overwrite()`. Case (a) fails. Revert. +- Regression: the existing four timeout cases, plus `editor-panel.mjs` and `editor-save-race.mjs` (the Overwrite success and in-flight typing paths), still pass. + +**DEBT-03** +- `rg "\(\`session\`\)" crates/workshop/server/src` returns nothing. +- The new `cargo doc` command passes locally. +- Mutation proof: temporarily rename `workshop_socket` in the `agents.rs` link to `session`. The docs step fails with `broken_intra_doc_links`. Revert. + +**DEBT-04** +- Windows: `cargo clippy --locked -p workshop-workspace --all-targets --all-features -- -D warnings` and `cargo nextest run --locked -p workshop-workspace jail` pass. The gated tests still compile and use the glob. +- Linux: the `clippy` CI job passes on the next CI run of this code. This is checked in CI only, because WSL has no Rust toolchain, and a cross-target check from Windows fails on C build dependencies (`aws-lc-sys`, `mlua-sys`, `simsimd`). + +**Exit checks** +- `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings` +- `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` +- `npm test --prefix crates/workshop/ui` +- The new docs step. + + + + +## Decision Record + +**Reversible decisions** +- DEBT-01: + - Chosen: restore the 100 ms real-time window, costing 100 ms of test time. + - Rejected: a tick counter under `test-fixtures` in workshop-gateway, which is new test surface for one assertion. Also rejected: paused-clock auto-advance, which interacts badly with real TCP. +- DEBT-02: + - Chosen: one shared write helper. + - Rejected: copying the 408 branch into `overwrite()`. That would be the third hand-copy of the same bookkeeping, after `f5cf157c`. + - `saveAs()` is excluded from the helper on purpose (see Technical Design). + - `lastSentText` needs no reset. It is read only while `tokenUnknown` is true, and only the helper sets that flag, immediately after assigning `lastSentText`. +- DEBT-03: + - Chosen: intra-doc links plus a rustdoc build that denies warnings. This is a compiler check, not a structural ratchet. + - Rejected: a tidy script that parses `//!` inventories (a bespoke source parser), and a manual "grep the old name" plan rule (the discipline that already failed). + - The step covers workshop-server only, where the instance lives (see Deferred and Out of Scope). +- DEBT-04: + - Chosen: a `#[cfg(windows)]` gate on the import. + - Rejected: `#[allow(unused_imports)]`, which hides the signal, and a nested `#[cfg(windows)] mod`, which is churn for one line. + +**User-resolved architecture choices** +- None required. No retained remedy touches a public interface, a persisted or wire format, component ownership, dependency direction, or a trust boundary. + +**Assumptions and risks** +- The DEBT-01 and DEBT-02 consequences were inferred from endpoint code and tokio 1.53.1 source, not observed by running. The mutation proofs settle them. +- `upstream/master` was not fetched, so newer upstream commits are not considered. +- The DEBT-03 docs step may surface existing rustdoc warnings. The stop threshold above bounds that. + +### Deferred and Out of Scope + +- DEBT-X1 and its statements: the server write race, the cleanup plan's lines 164, 488 and 504, and the mislabeled fourth case in `editor-save-timeout.mjs`. The conflict-dialog wording ("modified outside the editor") also belongs with X1. Revisit as its own data-integrity item; a real fix changes the workspace crate's write semantics. +- Extending the private-items docs step to `workshop` and `workshop-server-api`. Revisit once their rustdoc warning volume is known. +- The chat-gate quiet-check comment in `crates/workshop/server/tests/it/chat_gate.rs`, and all other residual candidates. Revisit a residual when its consequence becomes reachable, for example a second production UI entry bundle (the service-token split) or an engine test that streams past its `request_timeout` (the mock transport). +- The untracked `crates/workshop/shell/` artifacts. They are clone-local; delete them by hand if wanted. +- Any edit to `vibe/archdoc.md` or the `vibe/` plan files. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p `. Plain `cargo build` builds only the default member `crates/gateway/app` (binary `promptforge-gateway`). Desktop app: `cargo build --locked -p workshop`; desktop release orchestration: `cargo workshop` (alias for `run -p build-workshop --`). The UI bundles are built by crate build scripts through `build-ui`, which needs `npm ci --prefix crates/workshop/ui` and `npm ci --prefix crates/gateway/config-ui/ui` first. Windows links with `rust-lld` and the static CRT per `.cargo/config.toml`. +- Focused test command pattern: `cargo nextest run --locked -p --all-features `; for `workshop`, `workshop-server`, and `workshop-server-api` drop `--all-features`. Integration tests in one binary: add `--test it` (or `--test suite` for `promptforge`). UI tests: `node --test .mjs` from the UI directory. +- Component test command pattern: `cargo nextest run --locked -p --all-features`, then `cargo test -p --all-features --doc` (workshop crates without `--all-features`; `workshop-server` also has `cargo nextest run --locked -p workshop-server --features headless`). UI components: `npm test` in `crates/workshop/ui` or `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` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. The workspace run includes `build-xtask`, the structural harness (`cargo test -p build-xtask`). UI suites: `npm test` in both UI directories. +- 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`. Never run a standalone `cargo check --workspace`. UI type checks: `npm run typecheck` in both UI directories. Supply chain: `cargo deny check` (pre-push runs it when installed). +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`; the pre-commit hook runs it). +- Docs command: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` and `cargo doc -p promptforge --no-deps` (default features), both with `RUSTDOCFLAGS` set to `-D warnings` (PowerShell: `$env:RUSTDOCFLAGS='-D warnings'`); user guide: `mdbook build guide`. Facade surface: `cargo + xtask api --check`, nightly named in `crates/build-xtask/src/api/toolchain.rs`, checked against `crates/promptforge/public-api.txt`. +- Test placement and naming conventions: + - Unit tests either inline as `#[cfg(test)] mod tests { ... }` or in a sibling file wired with `#[cfg(test)] #[path = "-tests.rs"] mod tests;` (for example `tidy.rs` with `tidy-tests.rs`); a `tests/` subdirectory under `src/` appears once a module's tests reach three files. + - Integration tests are one binary per crate at `tests/it/main.rs` (`tests/suite/main.rs` in `promptforge`) that declares topic modules plus a `support.rs` helper module; prompt fixtures sit under `tests/prompts/`. Benches exist only in `promptforge-engine` and `promptforge-lua` (criterion). + - Test function names are behavior sentences in snake_case (`a_pending_timer_is_torn_down_by_a_cancel`, `workshop_tier_dependencies_flow_one_way`). Async tests use `#[tokio::test]`. Test roots open with `#![expect(clippy::expect_used, clippy::unwrap_used, reason = "...")]`; `clippy.toml` allows unwrap and expect in tests. + - UI tests are Node `node:test` `.mjs` files: `crates/workshop/ui/test/*.mjs` (with `test/helpers/`), and beside source as `src/**/*.test.mjs` in `crates/gateway/config-ui/ui`; `tools/*.test.mjs` sit beside their scripts. + - Behavior changes ship with tests in the same change; nextest caps the whisper-backed STT packages in a `heavy` test group. +- Directory map: + - `crates/` - the public and shared layer: root crates `promptforge` (facade), `gateway-api-types`, `gateway-api-discovery`, `harness-api`, `shared-error-source`, `shared-loopback`, `workspace-hack`, and `build-*` tooling (`build-xtask`, `build-ui`, `build-workshop`, `build-user-guide`, `build-llama-cuda`), plus `shared-ui` (TypeScript and CSS package, not a Rust crate). + - `crates/promptforge-internal/` - private engine family: `engine`, `types`, `vfs`, `lua`, `parser`, `store`, `model-client`. + - `crates/gateway/` - private gateway family: `app`, `cloud-providers`, `config`, `config-ui` (with its `ui/` SPA), `local`, `logging`, `progress`, `protocol`, `routing`, `web-search`, and the nested `stt/` subsystem (`api`, `engine`, `backend-whisper`, `whisper-ffi`). + - `crates/harness/` - private harness family: `runner`, `models`, `capabilities`, `log`, `sessions`, `web`, `webfetch`, `web-search`. + - `crates/workshop/` - private Workshop family: `desktop` (Tauri app, package `workshop`), `server`, `server-api`, `gateway`, `menu`, `protocol`, `registry`, `status`, `support`, `user-state`, `workspace`, and `ui/` (the SPA). + - `guide/` - mdBook user guide and contributor docs. `prompts/` - sample Markdown prompts. `tools/` - Node release scripts with tests. `vibe/` - plans, archdoc, and design notes. `images/` - banners. `.github/` - CI workflows and fixtures. `.config/` - nextest and hakari. `.githooks/` - pre-commit and pre-push. + - Root config: `Cargo.toml` (explicit member list, lints, workspace deps), `rust-toolchain.toml` (stable), `rustfmt.toml`, `clippy.toml`, `deny.toml`, `dist-workspace.toml` (cargo-dist), `AGENTS.md` (repository rules). +- Component boundaries: + - `promptforge` is the only crate outside its family that may reach `promptforge-internal/*`; promptforge crates never depend on gateway, workshop, or harness crates. The engine is a sans-I/O state machine exchanging effects and events. + - Gateway exposes only `gateway-api-types` and `gateway-api-discovery`; nothing outside may depend into `crates/gateway/`, and gateway crates never depend on promptforge, workshop, or harness. `gateway-stt` is the only family-visible STT crate. It runs as a separate process reached over HTTP and WebSocket plus the discovery file. + - Harness exposes only `harness-api`; harness crates may depend on `promptforge`, the gateway public pair, and `shared-*`, never on workshop or private gateway crates. It is the engine's only production host. + - Workshop crates may depend on `harness-api`, `promptforge`, the gateway public pair, and `shared-*` only. The desktop app depends on `workshop-server-api`, never `workshop-server`. Inside the family, tiers flow one way: server, then features, then services, then vocabulary. + - `shared-*` depend on no product crates. `build-*` crates are meta tooling exempt from container privacy; only `build-ui` is depended on, as a build dependency. Every member depends on `workspace-hack`. + - Family container crates may depend only on `crates/` root crates and their own siblings. Rules bind normal, dev, build, and target-specific dependencies, and `cargo test -p build-xtask` enforces them. The archdoc names a CLI component, but no dedicated CLI crate exists in the tree. +- Conventions summary: + - Rust 2024 edition on stable; workspace lints forbid `unsafe_code` (explicit boundaries only, each unsafe block preceded by its safety invariants), warn on `missing_docs` and `unreachable_pub`, and deny clippy `all`, `pedantic`, `unwrap_used`, and `expect_used`. + - Every `workshop-*` and `harness-*` lib.rs opens with a `//!` doc holding a `## Invariants` marker; files in marker crates stay at or under 500 lines. + - Source directories are flat: one or two child modules live as `- + + +## Execution Instructions + + + +### Step 1: Gate the jail tests' glob import to Windows [completed] + +- Component: workshop-workspace +- Component placement: first of three. `workshop-server` depends on this services-tier crate, and this is the only item that fixes a CI job already failing (Linux `clippy`, run 36110606135), so landing it first gives every later push a meaningful Linux clippy result. +- Piece: jail test module imports. It is the component's only piece, so it is built alone. +- Covers: DEBT-04. +- Artifacts: + - `crates/workshop/workspace/src/workspace/tests/jail.rs` line 11, `use super::*;`. + - Precedent: `crates/gateway/local/src/server-tests.rs` lines 10-11. +- Changes: + - Add `#[cfg(windows)]` on the line directly above `use super::*;`. + - Leave `symlink_unavailable` and its two ungated tests as they are; they use nothing from the parent module. +- Verification (Windows): + - `cargo clippy --locked -p workshop-workspace --all-targets --all-features -- -D warnings` passes. + - `cargo nextest run --locked -p workshop-workspace jail` passes, and the Windows-gated tests still compile against the glob. + - Linux proof: the `clippy` job passes on the next CI run of `vibe2`. Record it as pending, not as a blocker. Do not attempt a local Linux check: WSL has no Rust toolchain, and a cross-target check from Windows fails on `aws-lc-sys`, `mlua-sys`, and `simsimd`. +- Staged files: `crates/workshop/workspace/src/workspace/tests/jail.rs` only. + + + + + +### Step 2: Route editor save and overwrite through one write path [completed] + +- Component: workshop-ui +- Component placement: second of three. `crates/workshop/server/build.rs` bundles `crates/workshop/ui/src/main.ts` into the server's embedded assets, so the UI is an input to `workshop-server`. Landing it before the server steps means the closing exit checks, which rebuild that bundle, exercise the finished UI. +- Piece: editor write path. It is the component's only piece. The helper and its tests are built jointly in this one step because the new test cases exist to prove the helper. +- Covers: DEBT-02. +- Artifacts: + - `crates/workshop/ui/src/parts/editor/editor-panel.ts`: a new private method `writeCurrent(path, text, expectedToken)`; `save()` (lines 182-230, direct write and its bookkeeping at lines 214-217); `overwrite()` (lines 437-461, direct write and its bookkeeping at lines 448-451); `saveAs()` (line 240), which stays unchanged. + - `crates/workshop/ui/test/editor-save-timeout.mjs`: new cases (a) and (b), and the header comment's case list. +- Changes: + - Add `writeCurrent` as sketched in the Technical Design. It alone sets `lastSentText`, updates `token`, clears or sets `tokenUnknown`, calls `surface.markSaved`, shows the 408 message, and opens the 409 conflict dialog; any other error is rethrown. + - `save()`: keep its guard, text capture, and reconcile read; replace lines 214-217 with `await this.writeCurrent(this.path, text, expectedToken)`. + - `overwrite()`: keep its guard and fresh read; replace lines 448-451 with `await this.writeCurrent(this.path, text, fresh.token)`. + - Keep the existing outer `catch` in both methods, which calls `showError` for read failures. + - Update the doc comments on `save()` and `overwrite()` to say both route through `writeCurrent`. + - Leave `saveAs()` outside the helper: it writes a different path, so a 408 there must not mark `this.path`'s token unknown. + - Add case (a) and case (b), using the file's existing scripted reader and writer: + - Case (a): save answers 409 on T0, then Overwrite (reads T1) answers 408, then save. Assert that no write carries T0, that the save performs a reconcile read, and that the 408 message appears. + - Case (b): an unknown-token mismatch opens the dialog (a timed-out save, then a save whose reconcile read does not match), then Overwrite answers 408, then the disk holds the Overwrite text, then save. Assert that the save adopts the disk token and writes without reopening the dialog. Add the two cases to the header comment's list; leave the existing fourth case's wording alone, because relabeling it belongs to deferred DEBT-X1. +- Verification, from `crates/workshop/ui`: + - `node test/editor-save-timeout.mjs` passes all six cases. + - `node test/editor-panel.mjs` and `node test/editor-save-race.mjs` pass (the Overwrite success and in-flight typing paths). + - `npm run typecheck` passes. + - Mutation proof: temporarily restore the direct `this.writer()` call in `overwrite()`, confirm case (a) fails, then revert and rerun green. +- Staged files: `crates/workshop/ui/src/parts/editor/editor-panel.ts` and `crates/workshop/ui/test/editor-save-timeout.mjs` only. + + + + + +### Step 3: Restore the heartbeat test's real-time quiet window [completed] + +- Component: workshop-server +- Component placement: third of three. It is the top tier: it depends on `workshop-workspace` and embeds the UI bundle. It goes last so the closing exit checks, which include the docs step this component adds, see every change. +- Piece: heartbeat regression test. Built sequentially before the docs piece in Step 4: the two share no files and neither needs the other, and the docs piece goes second because it runs the exit checks. +- Covers: DEBT-01. +- Artifacts: + - `crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs` lines 147-157: the paused-clock comment (lines 148-149) and `tokio::time::pause`/`advance`/`resume` (lines 150-152). + - `TEST_INTERVAL` (25 ms) in `crates/workshop/server/tests/it/heartbeat_loop.rs` line 67. + - Mutation target only, never committed: the `if !refresh.profiles_ready || !refresh.catalog_ready` condition before `refresh_sources` in `crates/workshop/gateway/src/heartbeat.rs` (line 269). +- Changes: + - Replace the three paused-clock lines with `tokio::time::sleep(TEST_INTERVAL * 4).await;`, a 100 ms real-time window. + - Replace the comment above it with one saying the window must be real time: the probes are real loopback HTTP on the test runtime, and a paused-clock advance cannot drive them. + - Keep `requests_after_restore` and the `assert_eq!` unchanged. +- Verification: + - `cargo nextest run --locked -p workshop-server heartbeat_loop` passes 5 runs in a row. + - Mutation proof: temporarily force the "source incomplete" condition true in `heartbeat.rs` so refresh keeps running after the selection is restored; confirm the assertion "selection restoration ends refresh retries" fails; revert and rerun green. + - `cargo clippy -p workshop-server --all-targets -- -D warnings` passes. +- Staged files: `crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs` only. `crates/workshop/gateway/src/heartbeat.rs` must be unmodified after the mutation proof. + + + + + +### Step 4: Link the workshop-server module inventories and check them in CI [completed] + +- Component: workshop-server +- Piece: crate docs and CI docs gate. Built sequentially after Step 3. It runs the plan's exit checks, because they include the docs step it adds. +- Covers: DEBT-03 and the Testing Plan's exit checks. +- Artifacts: + - `crates/workshop/server/src/agents.rs` lines 1-5, the module inventory. + - `crates/workshop/server/src/lib.rs` lines 12-20, the subsystem inventory and the "assembled in `app.rs`" sentence. + - Link targets that exist today: `crate::workshop_socket` (`workshop_socket.rs`), `app` (`app.rs`), `app::compose` (`app/compose.rs`), and the `agents` children `socket`, `relay`, `state`, and `bindings`. + - `.github/workflows/ci.yml`: the `check-workshop` job, directly after the "Doctests (workshop)" step (lines 199-200). +- Changes: + - `agents.rs`: drop `/ws` and `session` from the inventory, write the children as intra-doc links ([`socket`], [`relay`], [`state`], [`bindings`]), and note that `/ws` is served by [`crate::workshop_socket`]. + - `lib.rs`: say the sessions subsystem in [`agents`] serves `/agents/ws` and `/v1/models` and that [`workshop_socket`] serves `/ws`; replace "assembled in `app.rs`" with [`app`], with the helpers in [`app::compose`]. + - `ci.yml`: insert the "Docs (workshop-server, private items)" step exactly as written in the Technical Design. + - Run the docs command locally in PowerShell: `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps -p workshop-server --document-private-items`. The non-headless build bundles the UI, so run `npm ci --prefix crates/workshop/ui` first if its `node_modules` is missing. + - Fix every warning it reports as doc text in `workshop-server`. Stop and report before continuing if any fix needs more than doc text, or if the warnings exceed roughly 30 sites. +- Verification: + - ``rg '\(`session`\)' crates/workshop/server/src`` returns nothing. + - The docs command passes. + - Mutation proof: temporarily rename `workshop_socket` in the `agents.rs` link to `session`, confirm the docs command fails with `broken_intra_doc_links`, then revert and rerun green. + - Exit checks, with this step's changes in the tree and before committing: `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`; `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; `npm test --prefix crates/workshop/ui`; and the docs command. + - To cover the rest of the `check-workshop` partition named in the success criteria, also run `cargo nextest run --locked -p workshop-server --features headless` and `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. + - If an exit check fails because of an earlier step's item, stop and report it instead of fixing it in this commit. +- Staged files: `crates/workshop/server/src/agents.rs`, `crates/workshop/server/src/lib.rs`, `.github/workflows/ci.yml`, and any `workshop-server` doc-text fixes the docs build required. + + + +