Compare commits
8 Commits
98a61a8320
...
c97adfc561
| Author | SHA1 | Date | |
|---|---|---|---|
| c97adfc561 | |||
| 0c2c7def09 | |||
| 4f34684e53 | |||
| 16815ed6bb | |||
| 918261c3a5 | |||
| 9755828ae4 | |||
| 28dc693781 | |||
| 7eebc83752 |
+193
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,116 +1,127 @@
|
|||||||
# Workbench
|
# 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.
|
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.
|
||||||
|
|
||||||
## What It Does
|
|
||||||
|
|
||||||
```
|
```
|
||||||
+------------------------------------------+
|
┌─────────────────────┐ ┌──────────────────────────────┐
|
||||||
| Phone Browser (at workbench) |
|
│ AI CLI │ │ Browser │
|
||||||
| +-------------------+----------------+ |
|
│ │ │ │
|
||||||
| | Diagnostic Page | Terminal Panel | |
|
│ > diagnose the HV │ HTTP │ ┌────────────────────────┐ │
|
||||||
| | - Schematic | (ttyd/tmux) | |
|
│ focus circuit │ + WS │ │ AI-generated content │ │
|
||||||
| | - Checklist | | |
|
│ ├────────►│ │ schematics, tables, │ │
|
||||||
| | - Measurements | AI session | |
|
│ AI pushes HTML/CSS/ │ │ │ checklists, dashboards│ │
|
||||||
| | - Log feed | runs here | |
|
│ 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
|
## 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+
|
- Python 3.10+
|
||||||
- `mcp` and `aiohttp` packages
|
- A web browser
|
||||||
|
- Any MCP-compatible AI CLI
|
||||||
|
|
||||||
```bash
|
## Platform Support
|
||||||
pip install mcp aiohttp
|
|
||||||
```
|
|
||||||
|
|
||||||
### Claude Code
|
| Platform | Status |
|
||||||
|
|----------|--------|
|
||||||
|
| Linux | Full support |
|
||||||
|
| macOS | Full support |
|
||||||
|
| Windows | Via WSL2 |
|
||||||
|
|
||||||
Add to `~/.claude/settings.json`:
|
## FAQ
|
||||||
|
|
||||||
```json
|
**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.
|
||||||
"mcpServers": {
|
|
||||||
"workbench": {
|
|
||||||
"command": "/path/to/workbench",
|
|
||||||
"args": ["mcp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
**What AI CLIs work with this?**
|
||||||
python3 server.py # runs on stdio transport
|
Any that supports the [Model Context Protocol](https://modelcontextprotocol.io) (MCP) over stdio transport.
|
||||||
```
|
|
||||||
|
|
||||||
## CLI
|
**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.
|
||||||
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.
|
|
||||||
|
|
||||||
## License
|
## 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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
mcp>=1.26.0
|
|
||||||
aiohttp>=3.9.0
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Workbench — MCP server for AI-driven interactive web pages."""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Allow running as `python -m workbench`."""
|
||||||
|
|
||||||
|
from workbench.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user