Skip to content

Bring the workshop up on macOS: Metal voice, dylib provisioning, webview mic capture - #7

Closed
gregjkal wants to merge 124 commits into
cppalliance:masterfrom
gregjkal:workshop-macos-voice
Closed

Bring the workshop up on macOS: Metal voice, dylib provisioning, webview mic capture#7
gregjkal wants to merge 124 commits into
cppalliance:masterfrom
gregjkal:workshop-macos-voice

Conversation

@gregjkal

Copy link
Copy Markdown

Brings the workshop desktop app fully up on macOS: build, local model provisioning, and voice transcription.

What was broken on macOS

  1. No voice backend. The only GPU whisper backend wired up was CUDA, and voice is deliberately GPU-only (a CPU pass stalls a take, so the UI hides the mic).
  2. Local model provisioning failed. The gateway's archive extractor rejects any tar entry that is not a plain file or directory, and the macOS llama.cpp release tarballs ship every dylib behind versioned symlink chains (libggml.dylib -> libggml.0.dylib -> libggml.0.17.0.dylib), so llama-server could never install.
  3. The webview never exposed the capture API. WKWebView hides navigator.mediaDevices on plain-http origins (no loopback exemption, unlike WebView2) and from host apps without an NSMicrophoneUsageDescription, so the mic's feature check failed before any permission was ever asked.

Changes

  • Metal feature chain: promptforge-ws/metal -> promptforge-gateway/workshop-metal -> promptforge-ws-server/metal -> whisper-rs/metal, mirroring the CUDA chain. The runtime gate in transcribe.rs now accepts either backend, with Metal treated as always present on macOS. macOS builds use cargo build -p promptforge-ws --no-default-features --features metal.
  • Tar symlink materialization: symlink entries are validated (relative target, no traversal, confined to the destination) and materialized after extraction as regular-file copies of their final targets, following chains and rejecting dangling targets and cycles. The extracted tree stays symlink-free, so the ART-006 cache confinement invariant is untouched. New tests cover the chain, traversal, dangling, and cycle cases.
  • macOS media capture: a new media_capture.rs in the shell builds the WKWebViewConfiguration up front (WKWebView copies its configuration at init, so a post-build flip never reaches the page), clears WebKit's secure-connection capture gate through the same SPI behind Safari's "allow media capture on insecure sites" develop toggle (probed with respondsToSelector:, degrading to voice-unavailable if WebKit drops it), and embeds an Info.plist with NSMicrophoneUsageDescription in the executable's __TEXT,__info_plist section. The module opts out of the workspace unsafe ban the same way file_drop.rs does on Windows.
  • Docs: the three crate READMEs now cover the Metal build, the GPU-only voice rule, macOS config paths, and the macOS microphone permission story (including that TCC may attribute the prompt to the launching terminal); changelog updated.

Testing

  • cargo test -p promptforge-gateway --lib, cargo test -p promptforge-ws-server --features metal, and cargo test -p promptforge-ws --no-default-features --features metal all pass locally (556 tests), plus cargo fmt --check and per-crate clippy with the metal feature set.
  • Verified end to end on an M4 Pro (24 GB, macOS 27): llama-server b10082 provisions from the release tarball, Qwen3-4B-Instruct-2507 Q4_K_M downloads and answers through the gateway at ~31 tok/s, whisper large-v3-turbo and large-v3 load on Metal, and push-to-talk streams interim transcripts with a final polish pass.

CoreML as a possibly faster macOS voice backend is tracked separately in #6.

Rename the desktop app from Workbench to Workshop across the whole
workspace. The crates promptforge-wb and promptforge-wb-server become
promptforge-ws and promptforge-ws-server: directory moves, package and
binary names, the workspace dependency table, Cargo.lock, and the
library path promptforge_ws_server. CI renames the check-workbench job
to check-workshop and updates every --exclude flag. The example config
workbench.example.toml becomes workshop.example.toml, the two design
documents are renamed to match, and the UI modules
workbench-provider.ts and workbench-socket.ts become
workshop-provider.ts and workshop-socket.ts with their WorkshopProvider
and WorkshopSocket classes. Prose sweeps cover the READMEs, doc
comments, .gitignore comments, the gateway user guide, and UI comments
and error strings. Most files move at 100 percent similarity; where
content changed, the edits are confined to name strings.

- The WebSocket surface already used the ws abbreviation before this
  rename and is untouched: the GET /ws route and the chat_ws module
  mean WebSocket, not Workshop. The new -ws crate suffix collides with
  that abbreviation knowingly.
- Config discovery keeps a migration path: the server binary reads
  workshop.toml from the current directory and falls back to a
  leftover workbench.toml when the canonical name is missing; the
  desktop shell tries workshop.toml then workbench.toml at each of its
  three search locations (beside the executable, the current
  directory, the profile directory), with a regression test.
  First-run generation writes only workshop.toml.
- The old promptforge-wb README shows as a delete plus a new
  promptforge-ws README rather than a rename, because the density of
  name changes pushed it below git's similarity threshold.
Add design/what-promptforge-is.md, a 398-line positioning and design
paper for the whole system. Its thesis: human intent is the source
code, and everything downstream (plans, prompts, reports) is a build
artifact to be regenerated rather than rewritten. The paper walks the
four layers - the promptforge-core engine that executes Markdown/Lua
prompt pipelines, the credential-holding gateway, the toolchain (CLI,
MCP server, tool picker, webfetch, dev runner), and the Workbench
desktop app - and grounds each design choice in the history that
produced it: the Staker, a sixteen-step stakeholder-analysis pipeline
run inside a chat harness, and the 120-rule field manual distilled
from 89 transcripts.

Two measured effects carry the argument. Semantic blur: model rewrites
of prompt files grow them without adding capability (one rulebook grew
145.8 percent), so plans should be preserved and artifacts
regenerated. The fan-out asymmetry: models evaluate more reliably than
they generate, so the human sits in the judge's seat. The Workbench
sections describe an append-only hash-chained event store as the
product, an identity model (ExecutionFingerprint, PromptVersion,
stable block IDs), plan mode as an entropy filter, a leaderboard
replacing the tree view, on-device voice, and a sidecar critic, then
explicit non-goals, a six-stage build order, and open questions.

- The document calls the desktop app the Workbench throughout; its
  footer dates the text 2026-08-24 and credits Kimi K3 (Cursor agent)
  as the drafting model.
- Quantitative claims cite internal reports (the semantic-blur
  measurement, an August infrastructure analysis), and the softer
  numbers carry inline confidence tags; the 6-to-10x wall-clock
  speedup, for example, is marked estimate, medium confidence.
