feat: project management module — create, log, read, list, server persistence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-03-30 07:30:24 -04:00
parent 918261c3a5
commit 16815ed6bb
3 changed files with 245 additions and 0 deletions
+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()
+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