docs: workbench v2 design spec — persistence, desktop layout, packaging

This commit is contained in:
Mortdecai
2026-03-30 07:02:33 -04:00
parent 98a61a8320
commit 7eebc83752
@@ -0,0 +1,365 @@
# Workbench Server v2 Design Spec
**Date:** 2026-03-30
**Status:** Draft
**Repo:** https://git.sethpc.xyz/Seth/workbench-server
## Summary
Workbench is an MCP server that lets any AI CLI create and control interactive web pages served over LAN. The AI pushes arbitrary HTML/CSS/JS to a browser page via WebSocket. Use cases: hardware diagnostics, guided procedures, dashboards, data collection — anything that benefits from a visual display surface the AI can control.
v2 adds: server persistence (survives AI CLI restarts), pip-installable packaging, AI-guided setup (INSTALL.md/START.md), and a simplified desktop-focused layout (no embedded terminal panel).
## What Changes From v1
| Aspect | v1 | v2 |
|--------|----|----|
| Server persistence | In-memory only — lost on restart | `.server.json` on disk, reconnect on startup |
| Layout | Split: diagnostic panel + sethmux iframe | Full-width display surface, no terminal |
| Mobile support | Responsive stacking | Desktop only |
| Dependencies | mcp, aiohttp | mcp, aiohttp (unchanged) |
| Packaging | Raw script | pip-installable package |
| Setup | Manual | AI-guided (INSTALL.md → START.md) |
| tmux/sethmux | Embedded iframe | Removed entirely |
## What Stays The Same
- MCP server on stdio transport
- 6 MCP tools: `workbench_scaffold`, `workbench_state`, `workbench_log`, `workbench_read_log`, `workbench_list`, `workbench_stop`
- HTTP + WebSocket per project (aiohttp)
- AI pushes arbitrary HTML/CSS/JS — no widget system, full creative freedom
- Dual-format session logging (session.md + session.jsonl)
- Project directories at `~/workbench/<name>/`
- LAN-native, no cloud, no auth
- Cost tracking (cost-log.jsonl)
## Architecture
```
┌─────────────────────┐ ┌──────────────────────────────┐
│ AI CLI terminal │ │ Browser (desktop) │
│ │ │ │
│ AI calls MCP tools │ stdio │ ┌────────────────────────┐ │
│ workbench_state ──┼────┐ │ │ AI-generated content │ │
│ workbench_log │ │ │ │ HTML / CSS / JS │ │
│ etc. │ │ │ │ updated live via WS │ │
│ │ ▼ │ │ │ │
│ │ MCP │ │ Schematics, tables, │ │
│ │ server │ │ checklists, dashboards│ │
│ │ │ │ │ — anything the AI │ │
│ │ │ │ │ decides to build │ │
│ │ ▼ │ ├────────────────────────┤ │
│ │ HTTP + │ │ Log feed │ │
│ │ WS on │ └────────────────────────┘ │
│ │ LAN │ ● Connected │ project-name │
└─────────────────────┘ └──────────────────────────────┘
```
## Persistence Mechanism
### Problem
The MCP server runs as a subprocess of the AI CLI. When the AI restarts, the MCP server restarts, and all in-memory state (running HTTP servers, WebSocket clients) is lost. The AI then calls `workbench_scaffold` again and starts a duplicate server.
### Solution
Persist server state to disk. On startup and on each `workbench_scaffold` call, check for an existing running server before starting a new one.
**Per-project server file:** `~/workbench/<name>/.server.json`
```json
{
"pid": 12345,
"port": 8070,
"started": "2026-03-30T10:00:00-04:00"
}
```
### workbench_scaffold flow
```
workbench_scaffold(name, title)
├─ Project dir exists?
│ ├─ No → create dir, write scaffold HTML, init logs
│ └─ Yes → keep existing files
├─ .server.json exists?
│ ├─ No → start new HTTP server, write .server.json
│ └─ Yes → read port from file
│ ├─ HTTP GET localhost:<port> returns 200?
│ │ ├─ Yes → server is alive, reattach (add to active_projects)
│ │ └─ No → server is dead, clean up .server.json, start new server
│ └─ PID still running? (fallback check)
└─ Return {"path": "...", "url": "http://<lan-ip>:<port>"}
```
### MCP server startup (reconnect)
When the MCP server process starts (before any tool calls), scan for running servers:
```python
for project_dir in ~/workbench/*/:
server_file = project_dir / ".server.json"
if server_file.exists():
info = json.loads(server_file.read_text())
if is_server_alive(info["port"]):
active_projects[name] = {"port": info["port"], ...}
```
This means: AI CLI restarts → MCP server restarts → immediately knows about all running project servers → `workbench_list` and `workbench_state` work without calling `workbench_scaffold` again.
### workbench_stop flow
```
workbench_stop(project)
├─ Log session end to cost-log.jsonl
├─ Stop HTTP server (runner.cleanup())
├─ Delete .server.json
└─ Remove from active_projects
```
## Scaffold HTML (v2)
Full-width desktop layout. No split pane, no iframe, no terminal embed.
```html
<!DOCTYPE html>
<html>
<head>
<title>{{TITLE}}</title>
<style>
:root {
--bg: #0a0f0c;
--panel-bg: #111a15;
--border: #2a3a2e;
--text: #aaccaa;
--text-dim: #557755;
--accent: #33ff66;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: monospace;
font-size: 14px;
height: 100vh;
display: flex;
flex-direction: column;
}
#content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
#log-feed {
border-top: 1px solid var(--border);
padding: 8px 12px;
max-height: 200px;
overflow-y: auto;
}
#log-feed h3 {
color: var(--text-dim);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 4px;
}
.log-entry {
font-size: 12px;
color: var(--text-dim);
padding: 2px 0;
}
.log-entry .log-time {
color: var(--accent);
margin-right: 8px;
}
#status {
background: var(--panel-bg);
border-top: 1px solid var(--border);
padding: 4px 12px;
font-size: 11px;
color: var(--text-dim);
}
#status.connected { color: var(--accent); }
</style>
</head>
<body>
<div id="content">
<h1 style="color: var(--accent); font-size: 18px;">{{TITLE}}</h1>
<p style="color: var(--text-dim); margin-top: 8px;">{{DESCRIPTION}}</p>
<p style="color: var(--text-dim); margin-top: 16px;">Waiting for content...</p>
</div>
<div id="log-feed">
<h3>Session Log</h3>
<div id="log-entries"></div>
</div>
<div id="status">Connecting...</div>
<script>
const WS_URL = `ws://${location.host}/ws`;
let ws;
function connect() {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
document.getElementById('status').textContent = 'Connected';
document.getElementById('status').className = 'connected';
};
ws.onclose = () => {
document.getElementById('status').textContent = 'Disconnected — reconnecting...';
document.getElementById('status').className = '';
setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'state') handleState(msg.state);
if (msg.type === 'log') handleLog(msg.entry);
};
}
function handleState(state) {
if (state.template) {
document.getElementById('content').innerHTML = state.template;
}
if (state.styles) {
let el = document.getElementById('dynamic-styles');
if (!el) { el = document.createElement('style'); el.id = 'dynamic-styles'; document.head.appendChild(el); }
el.textContent = state.styles;
}
if (state.script) {
try { new Function(state.script)(); } catch(e) { console.error('Script error:', e); }
}
window.__workbench_state = state;
try { localStorage.setItem('workbench-state', JSON.stringify(state)); } catch(e) {}
}
function handleLog(entry) {
const div = document.createElement('div');
div.className = 'log-entry';
const time = new Date().toLocaleTimeString();
div.innerHTML = `<span class="log-time">${time}</span>${entry}`;
const feed = document.getElementById('log-entries');
feed.appendChild(div);
feed.scrollTop = feed.scrollHeight;
}
// Restore state from localStorage on load
try {
const saved = JSON.parse(localStorage.getItem('workbench-state'));
if (saved) handleState(saved);
} catch(e) {}
connect();
</script>
</body>
</html>
```
Key changes from v1:
- No split layout, no divider, no term-panel, no iframe
- Full-width `#content` area
- Flexbox column layout (content grows, log and status at bottom)
- No mobile media queries
- Same WebSocket reconnect logic
- Same localStorage state persistence
## MCP Tools (unchanged API)
The 6 tools keep the same interface. Only internal behavior changes for persistence.
### workbench_scaffold
Same parameters: `name`, `title`, `description`.
New behavior: checks `.server.json` before starting a new server (see Persistence Mechanism above).
Returns: `{"path": "...", "url": "http://<lan-ip>:<port>"}` (unchanged).
### workbench_state
Unchanged. Pushes JSON to browser via WebSocket. `template`, `styles`, `script` fields.
### workbench_log
Unchanged. Appends to session.md and session.jsonl, pushes to browser log feed.
### workbench_read_log
Unchanged. Returns recent log entries from session.jsonl.
### workbench_list
Same return format, but now also checks `.server.json` files to detect servers that survived an MCP restart.
### workbench_stop
Same behavior, plus deletes `.server.json` on stop.
## Source Structure
```
workbench-server/
pyproject.toml
README.md
INSTALL.md
LICENSE
src/
workbench/
__init__.py
__main__.py # python -m workbench
cli.py # CLI: mcp, serve, list, help
server.py # MCP server + HTTP/WS management + persistence
project.py # Project dir management, logging
scaffold.html # HTML template
tests/
conftest.py
test_project.py
test_persistence.py
test_server.py
```
## Packaging
```toml
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "workbench-server"
version = "0.1.0"
description = "MCP server that lets AI CLIs build interactive web pages served over LAN"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.26.0",
"aiohttp>=3.9.0",
]
[project.scripts]
workbench = "workbench.cli:main"
```
## INSTALL.md
Same AI-guided pattern as kitty-workbench, but simpler (no terminal detection needed):
1. Clone + pip install
2. Detect platform and browser availability
3. Configure MCP for user's AI CLI
4. SSH ControlMaster setup (if applicable)
5. Smoke test
6. Write `~/workbench/START.md`
## README.md
Public-facing. Setup is "paste the URL" or "read INSTALL.md". Desktop browser as the display surface. No mention of kitty or terminal splitting. Examples: hardware diagnostics, guided procedures, data collection.
## CLI Interface
```
workbench mcp # start MCP server (stdio transport)
workbench serve <name> # serve a project without MCP (standalone)
workbench list # list projects
workbench help # usage
```