- Documentation only; no code changes.
The voice plugin kept a .voice-status div under the composer that grew
from zero height through a max-height transition every time it had
something to say, shifting the composer up and letting it drop back
eight seconds later. Recording state was already carried twice, by the
red mic button and the status bar's REC badge, and the routine
confirmations ("Recording...", "Transcript ready", "Recording
discarded") repeated what the user could already see. The div, its
CSS, and the showVoiceStatus helper with its auto-hide timer are gone;
setupVoice no longer receives a status element.

The messages worth keeping are genuine failures: microphone permission
denied, voice capture unsupported by the browser, a dropped voice
connection, capture failure, plus the no-speech-detected note. These
now paint the bottom status bar through a new
StatusBar.showLocal(label, severity), which writes the bar text and
error styling directly.

- showLocal is deliberately transient with no timer of its own: the
  next status frame from the server's observer overwrites it. The old
  8-second auto-hide timer was one of the pending timers that forced
  the smoke test to exit explicitly rather than wait them out.
- Two severities shifted in the move: no-speech-detected is now plain
  info, while an unrecognized frame from the voice socket is reported
  as an error instead of neutral text.
- The smoke test now asserts that no .voice-status element exists
  after the voice plugin mounts, so the removal cannot silently
  regress.
The workshop window is a wry webview pointed at the loopback server,
so clicking a link in an assistant reply navigated the app away from
itself with no way back. Two layers fix it. In the UI, the HTML
sanitizer now stamps target="_blank" and rel="noopener" on every
anchor that kept an href. In the desktop shell, a wry navigation
handler classifies each navigation target: loopback http(s) URLs
(127.0.0.1, [::1], or the literal localhost host) load in the webview
as before, while any other absolute http(s) URL is handed to the
system browser through the open crate and the webview stays put. The
classifier is a pure function with Rust unit tests, and the UI smoke
test asserts the target and rel attributes on a rendered reply link.

- target and rel are set after the sanitizer's attribute sweep, so
  author-supplied values (already stripped as unsafe) can never
  override them; an anchor whose href failed the URL check keeps no
  href, stays inert, and therefore gets no target either.
- Non-http schemes (about:blank, data:) and unparseable or relative
  targets are deliberately left to the webview, since blocking those
  would break the app's own internal navigation.
- localhost is matched by exact case-insensitive domain comparison, so
  a host like localhost.evil.example classifies as external (covered
  by a test); a failure to spawn the system browser is logged to
  stderr and the navigation is still blocked rather than falling
  through into the webview.
Streaming assistant text was re-parsed in full by marked on every
throttled update, so rendering cost grew with the square of the
message length. The new StreamingMarkdownRenderer in
markdown-blocks.ts splits the text into top-level blocks at blank-line
boundaries (code-fence aware, so blank lines inside an open fence
never split), renders each block into its own segment div, and
memoizes by rendered source: completed blocks parse exactly once, and
only the still-growing tail block re-parses on each update.

Before parsing, unterminated markdown on the tail is repaired so a
partial construct renders as its intended element instead of flashing
raw syntax: an unclosed code fence is closed, unbalanced bold and
italic markers are balanced (escaped markers ignored), an incomplete
link destination is closed, and a partial table delimiter row is
completed from the header's column count. MessageNode delegates its
markdown path to the renderer; every segment still passes through the
renderSafeHTML sanitizer as the final pass. A jsdom unit test uses a
parseCount instrumentation field to prove completed blocks are never
re-parsed, and joins npm test.

- Healed text is a rendering aid only and is never written back to the
  message. The memo key is the post-repair source, so a tail whose
  healed text equals its raw text is not parsed again when the message
  finalizes.
- Fence healing short-circuits the other repairs, because inside an
  open fence the remaining text is literal.
- The generating-caret CSS selector needed one more descent level
  (.mur-md-segment:last-child) since block content now nests inside
  per-segment divs.
The ThinkingPlugin shipped dormant with the vendored murm-ui chat
code. Register it in main.ts and rebuild it around three modes. While
reasoning streams, the block auto-opens into a preview capped at
roughly four lines that scrolls internally and stays pinned to the
newest line; the toggle label reads "Planning next moves..." with a
compositor-only shimmer (background-position is the only animated
property). When the first content token ends the reasoning stream, the
block auto-collapses once to the label row, capturing and restoring
the feed's scroll position across the layout change so the feed does
not jump. Clicking the toggle expands the full thinking text.

A grace-period loader covers slow starts: if generation runs about
500ms with no block of any kind, a synthetic non-interactive status
row with the streaming label appears on the last assistant message, so
fast responses never flicker it. A jsdom unit test covers the loader
timing, the preview, pinning, auto-collapse, and expansion, and joins
npm test.

- A manual toggle is sticky: after one click, auto open and collapse
  stop for that message. Preview auto-pin disengages when the user
  scrolls up inside the preview and re-engages within 8px of the
  bottom.
- The grace row is cleared by any block rendering into its message,
  not just reasoning blocks, so a text-first response removes it too.
- Mode transitions are announced through a visually hidden aria-live
  span; per-token reasoning text never is. The preview cap rule sits
  after the agent-run overrides in the stylesheet so it wins at equal
  specificity inside work segments, and the shimmer goes static under
  prefers-reduced-motion.
The ToolsPlugin shipped with the vendored murm-ui chat code but was
never registered. Register it in main.ts and rebuild it around a
per-run activity block: consecutive tool_call blocks in a message fold
into one collapsible group. Collapsed, which is the default, the
header is a fixed-height one-line status window; each new activity
line animates the previous one up and out, and once every call settles
the line rests on a summary such as "3 actions completed", with failed
calls counted separately. Expanding reveals the preserved log: one row
per call with a status icon, a human-readable label built from
preferred argument keys, and a chevron that unfolds Arguments and
Result sections.

The plugin is split into focused modules: tool-context pairs each
tool_call with its tool_result (which may live on a later message) and
caches the pairing, tool-format builds labels and summaries, tool-row
renders one log row, and tool-run-group owns the collapsible block. A
jsdom unit test drives the whole flow and joins npm test.

- Run grouping leans on engine render-pass semantics: blocks arrive in
  order once per state change with a fresh messages array, so the
  array's identity marks a new pass. The open-run table resets exactly
  then, which guarantees a member block always finds its run leader
  already registered earlier in the same pass.
- Member containers stay hidden while their rows live in the leader's
  preserved log; if a leader stops being first in its run, its group
  dissolves and the members rejoin on their own renders later in the
  same pass.
- The one-line window animates only transform at constant height, so
  the feed never shifts; the animation is skipped under
  prefers-reduced-motion, and the aria-live region announces resting
  summaries only, never per-activity lines.
The web UI gains a custom window title bar for the Windows desktop
shell: a semantic header in index.html with the program icon,
File/Edit/Window/Help menu buttons, a draggable empty center, and
Minimize/Maximize/Close controls with inline SVG glyphs in the standard
Windows order. The bar ships hidden, and the new window-chrome.ts
reveals it only when the wry initialization script has set the desktop
flag, so a plain browser never shows desktop chrome and never touches
window.ipc.

Every control speaks to the native shell through one narrow channel:
window-chrome.ts builds a typed WindowCommand envelope - a JSON object
naming exactly one of drag, minimize, toggle-maximize, or close - and
posts it over the wry IPC bridge. Coming the other way, the bar listens
for the shell's promptforge:maximized CustomEvent and validates its
detail field by field before swapping the maximize/restore glyph and
aria-label, so a malformed event changes nothing.

- The build pipeline learns nested static assets: build.rs and
  ui/build.mjs (mirrored STATIC_FILES lists) now create parent
  directories before copying, because icons/promptforge-icon-1.png is
  the first asset living in a subdirectory. The server routes it at
  /icons/promptforge-icon-1.png with an image/png content-type test.
- The menu buttons are inert placeholders here; a follow-up commit
  wires their popovers. The drag region only posts a drag command on a
  primary-button press whose target is the region itself, so presses
  on the buttons never start a native drag.
- If the desktop flag is set but window.ipc is missing (only possible
  by setting the flag by hand), commands are dropped silently rather
  than throwing from a click handler.
The title bar's placeholder menu buttons become working application
menus. The new window-menu.ts builds an accessible HTML popover behind
each button (role="menu", aria-expanded, role="menuitem" rows with
right-aligned shortcut hints), with one menu open at a time, dismissal
on outside pointer-down, and full keyboard support: ArrowUp/ArrowDown
move within a menu, ArrowLeft/ArrowRight step across menus, Escape
closes and returns focus to the button, Enter activates the focused
row. In a plain browser (no desktop flag) only the command set is
built; the popover DOM wiring is skipped entirely.

Every action dispatches through a single WindowMenuCommands set, so
future keyboard shortcuts and any other menu surface call the same
functions. The Window menu reuses the visible window controls' actions,
which window-chrome.ts now exports as named minimize, toggle-maximize,
and close functions. File offers New Chat (through the ChatUI session
API) and Close Window; Help opens a new About dialog - a focus-trapped,
Escape-dismissable modal naming the product, version, and license that
restores focus to its invoker when dismissed.

- Edit commands (undo, redo, cut, copy, paste, select all) go through
  document.execCommand rather than the async Clipboard API: WebView2
  hosts the page as application content with clipboard access, and
  execCommand preserves the editable target's native undo stack and
  selection semantics. jsdom leaves execCommand undefined, so a guard
  keeps the commands no-ops under test.
- Clicking a menu button moves focus to the button, so the edit target
  is remembered continuously through a document focusin listener
  instead of being read at open time; Edit rows render aria-disabled
  when no connected editable element has been focused.
- APP_VERSION in about-dialog.ts mirrors the workspace Cargo.toml
  version by hand; the two must be bumped together.
Replace the structural placeholder CSS for the title bar and its menu
popovers with the full visual treatment; no markup or script changes.
A --titlebar-* variable block joins the :root skin tokens (bar height,
foreground, divider, accent, glyph and hover colors, control width,
icon and font sizes, popover width and shadow), each derived from an
existing palette token with the stock value as fallback, so a future
skin can restyle the whole chrome by overriding variables.

The bar renders as a 40px near-black strip with a one-pixel bottom
divider: decorative program icon, text menu buttons flush left, a
flexible drag center, and three equal-width window controls with muted
SVG glyph strokes. Hovers flip instantly like native chrome - a neutral
wash on the menus, Minimize, and Maximize, and the conventional red
fill with a white glyph on Close - and every control takes a one-pixel
inset purple accent focus ring. Popovers become raised near-black
surfaces with a drop shadow, a 200px minimum width, and compact rows
with the shortcut right-aligned in smaller muted text.

- The bar's [hidden] rule must keep winning over the new base rule's
  display:flex; the attribute selector's extra specificity guarantees
  it, which is what keeps the bar out of plain-browser layout now that
  the base rule sets a display.
- Disabled menu items get their hover wash suppressed
  (aria-disabled="true":hover resets to none) so they read as inert.
- A new titlebar-style.mjs test file pins the skin rules, so token
  wiring regressions fail the UI test run instead of only visual
  review.
On Windows the shell drops the native window frame
(with_decorations(false)); macOS and Linux keep their decorated
windows. The custom HTML title bar is now the window's only chrome, so
the shell must accept window commands from the page: the event loop is
rebuilt with a user-event type, a wry IPC handler parses each incoming
envelope, and valid commands travel through an EventLoopProxy to run
on the event loop thread, where the tao Window actually lives. Drag
starts a native window drag, Minimize and ToggleMaximize call the
matching window methods, and Close exits the run_return loop, so the
in-process server behind the webview still shuts down cleanly through
the normal return path.

State flows back the other way too: every Resized event dispatches a
promptforge:maximized CustomEvent into the page via evaluate_script,
because every maximize path - the button, a title bar double-click,
Windows Snap, and restore - surfaces in the loop as a resize, making
Resized the one reliable hook for keeping the glyph in sync.

- parse_window_command is deliberately paranoid: malformed JSON,
  missing or mistyped fields, and unknown command names all return
  None, so no unrecognized payload can ever reach a native window
  operation. Unit tests enumerate the four valid envelopes and ten
  rejected shapes.
- The IPC handler runs on a webview thread with no access to the tao
  Window, which is why commands are enum values forwarded by proxy
  rather than handled inline.
- A wry initialization script sets window.__PROMPTFORGE_DESKTOP__ so
  the page can tell the desktop shell from a plain browser; serde_json
  joins the shell crate's dependencies for envelope parsing.
The desktop shell window now carries the PromptForge program icon (the
cold medallion frame) instead of the OS default. The PNG is embedded
at compile time with include_bytes! so the installed binary carries no
asset files, decoded once at startup with the png crate - a new
workspace dependency at 0.18 - and installed through
WindowBuilder::with_window_icon.

Decoding is strict but failure is soft. decode_png_rgba accepts only
8-bit RGBA output, matching the bundled asset, so the pixel format
handed to Icon::from_rgba is effectively fixed by the asset itself.
window_icon() maps any decode or conversion failure to a logged
message and None, so a bad asset can never block startup - the window
simply keeps the default icon.

- All five heat-stage frames (promptforge-icon-1 through -5) land in
  crates/promptforge-ws/assets/icons, but only frame 1 is embedded and
  shipped; the other four stay on disk, reserved for a future activity
  animation that swaps the icon as the forge heats.
- Unit tests pin the bundled asset itself, not just the code path: the
  embedded bytes must decode to exactly 128x128 with a full RGBA pixel
  buffer, and junk or empty input must fail. Re-exporting the icon in
  the wrong size or color type therefore fails cargo test instead of
  silently shipping a broken icon.
The workshop server gains filesystem access, deliberately jailed. A
new workspace.rs module holds an in-memory set of granted roots - a
dropped folder grants itself, a dropped file grants its parent
directory - and mounts four routes: GET /workspace/tree lists one
directory level (or the granted roots when no path is given), GET
/workspace/file reads a file, PUT /workspace/file writes one, and
POST /workspace/grant registers a root. Grants live only for the
running process; persisting them across runs is a separate future
consent decision.

Confinement is two layers, patterned on the gateway's artifact-cache
confine.rs: a lexical rejection of ".." components and ":" alternate
data stream names first, then canonicalize-and-prefix-match against
the canonical grants before any filesystem operation, so traversal,
symlink escapes, and UNC aliases cannot reach outside a grant. Reads
cap at 1 MiB and refuse binary (NUL-containing) and non-UTF-8 files.
Writes carry a modified-time conflict token: when the target exists,
expected_modified_ms must equal its current mtime or the write is
refused, preventing silent lost updates from concurrent editors.

- Writing a new file cannot canonicalize the file itself, so the
  parent is canonicalized and the name reattached - and a dangling
  symlink at the target is refused as outside the grants, because
  fs::write would follow it and create the target outside the jail.
- Every failure renders as a stable JSON envelope with a
  machine-readable code and a matched HTTP status: 403 for
  confinement refusals, 404, 400, 415 for binary or non-UTF-8, 413
  for oversize, 409 for a stale token, 500 for I/O.
- The grant set is an Arc<RwLock<BTreeSet>> shared by clone, so a
  grant registered through the route is visible to every handler
  immediately; poisoned locks recover through into_inner.
Dropping files or folders from Explorer onto the workshop window now
registers them as workspace grants. The shell installs a wry drag-drop
handler that reacts only to the Drop event - Enter, Over, and Leave are
cursor feedback the shell does not need - and forwards the dropped OS
paths through the tao event loop, since the handler runs on a webview
thread with no access to the WebView. The loop then dispatches the
paths into the page as a promptforge:file-drop CustomEvent. To carry
both payloads on one loop, the existing window-command user event is
folded into a new ShellEvent enum alongside the file-drop variant.

On the page, the new workspace-drops.ts validates the event detail
field by field - only an object carrying a paths array of plain strings
is accepted - and POSTs each path to /workspace/grant in order; a
per-path failure paints the status bar and does not stop the remaining
grants. No file bytes are read on drop; granting is path registration
only. In a plain browser the module is inert: the listener is only
installed under the desktop flag, and normal HTML5 drag and drop of
file contents keeps working untouched.

- The drag-drop handler returns true to take over the drop, so the
  webview never navigates to a dropped file; the page learns the paths
  only through the typed event.
- normalize_dropped_path strips the Windows verbatim prefix (\\?\) and
  collapses the verbatim UNC form (\\?\UNC\server\share) to the plain
  \\server\share spelling before dispatch, so the page hands the
  workspace API the same spelling Explorer shows. Tests cover spaces,
  unicode names, and both verbatim forms.
cargo fmt --all --check fails on workspace.rs as it landed with the
confined workspace file APIs: one assert! in the test module was
hand-wrapped across four lines even though its condition and message
fit rustfmt's line budget on a single line. rustfmt wants the collapsed
form, so the manually expanded call registers as formatting drift and
the whole workspace-wide check goes red on this one call site. This
commit collapses the assert! to the single line rustfmt produces,
restoring a formatting-clean tree so the gate passes again.

The change is whitespace only. The assertion's condition,
!target.exists(), and its message, "nothing may be written outside the
grant", are unchanged byte for byte; only the line breaks move, so no
test behavior shifts in any way.

- The affected assertion is not incidental: it closes out the
  confinement test proving that a refused write outside every granted
  root leaves no file behind on disk, which is the core security
  property of the workspace module. Worth knowing it still runs
  exactly as before, since a formatting fix touching a security test
  is the kind of diff that invites a double take.
The single-panel Dockview setup grows into the workshop layout. A new
zones.ts registry names three zones over Dockview groups - left for
the workspace tree, main for document editors, right for agent chat,
with bottom reserved - and is the only module that talks to Dockview
placement APIs. openInZone places a new panel by the user's recorded
override first, then the panel type's declared affinity; onDidMovePanel
records where the user dragged a panel so reopening it returns there,
and moving a panel back to its affinity zone deletes the override.
When a zone's last panel closes its Dockview group disappears, and the
next open rebuilds the group on that zone's side of the dock.

panel-types.ts replaces main.ts's inline single-component factory with
a static registry: each panel kind declares its zone affinity, default
title, and content factory, and Dockview's createComponent dispatches
through it. The new WorkshopTreePanel lists the granted workspace
roots and browses one directory level at a time through the new
validated workspace-api.ts fetch boundary (every response parsed field
by field from unknown, no casts); activating a file opens an editor
panel in the main zone, keyed and titled by its path.

- The editor panel is a deliberate placeholder that only shows its
  path: its registry entry and { path } params contract are already
  final, so the real editor later slots in without touching zones or
  the tree.
- Tree expansion state and fetched listings are module-level session
  caches, so closing and reopening the Workshop panel restores the
  tree as the user left it; listings are fetched once per directory
  per session.
- An unknown component name renders a labelled placeholder panel
  instead of throwing, so a stale layout can never break the dock.
The placeholder editor panel becomes a real editor, split across a
seam. The new editor-surface.ts defines the EditorSurface contract
(open, text, markSaved, isDirty, onDirtyChange, focus, dispose) and
its CodeMirror 6 implementation, the only place in the app that
imports @codemirror/*: basic-setup plus search, a dark theme and
syntax highlight style drawn from the same :root palette tokens as the
rest of the UI, lazily imported language modes for JS/TS, Python,
Rust, JSON, Markdown, and YAML plus TOML through legacy-modes, and
dirty tracking as an updateListener comparing the live document
against the last opened or saved baseline.

editor-panel.ts is rewritten against the interface only. It loads its
document through the workspace API, marks the Dockview tab title with
a dot while dirty, and saves with the server's modified-time conflict
token. A save refused as a modified conflict (409) opens a themed,
focus-trapped dialog offering Reload - discard the editor text for the
on-disk text - or Overwrite, which re-reads the file for a fresh token
and writes the editor text over it; a second conflict in that window
reopens the dialog. workspace-api.ts gains fetchFile and writeFile
with a typed ModifiedConflictError for the 409 case.

- open() carries a generation counter so a lazy language-mode import
  resolving after a newer open cannot reconfigure the wrong document;
  a failed language load silently leaves the document as plain text.
- The panel takes injectable seams (createSurface, readFile,
  writeFile), so the new editor-panel.mjs tests drive the save and
  conflict flows without CodeMirror or a live server.
- The single-file esbuild bundle inlines the dynamic language imports;
  the import() structure documents the load boundary rather than
  actually splitting chunks.
Three pieces of workbench infrastructure land together. Persistence:
the dock's serialized layout plus the zone registry's placement state
(zone group map and per-panel overrides) are written to localStorage
under one versioned key, debounced 250ms off layout changes. Only
panel identity is stored; panels re-create through their registered
factories on load. Any restore failure - bad JSON, a schema version
mismatch, a fromJSON throw - clears the dock and reports false so
boot builds the known-good default three-zone layout instead. Lock:
one shared boolean, driven from a state-labeled Window menu command
and a padlock control on every zone header; while locked, Dockview
refuses drags and CSS hides the tab strip and drop affordances. The
layout boots locked, and a lock toggle persists immediately since it
is not a layout change. Shortcuts: a single app-level keydown
listener binds plain-Ctrl combinations only - Ctrl+S saves the active
editor, Ctrl+W closes it, Ctrl+B toggles the Workshop tree, Ctrl+Tab
and Ctrl+Shift+Tab cycle open editors with wraparound, and
Ctrl+Shift+F opens and focuses the tree.

- Ctrl+W routes through a new EditorPanel.requestClose: a dirty
  buffer opens an unsaved-changes dialog (Save / Discard / Cancel)
  instead of silently losing edits, and Save only closes the panel
  when the write leaves the surface clean, so a failed or conflicted
  save keeps it open. The conflict modal was generalized into a
  shared showPanelDialog helper both dialogs build on.
- Unbound combinations fall through without preventDefault, so the
  browser and CodeMirror keep typing, undo/redo, and in-file find.
- The lock freezes user rearrangement only: openInZone and restore
  place panels identically in either state, and the status bar stays
  outside the serialized layout entirely.
Whisper falling back to a CPU pass stalls long enough (around half a
minute for a take) that the feature reads as broken rather than slow,
so the mic should not be offered at all in that case. The server
gains GET /voice/capability, answering {"gpu":bool}: true only when
the build carries the CUDA backend feature and a driver probe
succeeds. The voice plugin in the UI asks that endpoint before
mounting anything, and only inserts the push-to-talk button into the
composer when the answer is true.

The driver probe is deliberately cheap: it checks for the presence of
nvcuda.dll under System32 on Windows and /dev/nvidia0 on Linux (both
artifacts of an installed NVIDIA driver), and answers false on any
other OS. No CUDA context is created and no device is touched.

- The client probe fails closed on every path: non-OK status, a body
  that is not JSON, a missing or non-boolean gpu field, and network
  failure all answer false, and a dedicated test enumerates each
  mode. A stalling mic must never appear because of a flaky probe.
- The mic now mounts asynchronously, a microtask or two after the
  rest of the composer, so the smoke test polls for the button
  instead of querying it right after boot.
- cfg!(feature = "cuda") is evaluated at compile time, so a non-CUDA
  build reports false regardless of the hardware it runs on; the Rust
  test asserts that direction since CI has no GPU.
Generation feedback previously came from two overlapping sources:
message-node's generic three-dot loader on an empty assistant
message, and the thinking plugin's synthetic "Planning next moves..."
row, which only appeared after a 500ms grace timer. Both are
replaced. The prefill row now appears synchronously the moment
generation starts, reads exactly "Planning next moves" with no
ellipsis, and carries the shimmer animation. The first reasoning
token swaps it for the "Thinking" toggle, which streams a capped
four-line preview, auto-collapses on the first content token, and
remains a durable, repeatedly expandable disclosure after the reply
completes.

The label simplifies: the toggle reads "Thinking" in every state, and
the shimmer lives only on the prefill row. The visually hidden live
region now announces "Thinking" on stream start and "Thinking
complete" when it ends. A new optional ChatPlugin flag,
ownsEmptyLoadingState, lets a plugin claim the empty-assistant
loading state; message-node suppresses its generic dots whenever any
mounted plugin declares it, so the two indicators can never stack.

- The grace-timer machinery is deleted, not zeroed: fast responses
  used to skip the row entirely, and now every generation shows
  prefill until the first block renders, whatever its type.
- The shimmer class rename from streaming to prefill is enforced by a
  stylesheet-contract test asserting no --streaming rule remains and
  that reduced-motion still disables the animation.
- The toggle is deliberately click-repeatable both during streaming
  and after completion; a manual collapse stays sticky across later
  reasoning deltas.
Every completed model turn now ends with a quiet footer row: a Copy
button that writes the turn's plain text to the clipboard and shows a
checkmark for two seconds, a Fork button that is rendered fully
accessible but intentionally unwired (a placeholder for a future
action), and a semantic <time> element showing a compact relative
timestamp ("just now", "5m ago", "3h ago", "2d ago") whose
hover/focus tooltip shows the absolute completion time and, for agent
runs, a "Worked for ..." duration line.

The footer mounts at the feed-turn layer so both shapes of turn get
exactly one: plain assistant messages append it after their content,
and grouped agent runs attach it to the run element, keyed to the
final message and outside the collapsible thinking/tool work segment.
It hides while its turn is still generating and reappears on
completion. The dormant copy plugin is deleted since the footer owns
copy behavior now, and formatDuration moves out of feed-node into a
new shared format util alongside formatRelativeTime.

- The relative timestamp does not poll: each render schedules one
  timeout for the next minute or hour rollover boundary of the
  elapsed time, and destroy clears it. The test wraps window timers
  to prove no timer survives destroy.
- Updates re-append the footer whenever it stops being the last
  element, so work-segment collapse/expand rebuilds cannot bury or
  orphan it; the same footer instance survives those toggles.
- Clipboard denial is swallowed by design: the copy simply gives no
  checkmark feedback rather than surfacing an error.
The static sidebar (brand, model <select>, description div) is
deleted from index.html and style.css, and the workbench now occupies
the full window width. Model selection moves into a Model menu
between Edit and Window in the title bar. Unlike the other menus, its
popover is dynamic: every open rebuilds the rows from a small
catalog surface, rendering one menuitemradio row per model with
aria-checked state, a fixed-width check column so labels stay aligned,
and the model description as the row tooltip. An empty catalog (or a
missing surface) renders a single disabled "No models available" row.

Selection state moves out of the DOM into main.ts module state
(modelCatalog and currentModel). Recording a catalog keeps the
current selection when it survives the refresh and otherwise falls
back to the first entry or none, then writes the chat engine's
request defaults. A failed boot fetch of the catalog now just logs;
the menu shows its empty state until a pushed catalog from the server
heals it.

- Auto-selecting the first catalog entry is a behavior change: submit
  unblocks without any user interaction, where the old picker relied
  on the browser preselecting its first option.
- MenuHandle.rows had to become mutable, since the Model menu clears
  and rebuilds its row array on every open while the other menus stay
  frozen at build time.
- The smoke test orders pushed catalogs with the surviving entry
  second, so keeping a selection is distinguishable from the
  first-entry fallback, and it now proves selection through the model
  id carried on the actual chat frame.
The UI previously mounted exactly one ChatUI onto a singleton chat
panel from main.ts. Agent panels now carry stable per-agent ids
(chat:<uuid>), and a new AgentController owns one ChatUI per tab: it
observes the dock's add/remove/active-panel events, mounts a chat
onto each Agent panel's .mur-app surface as it appears (whether from
File > New Agent or a restored layout recreating its tabs), and
destroys that chat when its tab closes, falling back to a surviving
agent as the active one. The tab title changes from "Chat" to
"Agent".

All agents share a single provider, since the workshop socket already
multiplexes concurrent chat streams by request id, and they share one
model selection, which the controller applies at mount and broadcasts
to every live engine on change. File > New Agent always opens a fresh
independent tab in the right zone; File > New Chat starts a new
session on the active agent, opening one first when none exists. The
voice plugin becomes a per-tab factory so recording state in one tab
never touches another.

- The controller must be constructed before restoreLayout so restored
  tabs mount their chats; it also sweeps dock.panels at construction
  so it does not depend on that boot order.
- panelIdFor honors a provided agentId, which is how a restored
  layout reopens the same panel instead of duplicating it; without
  one it mints a fresh uuid.
- The menu layer wraps the agent surface in closures rather than
  aliasing its methods, because the surface may be a class instance
  whose methods need their receiver.
The layout lock is removed wholesale: the workbench is meant to be
rearranged freely now that Agent panels are tabbed, so a mode whose
only job was to freeze user drags no longer earns its complexity. The
layout-lock module (the shared boolean, its listeners, and the
padlock control rendered into every zone header) is deleted, the
Window menu loses its Lock/Unlock Layout command, the .dock--locked
styles that hid the tab strip and drop targets are gone, and Dockview
is created with locked: false everywhere, so tabs, dividers, and drag
targets are permanently interactive.

Layout persistence drops the locked field from its storage envelope
and bumps the schema version from 1 to 2. A version 1 snapshot (the
locked-era envelope) fails the parse and the boot falls back to the
known-good default layout; there is no migration, since the only
difference is a field nothing reads anymore. The default boot order
is now explicit: the Workspace tree anchors the left zone first, then
one Agent opens on the right.

- The schema bump is the chosen compatibility mechanism: even a
  structurally valid old envelope is rejected rather than migrated,
  trading one-time layout loss for zero migration code.
- CommandItem.label reverts from string | (() => string) to a plain
  string; the function form existed solely for the lock's
  state-tracking menu label, and refreshEnabled no longer re-renders
  labels on open.
- Zone-header right actions are gone entirely, since the padlock was
  the only control ever rendered there.
Pressing Stop while a reply streamed left the status bar's activity
LED lit amber indefinitely. The root cause is structural: aborting a
chat recycles the persistent /ws socket (reopening is how the client
cancels, there is no cancel frame), so the server's terminal status
frame for that chat can never arrive, and the sustained "thinking"
LED state set by the last status frame was never cleared.

The client now clears its own state at the moment of abort: the
workshop socket gains onAbort listeners fired from the abort path,
and main.ts wires them to a new StatusBar.clearActivity() that drops
both the sustained and pulsed LED states and applies the idle lens.
On the server, a failed delta send (client disconnected mid-stream)
now emits an idle status update before the relay returns, so any
other subscriber still watching the status bus returns to Ready
instead of keeping a stale activity LED.

- clearActivity also cancels the pending LED pulse timer; without
  that, a timer armed by the stream could re-light the LED after the
  abort. The smoke test waits 400ms after stopping to catch exactly
  that re-arm.
- clearActivity touches only the LED: the text, tooltip, progress,
  and REC badge belong to other flows and are left alone.
- The stop button is type=submit with no click handler of its own, so
  the test drives it by re-dispatching the form's submit event, and
  it waits for the chat frame on the wire rather than the button
  label, which flips synchronously before the request exists.
The custom title bar was gated on the desktop shell's
__PROMPTFORGE_DESKTOP__ flag, but the bar is also where the File,
Edit, Model, Window, and Help menus live - so serving the UI to a
plain browser produced an application with no menus at all. The bar
is now revealed unconditionally. Only the native window-control
cluster (drag region, minimize, maximize, close) remains
desktop-only, because those controls need the wry IPC bridge to act
on a real window; in a browser the cluster is hidden rather than
shown as dead buttons.

setupWindowMenus loses its browser-mode early return, so all five
menu popovers are built and wired in both modes. The native window
commands reachable from menus (Minimize, Maximize/Restore, Close
Window) become deliberate no-ops in a browser: the missing-bridge
guard in postWindowCommand, previously documented as unreachable, is
now the normal browser path, and dropping the command beats throwing
from a click handler.

- Hiding the control cluster with the hidden attribute required a CSS
  rule: the cluster's own display: flex would otherwise override the
  user agent's [hidden] rule and keep it visible.
- The smoke test runs the full menu path with no window.ipc defined,
  so a passing run proves the browser-mode wiring never touches the
  bridge; the unit tests additionally click Minimize and Close Window
  in browser mode and assert nothing is posted.
- Menu commands that do not need the bridge (New Chat, New Agent,
  Edit commands, About) dispatch identically in both modes through
  the one shared command set.
The Thinking block never appeared because the server discarded the
reasoning side channel entirely: delta parsing extracted only
choices[0].delta.content, so reasoning models' scratch work vanished
before it ever reached the client. The parser now returns both fields
of a streaming delta, accepting reasoning_content plus the reasoning
and thinking synonyms (first non-empty wins, matching
promptforge-core's normalization), and forwards each one as a
{"type":"reasoning"} frame. The websocket client surfaces these
through a new onReasoning handler, the provider turns them into
reasoning_delta events in their own block, and the thinking plugin
renders them.

Two UI behaviors change alongside the relay. A turn whose only "work"
is reasoning no longer collapses into an agent-run work segment: the
feed only builds an agent-run item when a tool call is present, so
reasoning-only replies render as plain messages with an inline,
durable Thinking toggle. And the "Planning next moves" prefill row no
longer races the feed render: message elements now carry a
data-message-id, and the prefill attaches to the generating message
by id instead of grabbing the last assistant element.

- Reasoning is deliberately kept off the tape: only answer content is
  assembled into the recorded response, and a server test pins that
  contract.
- A reasoning frame marks the pending chat as started, so a socket
  that closes after streaming only scratch work resolves instead of
  rejecting.
- The store notifies selectors before the feed's hot render creates
  the message element, so the prefill attach defers behind a
  microtask and retries across several frames, with a token
  invalidating attempts once generation moves on.
On Windows, wry's drag-drop handler revokes WebView2's IDropTarget,
which disables all HTML5 drag-and-drop inside the page - Dockview
panel dragging included. The shell now wraps Chromium's own drop
target instead of letting wry replace it: a new delegating IDropTarget
in drop_target.rs forwards every drag call to Chromium's target, so
panel drags work again, while CF_HDROP paths are observed on Drop and
still feed the existing promptforge:file-drop grant flow. The page
side suppresses the browser's default file-drop navigation itself, so
a dropped file never navigates the app away.

The commit also refactors the chat WebSocket handler: the duplicated
per-chunk forwarding of reasoning and content deltas is extracted into
a forward_payload helper with a small Forward result enum, and the
client-disconnected cleanup into its own function, with no behavior
change.

- Installation of the delegating target retries on a timer, because
  Chromium registers its own IDropTarget on the WebView2 child windows
  asynchronously; a one-shot install at window creation would find no
  target to wrap.
- The new windows crate dependencies are deliberately not
  workspace = true: the crate mirrors the workspace lints with
  unsafe_code lowered to deny, and drop_target.rs alone opts back out,
  keeping unsafe scoped to the COM-facing module.
Every panel now renders a normal Dockview chip tab: the singleTabMode
"fullwidth" option is removed, because a lone tab stretched to full
width reads as a title bar rather than a tab. Panel creation gains a
createTabComponent dispatch alongside the existing createComponent
one, and the Workshop tree panel gets a dedicated close-button-free
tab renderer, so the tree can no longer be closed from its tab. The
tree is also re-ensured at boot, so a stale persisted layout can never
lose it, and a new Window > Workshop Panel menu item toggles it,
sharing the Ctrl+B shortcut's command.

File > New Chat is gone, along with AgentController.newChat: New Agent
is now the only way to start a fresh conversation, collapsing two
near-duplicate entry points into one.

- The layout schema bumps from v2 to v3 and older snapshots are
  discarded: panels now serialize their tabComponent, and a v2
  snapshot restored as-is would rebuild the Workshop tree without its
  protected tab renderer, resurrecting the closable tree.
- createPanelTabComponent returns undefined for every panel other than
  the tree, which hands those panels to Dockview's default tab
  renderer rather than requiring a custom renderer per panel type.
The desktop app now builds with the cuda feature enabled by default,
so a stock build is voice-capable out of the box at the cost of
requiring the NVIDIA CUDA toolkit to compile; a machine without the
toolkit builds with --no-default-features, and voice then stays off at
runtime.

The other half closes a waste path: voice is GPU-only, because a
transcription take stalls on a CPU pass and the UI hides the mic. The
server now checks gpu_transcription_available() before touching the
whisper models. AppState::new skips the startup engine load and posts
a "Voice disabled" status explaining that the models stay unloaded,
and serve.rs hands the provisioning task an empty voice config so it
exits immediately. A CPU build therefore never spends gigabytes
downloading or loading models behind a mic that is not there, and
never announces Voice ready.

- The gate must exist in both boot paths: the startup engine load in
  AppState::new covers a model already on disk, while the provisioning
  task spawned in serve.rs is what would otherwise download a missing
  model through the gateway cache in the background.
- The commit also carries cargo fmt fixups in drop_target.rs and
  chat_ws.rs - formatting churn only, no behavior change in either
  file.
vinniefalco and others added 28 commits August 27, 2026 22:44
Give the gateway reconnect loop a backoff discipline in the new
backoff.rs: the delay escalates with jitter while the gateway is
down, is shared across the heartbeat and relay paths, and resets
only on useful work - a delivered streamed token or a successful
buffered completion - never on a mere successful connect. A
connect-without-delivery previously reset the schedule, so a
gateway that accepted connections but never answered was hammered
at the floor interval forever. A total-delay budget (24h) stops
reconnect probes with an explicit give-up report on the heartbeat
status instead of probing silently for the life of the process.

Tests pin the schedule (escalation, jitter bounds, clamp, budget
exhaustion, shared state across clones) and the reset semantics:
mere connect keeps the backoff escalated, a delivered token and a
buffered success reset it, an answered-but-declined exchange does
not.

- Reset flows through the delivery paths in chat_ws.rs and
  relay.rs, not the probe path, so the definition of useful work
  sits where delivery is proven.
Pin the traversal guarantee of the debug disk-serving asset path
with three tests in assets.rs: a relative ../ escape, a Windows
backslash-separator escape, and an absolute-path name that would
make Path::join replace the asset root - each must answer 404.
The targets are real files on disk (the crate's own manifest), so
the debug path can only refuse on containment, never on a missing
file, and the tests run in the debug profile, which is exactly
the disk-serving path under scrutiny.

No production change was needed: rust-embed 8.12.0's debug get()
canonicalizes and prefix-checks against the asset root, matching
the release embed-map miss, and the routes pass only fixed
literal names, leaving ui_asset as the sole seam.

- Tests-only commit by design; the step's deliverable was the
  proof, with a fix only if the proof failed.
Refactor the gateway runner from a blocking run(&ServeOptions)
that owned the runtime and waited on Ctrl-C into a spawnable
form: spawn(&ServeOptions) starts the server on a dedicated
thread with its own multi-thread runtime, confirms readiness
through an mpsc handshake before returning, and hands back a
GatewayHandle exposing url(), shutdown(), and join(). Shutdown is
a oneshot wired into the existing with_graceful_shutdown, and
dropping the handle signals it too. run() is now a thin wrapper -
spawn, install the Ctrl-C handler, join - so the binary's
behavior is unchanged. Startup errors cross the handshake to the
caller instead of dying on the spawned thread, and a bind
conflict fails spawn with the bind error kind rather than
reporting ready.

The pattern mirrors the workshop server's serve.rs so the two
embeddable servers read alike.

- The watchdog/Termination machinery in serve.rs is deliberately
  not mirrored; gateway shutdown semantics stay as today until
  the hosting work needs more.
- Ctrl-C waits on a small current-thread runtime on its own
  thread, preserving the existing handler-failure semantics.
Add workshop: Option<WorkshopConfig> to the gateway RawConfig,
following the tools pattern under deny_unknown_fields: bind
(default 127.0.0.1:7910), open_browser (default false), and
optional [workshop.voice] and [workshop.tape] sub-tables
mirroring the workshop server's own VoiceConfig and TapeConfig
fields. There is deliberately no [workshop.gateway] section - the
workshop client's base_url and api_key derive at boot from the
gateway's own [server] values via ServerConfig::client_url(),
which swaps an unspecified bind (0.0.0.0 or ::) for loopback, so
credentials are never duplicated and cannot drift. The same
api_key authorizes the admin endpoints, so the Model menu works
over derived credentials with no extra config.

A tape path that is absent or relative anchors against the
directory holding the boot config, never the process cwd, keeping
tape.jsonl and workshop-state.json together in a stable place.
Like [server], the section is boot-only: a profile carrying a
differing [workshop] is refused at boot by the existing match
check, and a mid-run switch refusal rides the switch SSE stream's
terminal error event without disturbing live state.

- A one-sided [workshop] (present in only boot or only profile)
  is refused, mirroring the strict [server] value-equality rule.
- AppState boot arguments are grouped into a BootOwned struct.
Behind a new workshop feature, the gateway spawns the workshop
server on its own loopback listener in the same process. The
Cargo surface: promptforge-ws-server becomes an optional
dependency, workshop = [dep:promptforge-ws-server], and
workshop-cuda forwards to the ws-server cuda feature; the default
feature set stays empty, so headless gateway builds never pull
whisper, CUDA, or the Node UI build into the graph.

When the feature is compiled in and the boot config carries a
[workshop] section, the spawn path builds the workshop Config
programmatically in the new workshop.rs: the client URL derives
from the gateway's own bound address (loopback-adjusted, with a
port-zero bind swapped for the real bound port), the same api_key
authorizes it, and the tape path anchors to the boot-config
directory. The workshop spawns only after the gateway listener is
bound; its ServerHandle rides inside GatewayHandle, and shutdown
sequences workshop first, then gateway, so the workshop's final
gateway calls never hit a dead socket. The workshop URL is
logged, and open_browser opens a tab for headless-with-UI use.

- A non-loopback workshop bind is refused at spawn.
- /health and /v1/models exist on both routers; a comment marks
  the collision as the known blocker for a future nested path.
- dep:open joins the workshop feature beyond the plan letter, to
  honor open_browser; it is gated, keeping the bare graph clean.
The shell no longer talks to a standalone workshop server: its
dependency swaps to promptforge-gateway with the workshop feature
enabled, and the shell default cuda feature forwards to
workshop-cuda. Boot spawns the merged gateway in-process with
profile default, waits on the hosted workshop /health through the
existing health-wait, opens the window at the workshop URL, and
shuts the handle down on window close.

Discovery keeps its search-order shape but now looks for the
gateway boot config: exe directory, cwd, ~/.promptforge/
gateway.toml. A first run generates the pair the gateway needs to
boot - gateway.toml with a loopback [server] bind on 8081, a
random hex api_key, and a [workshop] section carrying the current
voice-model defaults, plus profiles/default.toml, since the
gateway requires a profile and a profiles directory. Generated
output is tested against the gateway crate's own profile
resolution, so first-run files the gateway would refuse fail the
suite.

- The legacy workshop.toml flow and the standalone
  promptforge-ws-server binary stay untouched for development
  against an external gateway.
- rand joins the shell deps for api_key generation.
The gateway README gains a Hosting the workshop section: the
workshop and workshop-cuda feature flags with their build
implications (Node/esbuild and whisper enter the gateway build
only with --features workshop), field tables for [workshop],
[workshop.voice], and [workshop.tape] including the boot-config-
relative tape anchoring, the derived client credentials (no
[workshop.gateway]; base_url and api_key derive from [server],
loopback-adjusted), and the boot-only rule with the mid-run
switch refusal riding the switch SSE terminal error event. This
also completes the field reference the shell README points to.

The design log records the five decisions of the merge: a second
loopback listener now with nesting under /workshop/ as the
documented future option (route collisions on /health and
/v1/models are the known blocker), derived credentials over
duplicated ones, the boot-only [workshop] section refused in
profiles, workshop-first-then-gateway shutdown order, and the
shell booting the merged gateway with first-run generation of
gateway.toml and profiles/default.toml.
The serve tests wedge_http_connection fixture sent host:
workshop, which the cross-site guard now refuses with an
immediate 403 before ever polling the request body, so the
connection drained gracefully and the grace-window and
stopped-barrier tests failed: shutdown reported Graceful where
the tests expected Forced. The guard behavior is the intended
DNS-rebinding defense; the fixture was stale. It now sends a
loopback host and declares JSON so the request passes the guard,
reaches the body-consuming chat handler, and wedges the drain as
the tests intend. Production shutdown code is unchanged.
The ratchet ledger lagged the recent hardening commits: four new
modules had no entries, and seven modules grew past their
recorded ceilings. Add the new entries at their measured sizes
(atomic.rs 205, backoff.rs 229, cross_site.rs 338, deadline.rs
109) and raise the grown ceilings to actual line counts with no
padding.

Raise reasons, attributed per commit history:
- app.rs 500->544: wiring for the cross-site guard, atomic
  writes, deadlines, and backoff.
- assets.rs 27->66: the debug asset path-traversal proof.
- chat_ws.rs 2851->2963: SSE decoder conformance and backoff
  reset hooks.
- gateway.rs 1107->1300: gateway-call deadlines and decoder
  conformance tests.
- heartbeat.rs 721->872: backoff-reset-on-useful-work rework and
  probe deadlines.
- relay.rs 473->537: backoff reset on useful work.
- workspace.rs 935->1018: cross-site guard and atomic writes.
Update what-promptforge-is.md to the product's current name: the
Workbench is now the Workshop throughout - prose, section titles,
and the references entry. No content changes beyond the rename.
Two review fixes to the atomic-write sweep.

The sweep_orphaned_temps doc claimed every failure, including a missing
directory, is logged and tolerated, but a NotFound directory is
deliberately skipped without logging: the server has simply never
written there. The doc now states that a missing directory is tolerated
silently and reserves the logged-and-tolerated wording for the other
failures (an unreadable directory or entry, an unremovable file).

The sweep matched only the .pf-tmp suffix, so an orphaned
workshop-state.json.tmp left by the pre-helper menu scheme was never
removed. The sweep now also removes that fixed legacy name, covered by
a test that plants the legacy orphan beside an intact state file and
checks the sweep removes one and spares the other.

- Ceiling: atomic.rs 205 -> 230; the legacy-name constant with its doc,
  the wider match, and the new test grew the module past its recorded
  ceiling, so the ceiling moves to the actual line count.
The test a_stalled_route_answers_408_at_its_deadline spent 50ms of wall
clock waiting for the deadline layer to answer 408 over a handler
asleep for 30s. It now runs under tokio's paused time: both the stall
and the deadline advance virtually, so the test is instant and
deterministic. A comment records the invisible constraint - paused time
freezes real socket I/O, and the test stays safe only because the
socketless oneshot does none.

The named fix needed machinery the crate lacked: start_paused requires
tokio's test-util feature, which promptforge-ws-server did not enable.
It is now a dev-dependency feature, matching the pattern
promptforge-mcp-server, promptforge-dev, and promptforge-core-tests
already use.

- Ceiling: deadline.rs 109 -> 112; the comment explaining why paused
  time is safe here grew the module past its recorded ceiling, so the
  ceiling moves to the actual line count.
Two review fixes to the reconnect backoff.

The jitter draw computed xorshift(rng) mod (span + 1), where span falls
back to u64::MAX when the nanos conversion overflows. Had that fallback
ever fired, span + 1 would have wrapped and panicked in debug or
wrapped in release; the draw now uses span.saturating_add(1), so the
guarded arithmetic stays guarded end to end.

The production xorshift64 in backoff.rs was duplicated byte-for-byte by
a test-local copy inside gateway.rs's decoder test. The generator is
now pub(crate) under backoff, the gateway test imports the shared one,
and its doc notes the explicit-seed contract the gateway test relies
on: each randomized failure names its seed.

- Ceiling: backoff.rs 229 -> 232; the widened arithmetic comment and
  the shared-generator doc grew the module past its recorded ceiling,
  so the ceiling moves to the actual line count. gateway.rs shrank and
  keeps its ceiling.
The parity comment above the traversal tests said a debug build must
refuse names resolving outside ui/dist/, without noting the limit of
that guarantee: rust-embed 8.12.0 deliberately still serves an
out-of-root symlink planted inside ui/dist/. A reader could take the
comment as promising containment of the directory itself.

One added sentence scopes the guarantee to request-supplied names and
names the symlink bypass as outside it. No behavior changes; the tests
are untouched.

- Ceiling: assets.rs 66 -> 69; the added sentence grew the module past
  its recorded ceiling, so the ceiling moves to the actual line count.
spawn() wrapped two non-bind failures in StartupError::bind, whose
Display reads "failed to bind the listener": a failure to spawn the
gateway thread at all, and the thread exiting before the readiness
handshake. Both error paths also ran let _ = thread.join(), discarding
the panic payload of the one thread failure that closes the readiness
channel with no message.

Add StartupErrorKind::Thread (the kind enum is non_exhaustive) with a
matching private representation, route the thread-spawn failure through
it, and join the thread in both handshake-failure arms through a
failed_handshake helper that downcasts the panic payload into the
returned error text.

- The panic payload must be read through &*payload: passing &payload
  unsize-coerces the Box itself into dyn Any, and the downcasts then
  see Box<dyn Any + Send> instead of the panic message.
- A tokio runtime build failure inside serve_thread is still reported
  as Bind kind; that pre-existing misnaming is out of scope here.
The both-present arm of check_workshop_matches_boot reported only that
"the profile's workshop settings differ", while the adjacent
check_server_matches_boot names the exact differing field and its
values. An operator whose profile drifted from the boot file had to
diff the two [workshop] sections by hand.

Compare the four fields in declaration order through a
first_workshop_difference helper and name the first difference in the
validation message: bind and open_browser print both values; voice and
tape print both Debug forms (neither carries a secret). The message
keeps the "[workshop] mismatch" prefix the existing tests assert on.
The no-workshop stub of spawn_if_configured suppressed clippy::unnecessary_wraps with #[allow], which stays silent forever. Switch to #[expect] with the same reason so the suppression warns once it goes stale - for example if the stub ever gains a fallible path. Verified against -D warnings in both feature configurations: the lint still fires in the no-workshop build (the expectation is fulfilled) and the workshop build compiles the hosted variant instead.
The open_browser honor called open::that on the workshop URL inline, so
no test could observe it without opening a real browser. Split
spawn_if_configured into a thin production wrapper (passing open::that)
over a spawn_with_opener core that takes the opener as a plain closure -
the rulebook's cheapest seam for a single varying operation.

Three tests cover the honor: the opener is called with the workshop URL
when open_browser is set, it never runs when the setting is absent, and
a failing opener logs a warning without failing the spawn. Each spawns a
real workshop on an ephemeral loopback port with the tape anchored in a
tempdir, matching the runner's existing spawn fixtures.
GatewayHandle::shutdown stops a hosted workshop (waiting out its bounded
drain) before sending the gateway's graceful-shutdown signal, so the
workshop's final gateway calls never hit a dead socket. No test asserted
that order, and the obvious stall-based test would rest on unverified
hyper drain semantics and cost five-plus brittle wall-clock seconds.

Add a sequence-recording seam instead: a test-only mpsc observer on
GatewayHandle that records WorkshopStopped after the workshop's drain
returns and GatewaySignaled after the shutdown send. The workshop test
spawns with a [workshop] section, shuts down, and asserts the recorded
order; both records are synchronous inside shutdown(), so the test has
no timing dependence. A second test covers the no-workshop path (only
GatewaySignaled), which also keeps the seam used in every test build.

- The seam is cfg(test)-gated: the field, the ShutdownStep enum, the
  observe_shutdown setter, and the two record calls vanish from
  production builds.
The gateway's startup path called load_server and load_workshop on the
boot file back to back, and each ran its own collect_config_chain plus
${VAR} interpolation, so the same include tree was read, parsed, and
merged twice per boot.

promptforge-gateway-config gains load_boot_sections, which resolves the
chain and interpolates once, then extracts the [server] and optional
[workshop] sections from the same document. Section extraction now lives
in shared server_section / workshop_section helpers used by all three
loaders, so the single-section entry points keep their exact behavior:
load_server still requires [server], and load_workshop still returns
None for a missing section even when [server] is absent, which the
combined loader would reject.

load_startup in the gateway now makes one boot-file pass instead of
two, with the server and workshop parity checks unchanged. New tests
cover the combined loader: both sections extracted without full
validation, None for a missing [workshop], and a validation error
naming [server] when it is absent.

- The combined loader deliberately does not back load_workshop: a
  workshop-only file has no [server], and the existing include-chain
  test for that case pins the tolerant behavior.
The shell's workshop_url mapped the gateway handle's Option<&str> to the
window URL inline, so the user-facing error arm - a boot config with no
[workshop] section, leaving the shell with no page to open - had no test
coverage.

The mapping now lives in workshop_url_from, a pure Option<&str> ->
anyhow::Result<String> function that workshop_url delegates to. Behavior
is unchanged: the same context message, the same Ok passthrough.

Two unit tests pin the contract: a present URL passes through untouched,
and the None arm's error names the [workshop] section, so the message
cannot drift into telling the user nothing actionable.
The shell's README sends readers to the gateway README for the field
reference, and the [workshop] tables landed, but the required [server]
section's bind and api_key were documented nowhere in it.

Adds a short The [server] section table between Usage and Hosting the
workshop: both fields required, both accepting ${VAR} interpolation,
with a pointer to the boot-ownership rule the two sections share.
serve_thread wrapped a tokio runtime build failure in
StartupError::bind, whose Display reads "failed to bind the listener",
misnaming what actually failed - the same class as the thread-spawn and
pre-bind exit paths that StartupErrorKind::Thread was added to fix, and
noted as left behind when that kind landed.

The runtime build now reports through StartupError::thread, and the
kind's doc widens to name the runtime build alongside the spawn, exit,
and panic cases. Forcing a real runtime build failure takes resource
exhaustion, so the path is covered by the existing kind-mapping test on
the constructor rather than a dedicated spawn fixture.

- Pre-existing bug predating the sweep, fixed in its own commit per the
  rulebook; the kind enum is non_exhaustive, so the widened doc is the
  only API-surface change.
failed_handshake's payload reader had three arms and a test for one:
the panicked-thread fixture exercises the &str payload, but the owned
String arm (a panic! with format args) and the non-string fallback (a
panic_any call) were new behavior nothing would catch breaking.

One unit test calls panic_message directly with each payload shape:
borrowed &str, owned String, and a u64 standing in for a panic_any
payload, pinning the "non-string panic payload" fallback text.
@gregjkal

Copy link
Copy Markdown
Author

Closing: this accidentally diffed against master instead of its true base (the #5 branch), showing all 124 upstream-pending commits. Re-opened stacked on #5 as vinniefalco/promptforge#1; it will come back here once #5 merges.

@gregjkal gregjkal closed this Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants