Compare commits

...

8 Commits

Author SHA1 Message Date
Mortdecai c97adfc561 docs: README and INSTALL.md — AI-guided setup with SSH ControlMaster 2026-03-30 07:36:09 -04:00
Mortdecai 0c2c7def09 feat: CLI entry point + remove old root-level files
Add src/workbench/cli.py with subcommands: mcp (stdio transport), serve
(standalone project serving), list (show all projects). Remove legacy
server.py and scaffold.html from project root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 07:34:40 -04:00
Mortdecai 4f34684e53 feat: MCP server with persistence — reattach to running servers on restart
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 07:33:27 -04:00
Mortdecai 16815ed6bb feat: project management module — create, log, read, list, server persistence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 07:30:24 -04:00
Mortdecai 918261c3a5 feat: scaffold HTML v2 — full-width desktop layout, no terminal panel 2026-03-30 07:28:54 -04:00
Mortdecai 9755828ae4 feat: package scaffold — pyproject.toml, src/workbench/ layout 2026-03-30 07:28:01 -04:00
Mortdecai 28dc693781 docs: workbench v2 implementation plan — 8 tasks 2026-03-30 07:19:20 -04:00
Mortdecai 7eebc83752 docs: workbench v2 design spec — persistence, desktop layout, packaging 2026-03-30 07:02:33 -04:00
18 changed files with 2734 additions and 96 deletions
View File
+193
View File
@@ -0,0 +1,193 @@
# Workbench — AI-Guided Setup
You are an AI assistant helping a user set up Workbench. Follow these steps in order. Do not ask the user what they have — detect it yourself. Present options conversationally.
## Step 0: Clone and Install
If the repo is not already cloned locally, clone it:
```bash
git clone <repo-url>
cd workbench-server
```
Install the package:
```bash
pip install .
```
If `pip install` fails due to PEP 668, use:
```bash
pip install --break-system-packages .
```
Verify it installed:
```bash
workbench --version
```
If already installed, skip to Step 1.
## Step 1: Detect the Environment
Run these commands and collect the output. Do NOT ask the user:
```bash
# Python
python3 --version
# Platform
uname -s -r -m
# Access topology
echo "SSH_CONNECTION=$SSH_CONNECTION"
# SSH usage detection
ls ~/.ssh/config 2>/dev/null && echo "ssh config: found" || echo "ssh config: not found"
grep -l "ControlMaster\|ControlPath" ~/.ssh/config 2>/dev/null && echo "ssh multiplexing: configured" || echo "ssh multiplexing: not configured"
ls ~/.ssh/known_hosts 2>/dev/null && wc -l < ~/.ssh/known_hosts 2>/dev/null && echo "known hosts (suggests SSH usage)" || true
```
## Step 2: Create Project Directory
```bash
mkdir -p ~/workbench
```
## Step 3: Configure SSH ControlMaster (if applicable)
If the user SSHes to remote machines (detected by known_hosts having entries, or the user mentions remote work), set up SSH connection multiplexing so the AI CLI can reuse authenticated SSH connections without needing to re-enter passwords or touch physical keys.
Ask the user: "Do you SSH into remote machines as part of your work? If so, I can configure SSH connection multiplexing — this lets you authenticate once, and my SSH commands piggyback on your open connection without needing a password."
If yes:
```bash
mkdir -p ~/.ssh/sockets
chmod 700 ~/.ssh/sockets
```
Check if ControlMaster is already configured:
```bash
grep -q "ControlMaster" ~/.ssh/config 2>/dev/null && echo "Already configured" || echo "Not configured"
```
If not configured, add to `~/.ssh/config` (create if needed):
```bash
touch ~/.ssh/config
chmod 600 ~/.ssh/config
cat >> ~/.ssh/config << 'SSHEOF'
# Workbench: SSH connection multiplexing
# First connection authenticates normally (password, key, etc.)
# Subsequent connections reuse the tunnel — no re-auth needed
Host *
ControlMaster auto
ControlPath ~/.ssh/sockets/%r@%h-%p
ControlPersist yes
SSHEOF
```
Explain to the user how it works:
> **How this works:** When you SSH into a remote machine, the connection stays open in the background. During that time, any other SSH command to the same host — including ones I run — reuses your authenticated tunnel. No password prompt, no key tap. The tunnel stays open until the original session closes or the connection drops — your key/auth provider handles its own timeout. Just open an SSH session to your target machine before asking me to work on it.
If the user's `~/.ssh/config` already has Host-specific blocks, add the ControlMaster settings under a `Host *` block at the **end** of the file so it acts as a default without overriding specific host configs.
## Step 4: Configure MCP
Add the Workbench MCP server to the user's AI CLI configuration.
**Detect which CLI they're using** by checking for config files:
```bash
ls ~/.claude/settings.json 2>/dev/null && echo "Claude Code detected"
ls ~/.gemini/settings.json 2>/dev/null && echo "Gemini CLI detected"
ls ~/.config/gemini/settings.json 2>/dev/null && echo "Gemini CLI detected (alt path)"
```
The MCP server configuration:
```json
{
"mcpServers": {
"workbench": {
"command": "workbench",
"args": ["mcp"]
}
}
}
```
Add this to the appropriate config file for the detected CLI. For unknown CLIs, print the JSON and tell the user to add it to their MCP settings. The server command is `workbench mcp` on stdio transport.
## Step 5: Smoke Test
```bash
workbench list
```
Should print "No projects found in ~/workbench/" (or list existing projects if any).
## Step 6: Write START.md
Write `~/workbench/START.md` — a personalized startup guide for this user's environment.
Include:
- Setup date
- Platform details
- MCP configuration that was applied (which CLI, what config)
- How to start a session
- SSH ControlMaster status
- How to access the workbench page (open URL in browser)
Example:
```markdown
# Workbench — Start Guide
**Setup date:** 2026-03-30
**Platform:** Linux (Ubuntu 24.04)
## Starting a Session
1. Start your AI CLI
2. Ask the AI to create a workbench project — it calls `workbench_scaffold` and returns a URL
3. Open the URL in your browser
4. The AI pushes content to the page in real-time
## MCP Configuration
Configured in `~/.claude/settings.json`:
```json
{
"mcpServers": {
"workbench": {
"command": "workbench",
"args": ["mcp"]
}
}
}
```
## Environment
- Python: 3.11.4
- SSH ControlMaster: configured
## Notes
- The HTTP server binds to 0.0.0.0 — open the URL from any device on your LAN
- If you restart your AI CLI, the server keeps running — just call workbench_scaffold again
- Session logs are saved in ~/workbench/<project>/session.md
```
Adapt the content to match what you actually detected and configured. This file is for future AI sessions to read, so be precise.
## Done
Tell the user setup is complete and how to start their first session.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+105 -94
View File
@@ -1,116 +1,127 @@
# Workbench
MCP server that lets any AI CLI spin up interactive hardware diagnostic web pages served over LAN.
An MCP server that lets your AI build interactive web pages you can open in your browser — diagnostic tools, dashboards, guided procedures, anything.
You describe the problem, hand the AI a manual or datasheet, and it builds a live diagnostic tool you can open on your phone at the workbench. Measurements, schematics, checklists, and session logs — all updated in real-time as you work.
## What It Does
The AI has full control over the HTML, CSS, and JavaScript. It pushes content to the browser in real-time via WebSocket. You see it update live as the AI works.
```
+------------------------------------------+
| Phone Browser (at workbench) |
| +-------------------+----------------+ |
| | Diagnostic Page | Terminal Panel | |
| | - Schematic | (ttyd/tmux) | |
| | - Checklist | | |
| | - Measurements | AI session | |
| | - Log feed | runs here | |
| +-------------------+----------------+ |
+------------------------------------------+
┌─────────────────────┐ ┌──────────────────────────────┐
│ AI CLI │ │ Browser │
│ │ │ │
> diagnose the HV │ HTTP │ ┌────────────────────────┐ │
│ focus circuit │ + WS │ │ AI-generated content │ │
│ ├────────►│ │ schematics, tables, │ │
│ AI pushes HTML/CSS/ │ │ │ checklists, dashboards│ │
│ JS to the browser │ │ │ — updated live │ │
│ │ │ ├────────────────────────┤ │
│ │ │ │ Session Log │ │
│ │ │ │ 14:32 R412: FAIL │ │
└──────────────────────┘ └──────────────────────────────┘
```
The AI generates all diagnostic content — schematics, component databases, test procedures, checklists. The server just provides the plumbing: HTTP file serving, WebSocket state push, and session logging.
## MCP Tools
| Tool | What it does |
|------|-------------|
| `workbench_scaffold` | Creates a project directory, writes the HTML shell, starts the HTTP/WebSocket server, returns the LAN URL |
| `workbench_state` | Pushes arbitrary JSON state to the browser. Include `template` (HTML), `styles` (CSS), `script` (JS) fields to update the page |
| `workbench_log` | Appends to `session.md` (human-readable) and `session.jsonl` (machine-readable), pushes to browser log feed |
| `workbench_read_log` | Returns recent log entries so the AI can resume a previous session |
| `workbench_list` | Lists all projects and their active/inactive status |
| `workbench_stop` | Stops the HTTP/WebSocket server, logs session end to `cost-log.jsonl` |
## Setup
### Requirements
Give your AI the repo URL and tell it to set you up:
```
Clone https://git.sethpc.xyz/Seth/workbench-server and set it up for me
```
Or if you've already cloned it, open your AI CLI in the repo and say:
```
Read INSTALL.md and set me up
```
The AI clones the repo (if needed), installs the package, configures the MCP server, and writes a personalized `START.md` for future sessions.
That's it. The AI handles everything.
## What It Does
Workbench is an [MCP server](https://modelcontextprotocol.io). Once configured, your AI CLI has access to these tools:
| Tool | What it does |
|------|-------------|
| `workbench_scaffold` | Creates a project and starts the HTTP server. Returns the URL. |
| `workbench_state` | Pushes HTML/CSS/JS to the browser page via WebSocket |
| `workbench_log` | Records a session log entry (saved to disk + shown in browser) |
| `workbench_read_log` | Reads recent log entries (for session resume) |
| `workbench_list` | Lists all projects and their status |
| `workbench_stop` | Stops the HTTP server and ends the session |
The AI generates all content — HTML, CSS, JavaScript. Workbench is just the plumbing.
### Server Persistence
Workbench servers survive AI CLI restarts. If you restart your AI and it calls `workbench_scaffold` again, it detects the existing running server and reattaches — no duplicates, no lost connections. The browser stays connected throughout.
## Usage
Once set up, just talk to your AI. It decides when to use the workbench.
**Hardware diagnostic:**
> "I need to troubleshoot the focus circuit on this oscilloscope. Here's the service manual."
**Guided procedure:**
> "Walk me through replacing the capacitors on this PCB. Show me the layout and a checklist."
**Data collection:**
> "I need to measure and record voltages at 12 test points on this power supply."
**Dashboard:**
> "Build me a live status dashboard for my homelab services."
The AI creates the project, starts the server, gives you a URL, and builds the page. Open the URL in your browser and watch it update in real-time.
## Project Data
Each session creates a project in `~/workbench/`:
```
~/workbench/
START.md # Personalized startup guide (created during setup)
io102/
index.html # Scaffold HTML (WebSocket client)
state.json # Current page state
session.md # Human-readable session log
session.jsonl # Machine-readable log (for AI session resume)
cost-log.jsonl # Session tracking
assets/ # Images, manuals, datasheets
```
Session logs are always human-readable. Anyone can follow what happened without AI access.
## Requirements
- Python 3.10+
- `mcp` and `aiohttp` packages
- A web browser
- Any MCP-compatible AI CLI
```bash
pip install mcp aiohttp
```
## Platform Support
### Claude Code
| Platform | Status |
|----------|--------|
| Linux | Full support |
| macOS | Full support |
| Windows | Via WSL2 |
Add to `~/.claude/settings.json`:
## FAQ
```json
{
"mcpServers": {
"workbench": {
"command": "/path/to/workbench",
"args": ["mcp"]
}
}
}
```
**Does the browser need to be on the same machine?**
No. The HTTP server binds to `0.0.0.0` — any device on your LAN can open the URL.
### Any MCP-compatible AI CLI
**What happens if I restart my AI CLI?**
The HTTP server keeps running. When the AI calls `workbench_scaffold` again, it detects the existing server and reattaches. The browser stays connected.
Point your client at the server:
**Can I resume a previous session?**
Yes. Projects persist on disk. The AI calls `workbench_read_log` to catch up on what happened.
```bash
python3 server.py # runs on stdio transport
```
**What AI CLIs work with this?**
Any that supports the [Model Context Protocol](https://modelcontextprotocol.io) (MCP) over stdio transport.
## CLI
The `workbench` script provides standalone commands:
```
workbench serve <name> [port] # serve a project without MCP (default port 8070)
workbench list # list all projects
workbench mcp # start the MCP server
workbench help # show usage
```
## How It Works
1. AI calls `workbench_scaffold("io102", "Heathkit IO-102 Focus Diagnostic")`
2. Server creates `~/workbench/io102/` with the HTML shell, log files, and state file
3. Server starts HTTP + WebSocket on a free port, returns `http://192.168.0.141:8070`
4. AI reads the equipment manual, builds a diagnostic page, calls `workbench_state` with HTML/CSS/JS
5. Browser updates live via WebSocket
6. As the user takes measurements, AI calls `workbench_log` to record everything
7. Session log is always human-readable — anyone can follow what happened without AI access
## Project Structure
Each project lives in `~/workbench/<name>/`:
```
~/workbench/io102/
index.html # scaffold shell + AI-generated content
state.json # current state (pushed to browser on connect)
session.md # human-readable diagnostic log
session.jsonl # machine-readable log (one JSON object per line)
cost-log.jsonl # session start/end tracking
assets/ # manuals, datasheets, images
```
## Design Principles
- **AI builds the content** — no templates, no rigid schemas. The AI reads the manual and generates everything.
- **Context light** — MCP tools return short confirmations. The AI has full freedom.
- **Everything is logged** — dual-format session logs that humans and AIs can both read.
- **LAN-native** — no cloud, no auth. Served on your local network.
## Origin
Built for troubleshooting a 1971 Heathkit IO-102 oscilloscope with a defocused CRT. The AI read the service manual, identified the likely fault (drifted carbon composition resistors in the HV focus voltage divider), and generated an interactive diagnostic page with circuit schematic, component test procedures, and a checklist — all viewable on a phone at the workbench.
**Can I use this over SSH?**
Yes. The AI runs on the remote host and the HTTP server is accessible over the network. Open the URL from any browser on the LAN.
## License
File diff suppressed because it is too large Load Diff
@@ -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
```
+18
View File
@@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "workbench-server"
version = "0.2.0"
description = "MCP server that lets AI CLIs build interactive web pages served over LAN"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"mcp>=1.26.0",
"aiohttp>=3.9.0",
]
[project.scripts]
workbench = "workbench.cli:main"
-2
View File
@@ -1,2 +0,0 @@
mcp>=1.26.0
aiohttp>=3.9.0
+3
View File
@@ -0,0 +1,3 @@
"""Workbench — MCP server for AI-driven interactive web pages."""
__version__ = "0.2.0"
+5
View File
@@ -0,0 +1,5 @@
"""Allow running as `python -m workbench`."""
from workbench.cli import main
main()
+81
View File
@@ -0,0 +1,81 @@
"""CLI entry point for workbench."""
from __future__ import annotations
import argparse
import asyncio
import sys
from workbench import __version__
def cmd_mcp(args):
"""Start the MCP server on stdio."""
from workbench.server import create_mcp_server
mcp = create_mcp_server()
mcp.run(transport="stdio")
def cmd_serve(args):
"""Serve a project without MCP."""
from workbench.server import WorkbenchServer
srv = WorkbenchServer()
async def run():
result = await srv.workbench_scaffold(args.name, args.name)
import json
data = json.loads(result)
print(f"Serving: {data['url']}")
try:
while True:
await asyncio.sleep(1)
except (KeyboardInterrupt, asyncio.CancelledError):
await srv.workbench_stop(args.name)
asyncio.run(run())
def cmd_list(args):
"""List all projects."""
from workbench.project import list_projects, read_server_info
projects = list_projects()
if not projects:
print("No projects found in ~/workbench/")
return
for p in projects:
info = read_server_info(p["name"])
status = f" (running on port {info['port']})" if info else ""
print(f" {p['name']}{status}")
def main():
parser = argparse.ArgumentParser(
prog="workbench",
description="MCP server for AI-driven interactive web pages",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
sub = parser.add_subparsers(dest="command")
sub.add_parser("mcp", help="Start MCP server (stdio transport)")
serve_parser = sub.add_parser("serve", help="Serve a project (standalone)")
serve_parser.add_argument("name", help="Project name")
sub.add_parser("list", help="List all projects")
sub.add_parser("help", help="Show help")
args = parser.parse_args()
commands = {
"mcp": cmd_mcp,
"serve": cmd_serve,
"list": cmd_list,
"help": lambda a: parser.print_help(),
}
if args.command is None:
parser.print_help()
sys.exit(0)
commands[args.command](args)
+156
View File
@@ -0,0 +1,156 @@
"""Project directory management — create, log, read, list, server persistence."""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
WORKBENCH_DIR = Path.home() / "workbench"
SCAFFOLD_HTML = Path(__file__).parent / "scaffold.html"
def _now_iso() -> str:
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
def project_path(name: str, workbench_dir: Path = WORKBENCH_DIR) -> Path:
return workbench_dir / name
def project_exists(name: str, workbench_dir: Path = WORKBENCH_DIR) -> bool:
return project_path(name, workbench_dir).is_dir()
def create_project(
name: str,
title: str,
description: str = "",
workbench_dir: Path = WORKBENCH_DIR,
) -> Path:
"""Create a project directory with scaffold HTML and log files. Idempotent."""
pdir = project_path(name, workbench_dir)
pdir.mkdir(parents=True, exist_ok=True)
(pdir / "assets").mkdir(exist_ok=True)
if SCAFFOLD_HTML.exists():
template = SCAFFOLD_HTML.read_text()
html = template.replace("{{TITLE}}", title)
html = html.replace("{{DESCRIPTION}}", description)
(pdir / "index.html").write_text(html)
md_path = pdir / "session.md"
if not md_path.exists():
md_path.write_text(f"# {title} — Session Log\n\nStarted: {_now_iso()}\n\n")
jsonl_path = pdir / "session.jsonl"
if not jsonl_path.exists():
jsonl_path.write_text("")
cost_path = pdir / "cost-log.jsonl"
if not cost_path.exists():
cost_path.write_text("")
state_path = pdir / "state.json"
if not state_path.exists():
state_path.write_text("{}")
return pdir
def append_log(
name: str,
entry: str,
data: Optional[dict] = None,
workbench_dir: Path = WORKBENCH_DIR,
) -> dict:
"""Append to session.md and session.jsonl."""
pdir = project_path(name, workbench_dir)
ts = _now_iso()
with open(pdir / "session.md", "a") as f:
f.write(f"\n### {ts}\n{entry}\n")
obj = {"ts": ts, "entry": entry}
if data:
obj.update(data)
with open(pdir / "session.jsonl", "a") as f:
f.write(json.dumps(obj) + "\n")
return obj
def read_log(
name: str, tail: int = 20, workbench_dir: Path = WORKBENCH_DIR
) -> list[dict]:
"""Read recent log entries from session.jsonl."""
jsonl_path = project_path(name, workbench_dir) / "session.jsonl"
if not jsonl_path.exists():
return []
lines = jsonl_path.read_text().strip().split("\n")
lines = [l for l in lines if l.strip()]
recent = lines[-tail:] if len(lines) > tail else lines
entries = []
for line in recent:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
entries.append({"raw": line})
return entries
def list_projects(workbench_dir: Path = WORKBENCH_DIR) -> list[dict]:
"""List all project directories."""
if not workbench_dir.exists():
return []
projects = []
for d in sorted(workbench_dir.iterdir()):
if d.is_dir() and not d.name.startswith("."):
projects.append({"name": d.name})
return projects
def log_session_event(
name: str, event: str, workbench_dir: Path = WORKBENCH_DIR, **extra
) -> None:
"""Append to cost-log.jsonl."""
pdir = project_path(name, workbench_dir)
obj = {"ts": _now_iso(), "event": event, "project": name}
obj.update(extra)
with open(pdir / "cost-log.jsonl", "a") as f:
f.write(json.dumps(obj) + "\n")
def write_server_info(
name: str, pid: int, port: int, workbench_dir: Path = WORKBENCH_DIR
) -> None:
"""Write .server.json with running server info."""
pdir = project_path(name, workbench_dir)
info = {"pid": pid, "port": port, "started": _now_iso()}
(pdir / ".server.json").write_text(json.dumps(info))
def read_server_info(
name: str, workbench_dir: Path = WORKBENCH_DIR
) -> Optional[dict]:
"""Read .server.json. Returns None if not found."""
server_file = project_path(name, workbench_dir) / ".server.json"
if not server_file.exists():
return None
try:
return json.loads(server_file.read_text())
except (json.JSONDecodeError, OSError):
return None
def clear_server_info(
name: str, workbench_dir: Path = WORKBENCH_DIR
) -> None:
"""Delete .server.json."""
server_file = project_path(name, workbench_dir) / ".server.json"
if server_file.exists():
server_file.unlink()
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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;
}
try {
const saved = JSON.parse(localStorage.getItem('workbench-state'));
if (saved) handleState(saved);
} catch(e) {}
connect();
</script>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
"""MCP server — 6 workbench tools with HTTP/WS server management and persistence."""
from __future__ import annotations
import asyncio
import json
import os
import socket
from pathlib import Path
from typing import Optional
from aiohttp import web
from mcp.server.fastmcp import FastMCP
from workbench.project import (
create_project, project_exists, project_path,
append_log, read_log, list_projects, log_session_event,
write_server_info, read_server_info, clear_server_info,
WORKBENCH_DIR,
)
def _get_lan_ip() -> str:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("192.168.0.1", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
def _find_free_port(start: int = 8070) -> int:
for port in range(start, start + 100):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("0.0.0.0", port))
s.close()
return port
except OSError:
continue
raise RuntimeError(f"No free port found in range {start}-{start + 99}")
class WorkbenchServer:
"""Core server logic — testable without MCP transport."""
def __init__(self, workbench_dir: Path = WORKBENCH_DIR):
self.workbench_dir = Path(workbench_dir)
self._active: dict[str, dict] = {}
self._runners: dict[str, web.AppRunner] = {}
def _is_server_alive(self, port: int) -> bool:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
s.connect(("127.0.0.1", port))
s.close()
return True
except (OSError, ConnectionRefusedError):
return False
async def reconnect_existing_servers(self) -> None:
if not self.workbench_dir.exists():
return
for d in self.workbench_dir.iterdir():
if not d.is_dir():
continue
name = d.name
info = read_server_info(name, workbench_dir=self.workbench_dir)
if info is None:
continue
port = info["port"]
if self._is_server_alive(port):
self._active[name] = {"port": port, "ws_clients": set()}
else:
clear_server_info(name, workbench_dir=self.workbench_dir)
async def _start_http_server(self, name: str) -> int:
port = _find_free_port()
pdir = project_path(name, self.workbench_dir)
app = web.Application()
app["project_name"] = name
app["workbench_server"] = self
async def ws_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
proj = request.app["project_name"]
if proj in self._active:
self._active[proj]["ws_clients"].add(ws)
try:
async for msg in ws:
pass
finally:
if proj in self._active:
self._active[proj]["ws_clients"].discard(ws)
return ws
async def static_handler(request):
proj = request.app["project_name"]
path = request.match_info.get("path", "index.html") or "index.html"
file_path = project_path(proj, self.workbench_dir) / path
if not file_path.exists():
return web.Response(status=404, text="Not found")
return web.FileResponse(file_path)
app.router.add_get("/ws", ws_handler)
app.router.add_get("/{path:.*}", static_handler)
app.router.add_get("/", static_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
self._runners[name] = runner
self._active[name] = {"port": port, "ws_clients": set()}
write_server_info(name, pid=os.getpid(), port=port, workbench_dir=self.workbench_dir)
return port
async def _broadcast_ws(self, name: str, message: dict) -> None:
if name not in self._active:
return
clients = self._active[name].get("ws_clients", set())
dead = set()
for ws in clients:
try:
await ws.send_json(message)
except Exception:
dead.add(ws)
clients -= dead
async def workbench_scaffold(self, name: str, title: str, description: str = "") -> str:
pdir = project_path(name, self.workbench_dir)
create_project(name, title, description, workbench_dir=self.workbench_dir)
info = read_server_info(name, workbench_dir=self.workbench_dir)
if info and self._is_server_alive(info["port"]):
port = info["port"]
if name not in self._active:
self._active[name] = {"port": port, "ws_clients": set()}
else:
if info:
clear_server_info(name, workbench_dir=self.workbench_dir)
port = await self._start_http_server(name)
if name not in self._active:
self._active[name] = {"port": port, "ws_clients": set()}
ip = _get_lan_ip()
log_session_event(name, "session_start", workbench_dir=self.workbench_dir)
return json.dumps({"path": str(pdir), "url": f"http://{ip}:{port}"})
async def workbench_state(self, project: str, state: str) -> str:
if not project_exists(project, workbench_dir=self.workbench_dir):
return json.dumps({"error": f"Project '{project}' not found. Run workbench_scaffold first."})
state_obj = json.loads(state)
pdir = project_path(project, self.workbench_dir)
(pdir / "state.json").write_text(json.dumps(state_obj, indent=2))
await self._broadcast_ws(project, {"type": "state", "state": state_obj})
return json.dumps({"ok": True})
async def workbench_log(self, project: str, entry: str, data: str = "{}") -> str:
if not project_exists(project, workbench_dir=self.workbench_dir):
return json.dumps({"error": f"Project '{project}' not found."})
data_obj = json.loads(data) if data and data != "{}" else None
append_log(project, entry, data=data_obj, workbench_dir=self.workbench_dir)
await self._broadcast_ws(project, {"type": "log", "entry": entry})
return json.dumps({"ok": True})
async def workbench_read_log(self, project: str, tail: int = 20) -> str:
if not project_exists(project, workbench_dir=self.workbench_dir):
return json.dumps({"error": f"Project '{project}' not found."})
entries = read_log(project, tail=tail, workbench_dir=self.workbench_dir)
return json.dumps({"entries": entries})
async def workbench_list(self) -> str:
projects = list_projects(workbench_dir=self.workbench_dir)
for p in projects:
p["active"] = p["name"] in self._active
if p["active"]:
ip = _get_lan_ip()
port = self._active[p["name"]]["port"]
p["url"] = f"http://{ip}:{port}"
return json.dumps({"projects": projects})
async def workbench_stop(self, project: str) -> str:
if project not in self._active:
return json.dumps({"error": f"Project '{project}' is not running."})
if project_exists(project, workbench_dir=self.workbench_dir):
entries = read_log(project, tail=999999, workbench_dir=self.workbench_dir)
log_session_event(project, "session_end", workbench_dir=self.workbench_dir, log_entries=len(entries))
if project in self._runners:
await self._runners[project].cleanup()
del self._runners[project]
clear_server_info(project, workbench_dir=self.workbench_dir)
del self._active[project]
return json.dumps({"ok": True})
def create_mcp_server(workbench_dir: Path = WORKBENCH_DIR) -> FastMCP:
srv = WorkbenchServer(workbench_dir=workbench_dir)
mcp = FastMCP("workbench", instructions="Workbench — build interactive web pages served over LAN. Call workbench_scaffold first.")
@mcp.tool()
async def workbench_scaffold(name: str, title: str, description: str = "") -> str:
"""Create a workbench project and start the HTTP server. Returns the LAN URL to open in a browser. If the project already exists and its server is running, reattaches without starting a duplicate. Always safe to call — never creates duplicates."""
return await srv.workbench_scaffold(name, title, description)
@mcp.tool()
async def workbench_state(project: str, state: str) -> str:
"""Push a state update to the browser via WebSocket. The state is a JSON string — include 'template' (HTML string) to replace the page content, 'styles' (CSS string) to inject styles, and 'script' (JS string) to execute code. The AI has full control over the page."""
return await srv.workbench_state(project, state)
@mcp.tool()
async def workbench_log(project: str, entry: str, data: str = "{}") -> str:
"""Append a log entry to the session log. Shows in the browser log feed. entry: human-readable markdown string. data: optional JSON for the machine-readable log."""
return await srv.workbench_log(project, entry, data)
@mcp.tool()
async def workbench_read_log(project: str, tail: int = 20) -> str:
"""Read recent session log entries so the AI can resume a previous session."""
return await srv.workbench_read_log(project, tail)
@mcp.tool()
async def workbench_list() -> str:
"""List all workbench projects and whether their HTTP server is running."""
return await srv.workbench_list()
@mcp.tool()
async def workbench_stop(project: str) -> str:
"""Stop the HTTP server for a project and end the session."""
return await srv.workbench_stop(project)
return mcp
+8
View File
@@ -0,0 +1,8 @@
import pytest
@pytest.fixture
def tmp_workbench(tmp_path):
"""Provide a temporary workbench directory."""
wb = tmp_path / "workbench"
wb.mkdir()
return wb
+81
View File
@@ -0,0 +1,81 @@
import json
from workbench.project import (
create_project, project_exists, project_path,
append_log, read_log, list_projects, log_session_event,
write_server_info, read_server_info, clear_server_info,
WORKBENCH_DIR,
)
def test_create_project(tmp_workbench):
path = create_project("io102", "Heathkit IO-102", workbench_dir=tmp_workbench)
assert path.exists()
assert (path / "index.html").exists()
assert (path / "session.md").exists()
assert (path / "session.jsonl").exists()
assert (path / "cost-log.jsonl").exists()
assert (path / "state.json").exists()
assert (path / "assets").is_dir()
md = (path / "session.md").read_text()
assert "Heathkit IO-102" in md
def test_create_project_idempotent(tmp_workbench):
create_project("io102", "First", workbench_dir=tmp_workbench)
append_log("io102", "test entry", workbench_dir=tmp_workbench)
create_project("io102", "Second", workbench_dir=tmp_workbench)
entries = read_log("io102", workbench_dir=tmp_workbench)
assert len(entries) == 1
def test_project_exists(tmp_workbench):
assert not project_exists("io102", workbench_dir=tmp_workbench)
create_project("io102", "Test", workbench_dir=tmp_workbench)
assert project_exists("io102", workbench_dir=tmp_workbench)
def test_append_and_read_log(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
append_log("io102", "R412 measured 1.05M", data={"ohms": 1050000}, workbench_dir=tmp_workbench)
append_log("io102", "R413 OK", workbench_dir=tmp_workbench)
entries = read_log("io102", tail=10, workbench_dir=tmp_workbench)
assert len(entries) == 2
assert entries[0]["entry"] == "R412 measured 1.05M"
assert entries[0]["ohms"] == 1050000
def test_read_log_tail(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
for i in range(30):
append_log("io102", f"Entry {i}", workbench_dir=tmp_workbench)
entries = read_log("io102", tail=5, workbench_dir=tmp_workbench)
assert len(entries) == 5
assert entries[0]["entry"] == "Entry 25"
def test_list_projects(tmp_workbench):
assert list_projects(workbench_dir=tmp_workbench) == []
create_project("io102", "First", workbench_dir=tmp_workbench)
create_project("psu-rebuild", "Second", workbench_dir=tmp_workbench)
projects = list_projects(workbench_dir=tmp_workbench)
assert [p["name"] for p in projects] == ["io102", "psu-rebuild"]
def test_log_session_event(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
log_session_event("io102", "session_start", workbench_dir=tmp_workbench)
cost_log = (tmp_workbench / "io102" / "cost-log.jsonl").read_text().strip()
entry = json.loads(cost_log.split("\n")[-1])
assert entry["event"] == "session_start"
def test_server_info_write_read_clear(tmp_workbench):
create_project("io102", "Test", workbench_dir=tmp_workbench)
write_server_info("io102", pid=12345, port=8070, workbench_dir=tmp_workbench)
info = read_server_info("io102", workbench_dir=tmp_workbench)
assert info is not None
assert info["pid"] == 12345
assert info["port"] == 8070
assert "started" in info
clear_server_info("io102", workbench_dir=tmp_workbench)
assert read_server_info("io102", workbench_dir=tmp_workbench) is None
+105
View File
@@ -0,0 +1,105 @@
import asyncio
import json
import os
from pathlib import Path
from unittest.mock import patch, AsyncMock, MagicMock
import pytest
from workbench.server import WorkbenchServer
@pytest.fixture
def server(tmp_workbench):
return WorkbenchServer(workbench_dir=tmp_workbench)
@pytest.mark.asyncio
async def test_scaffold_creates_project(server, tmp_workbench):
with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
result = await server.workbench_scaffold("test-proj", "Test Project")
data = json.loads(result)
assert "url" in data
assert (tmp_workbench / "test-proj" / "index.html").exists()
@pytest.mark.asyncio
async def test_scaffold_reattaches_to_running_server(server, tmp_workbench):
from workbench.project import create_project, write_server_info
create_project("test-proj", "Test", workbench_dir=tmp_workbench)
write_server_info("test-proj", pid=os.getpid(), port=9999, workbench_dir=tmp_workbench)
with patch.object(server, "_is_server_alive", return_value=True):
with patch.object(server, "_start_http_server", new_callable=AsyncMock) as mock_start:
result = await server.workbench_scaffold("test-proj", "Test")
mock_start.assert_not_called()
data = json.loads(result)
assert "9999" in data["url"]
@pytest.mark.asyncio
async def test_scaffold_replaces_dead_server(server, tmp_workbench):
from workbench.project import create_project, write_server_info
create_project("test-proj", "Test", workbench_dir=tmp_workbench)
write_server_info("test-proj", pid=99999, port=9999, workbench_dir=tmp_workbench)
with patch.object(server, "_is_server_alive", return_value=False):
with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
result = await server.workbench_scaffold("test-proj", "Test")
data = json.loads(result)
assert "8070" in data["url"]
@pytest.mark.asyncio
async def test_list_empty(server):
result = await server.workbench_list()
data = json.loads(result)
assert data["projects"] == []
@pytest.mark.asyncio
async def test_log_writes_to_disk(server, tmp_workbench):
with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
await server.workbench_scaffold("test-proj", "Test")
result = await server.workbench_log("test-proj", "R412 measured 1.05M")
data = json.loads(result)
assert data["ok"] is True
jsonl = (tmp_workbench / "test-proj" / "session.jsonl").read_text().strip()
assert "R412 measured 1.05M" in jsonl
@pytest.mark.asyncio
async def test_state_saves_to_disk(server, tmp_workbench):
with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
await server.workbench_scaffold("test-proj", "Test")
state = json.dumps({"template": "<h1>Hello</h1>"})
result = await server.workbench_state("test-proj", state)
data = json.loads(result)
assert data["ok"] is True
saved = json.loads((tmp_workbench / "test-proj" / "state.json").read_text())
assert saved["template"] == "<h1>Hello</h1>"
@pytest.mark.asyncio
async def test_stop_cleans_up(server, tmp_workbench):
with patch.object(server, "_start_http_server", new_callable=AsyncMock, return_value=8070):
await server.workbench_scaffold("test-proj", "Test")
server._runners["test-proj"] = AsyncMock()
result = await server.workbench_stop("test-proj")
data = json.loads(result)
assert data["ok"] is True
assert not (tmp_workbench / "test-proj" / ".server.json").exists()
@pytest.mark.asyncio
async def test_reconnect_on_startup(tmp_workbench):
from workbench.project import create_project, write_server_info
create_project("live-proj", "Live", workbench_dir=tmp_workbench)
write_server_info("live-proj", pid=os.getpid(), port=8070, workbench_dir=tmp_workbench)
create_project("dead-proj", "Dead", workbench_dir=tmp_workbench)
write_server_info("dead-proj", pid=99999, port=8071, workbench_dir=tmp_workbench)
srv = WorkbenchServer(workbench_dir=tmp_workbench)
with patch.object(srv, "_is_server_alive", side_effect=lambda port: port == 8070):
await srv.reconnect_existing_servers()
assert "live-proj" in srv._active
assert srv._active["live-proj"]["port"] == 8070
assert "dead-proj" not in srv._active
assert not (tmp_workbench / "dead-proj" / ".server.json").exists()