feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat

Merges workspace.html + ralph.html into a single unified project hub with:
- Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict)
- 9-state project badge system (running-ralph/manual, planning, approved,
  pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix
- SSE realtime + polling fallback, version-based optimistic concurrency (If-Match)
- Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume)
- Propose modal (Variant B: inline Plan-with-Echo checkbox)
- 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar)
- Inter font self-hosted + shared tokens.css design system + DESIGN.md
- src/jsonlock.py (flock helper, sidecar .lock for stable inode)
- src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh)
- 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28
- No emoji anywhere (enforced by test_dashboard_no_emoji.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 07:26:19 +00:00
parent e771479d67
commit 5e930ade02
26 changed files with 5700 additions and 1569 deletions

147
src/jsonlock.py Normal file
View File

@@ -0,0 +1,147 @@
"""Shared flock-based JSON locking helper.
Lock ordering invariant: always acquire locks in alphabetical order by filename
to avoid deadlock when a caller holds multiple locks simultaneously.
Implementation note (2026-04 — Lane C2 fix):
We lock on a sidecar `<path>.lock` file rather than the data file itself.
That's because `write_locked` uses `os.replace(tmp, target)` for atomic
publish — but `replace` swaps the inode behind `target`, which means a flock
held on the *old* fd no longer guards the new file. Concurrent writers on a
sidecar lockfile (whose inode is stable) get correct serialisation across
threads and processes.
"""
import errno
import fcntl
import json
import logging
import os
import threading
import time
from typing import Callable
_TIMEOUT_SEC = 5.0
_POLL_INTERVAL = 0.05
_log = logging.getLogger(__name__)
class LockTimeoutError(Exception):
pass
_local = threading.local()
def _held_locks() -> dict:
"""Per-thread map: abspath → (lockfd, refcount). Used for re-entrancy."""
if not hasattr(_local, 'locks'):
_local.locks = {}
return _local.locks
def _try_lock(fd: int, lock_type: int, timeout: float) -> bool:
deadline = time.monotonic() + timeout
while True:
try:
fcntl.flock(fd, lock_type | fcntl.LOCK_NB)
return True
except BlockingIOError:
if time.monotonic() >= deadline:
return False
time.sleep(_POLL_INTERVAL)
def _acquire(fd: int, lock_type: int) -> None:
if _try_lock(fd, lock_type, _TIMEOUT_SEC):
return
if _try_lock(fd, lock_type, _TIMEOUT_SEC):
return
raise LockTimeoutError(
f"could not acquire flock within {2 * _TIMEOUT_SEC}s (after retry)"
)
def _open_lockfile(abspath: str) -> int:
"""Open (creating if needed) the sidecar `<abspath>.lock` file."""
lock_path = abspath + ".lock"
# Ensure the parent dir exists — write_locked auto-creates the data file
# too, so we should be tolerant of the parent dir not having ever been
# touched.
parent = os.path.dirname(lock_path)
if parent:
try:
os.makedirs(parent, exist_ok=True)
except OSError as exc:
if exc.errno != errno.EEXIST:
raise
return os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o644)
def read_locked(path: str) -> dict:
abspath = os.path.abspath(path)
held = _held_locks()
if abspath in held:
_log.debug("re-entrant read on %s; skipping flock", abspath)
with open(abspath, 'r', encoding='utf-8') as f:
return json.load(f)
lock_fd = _open_lockfile(abspath)
try:
_acquire(lock_fd, fcntl.LOCK_SH)
held[abspath] = (lock_fd, 1)
try:
with open(abspath, 'r', encoding='utf-8') as f:
return json.load(f)
finally:
held.pop(abspath, None)
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except OSError:
pass
finally:
os.close(lock_fd)
def write_locked(path: str, mutator: Callable[[dict], dict]) -> dict:
abspath = os.path.abspath(path)
held = _held_locks()
reentrant = abspath in held
lock_fd = -1 if reentrant else _open_lockfile(abspath)
try:
if not reentrant:
_acquire(lock_fd, fcntl.LOCK_EX)
held[abspath] = (lock_fd, 1)
else:
_log.debug("re-entrant write on %s; skipping flock", abspath)
try:
# Read current data (file may not exist yet — treat as {}).
try:
with open(abspath, 'r', encoding='utf-8') as f:
text = f.read()
data = json.loads(text) if text.strip() else {}
except FileNotFoundError:
data = {}
new_data = mutator(data)
# Atomic-rename invariant: tmp file MUST be on the same filesystem
# as the target (sibling path guarantees this).
tmp_path = abspath + ".tmp"
with open(tmp_path, 'w', encoding='utf-8') as tmp:
json.dump(new_data, tmp, indent=2)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp_path, abspath)
return new_data
finally:
if not reentrant:
held.pop(abspath, None)
try:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
except OSError:
pass
finally:
if not reentrant and lock_fd >= 0:
os.close(lock_fd)