From 9d98e2c55a0fd2f6ae4d1455fee0296371e96be7 Mon Sep 17 00:00:00 2001 From: istos Date: Thu, 30 Jul 2026 09:47:38 +0200 Subject: [PATCH] sync: origin/main as the shared board, behind BOARD_SYNC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team mode's second half. A board-made move already commits itself (18); now that commit publishes, every board pulls on a beat, and a card two boards move at once resolves the way git resolves everything else — the push race is the concurrency control. - core/sync.py: push is event-driven (a new state.COMMIT_HOOKS registry fires it from taskfiles, so taskfiles stays left of everything that reacts to it); pull is a beat that fast-forwards, or replays this board's own commits on top when the two diverged. A replay that conflicts on a task file drops the local move — origin is the linearizer — and toasts who took the card. - The piggyback guard stands in front of every push and every replay: each local-ahead commit on main must be `board: `-prefixed, so a human's unpushed work is never published as a side effect of a card moving. Uncommitted changes, a checkout off main and an unreachable origin all stall or degrade rather than risk anything, each narrated once instead of once per beat. - watch.py names the commit author instead of "disk" for moves a pull brought, via the arrivals sync files for it; its narration moved out of the loop into narrate() so it can be tested directly. - The board grows a sync chip that appears only when sync stops converging, and the SSE stream grows a toast type so the server can say something to the person, not just to the ticker. Gate off (the default) means no fetch, no push, no thread, no change. Verified with tests/test_boards_sync.py: two real clones of a real bare upstream race each other through every case above. --- manager/core/board.html | 30 +- manager/core/board.py | 7 + manager/core/config.py | 9 +- manager/core/httpd.py | 2 + manager/core/state.py | 14 + manager/core/sync.py | 448 ++++++++++++++++++++++++++++++ manager/core/taskfiles.py | 1 + manager/core/watch.py | 59 ++-- tests/test_boards_sync.py | 565 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 1112 insertions(+), 23 deletions(-) create mode 100644 manager/core/sync.py create mode 100644 tests/test_boards_sync.py diff --git a/manager/core/board.html b/manager/core/board.html index 7cbe7e5..ddcbc9e 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -86,6 +86,9 @@ background:var(--surface);border:1px solid var(--border);border-radius:99px;font-size:12.5px; } .livechip .mono{font-size:11.5px;color:var(--dim)} + /* an author display beats the UA's [hidden] — say it here or the sync + chip is never hidden */ + .livechip[hidden]{display:none} .dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none} .dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite} @@ -480,6 +483,7 @@ +
@@ -562,10 +566,10 @@ const STAGE_TINT = { backlog: 'var(--dim)', 'to-do': 'var(--muted)', const STAGE_NOTE = { 'to-do': 'next up', review: 'your move' }; const GLYPHS = { session: '●', end: '○', idle: '…', edit: '✎', read: '◔', search: '⌕', command: '$', test: '▶', check: '☑', git: '⎇', plan: '≡', subagent: '⑂', web: '∿', - move: '⇢', new: '+', agent: '⚑', report: '▣', other: '·' }; + move: '⇢', new: '+', agent: '⚑', report: '▣', sync: '⇅', other: '·' }; const FILTERS = [ ['all', 'All', null], - ['moves', 'Moves', new Set(['move', 'new', 'agent'])], + ['moves', 'Moves', new Set(['move', 'new', 'agent', 'sync'])], ['edits', 'Edits', new Set(['edit'])], ['reads', 'Reads', new Set(['read', 'search'])], ['tests', 'Tests', new Set(['test', 'check'])], @@ -702,6 +706,10 @@ function connectStream() { scheduleRender(); } else if (msg.type === 'board' || msg.type === 'agents') { loadState(); + } else if (msg.type === 'toast') { + // the server needs to say something to the person, not just the + // ticker — losing a card to another board is the case that matters + toast(msg.message, !!msg.error); } else if (msg.type === 'board_event') { S.state?.boardEvents.push(msg.event); scheduleRender(); @@ -741,6 +749,23 @@ function renderChip() { } } +/* Sync only shows itself when it has stopped converging: a stall that is + not visible is two halves of a team quietly drifting apart. Offline is + driftwood (degraded, self-healing); anything waiting on a human is + terracotta. */ +function renderSync() { + const s = S.state?.sync; + const el = $('#syncchip'); + if (!s || !s.enabled || s.state === 'ok') { el.hidden = true; return; } + const alarm = s.state === 'stalled'; + const detail = s.detail || ''; + el.hidden = false; + el.title = detail; + el.innerHTML = `` + + `sync ${alarm ? 'stalled' : 'behind'}` + + `${esc(detail.split(' — ')[0].replace(/^sync[^:]*:\s*/, ''))}`; +} + function setView(view) { S.view = view; document.querySelectorAll('#views button').forEach(b => b.classList.toggle('on', b.dataset.view === view)); @@ -762,6 +787,7 @@ function render() { if (!S.state) return; renderTitle(); renderChip(); + renderSync(); if (S.view === 'board') renderBoard(); else if (S.view === 'flight') renderFlight(); else renderFocus(); diff --git a/manager/core/board.py b/manager/core/board.py index eae7286..556ec46 100755 --- a/manager/core/board.py +++ b/manager/core/board.py @@ -12,6 +12,7 @@ task files, but the tasks work as a plain folder kanban without it. See state.py shared registries, event persistence, SSE fan-out taskfiles.py reading/moving task files (the only code touching tasks/) events.py hook payloads → displayable events, session registry + sync.py origin/main as the shared board: push on move, pull on a beat agents.py headless work/review agents: launch, reap, stop, diff watch.py 2s disk poller narrating moves made outside the API httpd.py HTTP routes, SSE stream, the page itself @@ -34,6 +35,7 @@ import events import github import httpd import state +import sync import watch @@ -64,6 +66,11 @@ def main() -> None: threading.Thread(target=watch.watcher, daemon=True).start() threading.Thread(target=github.poller, daemon=True).start() threading.Thread(target=github.reconcile, daemon=True).start() + if config.SYNC: + # Team mode: board commits publish themselves and a beat pulls what + # the other boards published. Off, neither thread nor hook exists. + sync.install() + threading.Thread(target=sync.beat, daemon=True).start() drive.adopt() print(f"Task board for {config.TASKS}\n {url}\n Ctrl-C to stop") diff --git a/manager/core/config.py b/manager/core/config.py index 4eff8f2..d0bf41a 100644 --- a/manager/core/config.py +++ b/manager/core/config.py @@ -134,12 +134,19 @@ PR_POLL_INTERVAL = float(setting("BOARD_PR_POLL_INTERVAL", "60")) # network weather; this bounds the whole delay. FETCH_TIMEOUT = float(setting("BOARD_FETCH_TIMEOUT", "10")) +# Team mode's second half: origin/main is the shared truth and every board +# a converging replica — board commits push as they are made, a beat pulls +# what other boards published. Off by default; on, it implies COMMIT_MOVES +# below, because a move that never commits has nothing to publish. +SYNC = flag("BOARD_SYNC") +SYNC_INTERVAL = float(setting("BOARD_SYNC_INTERVAL", "30")) + # Team mode's first half: a board-made move claims the card (writing # **Assignee:** from git's own user.name) and commits itself, so ownership # and stage travel with the file to every clone. Off by default — a # single-player board moves cards exactly as it always did, and committing # tasks/ stays a hand job. -COMMIT_MOVES = flag("BOARD_COMMIT_MOVES") +COMMIT_MOVES = flag("BOARD_COMMIT_MOVES") or SYNC WATCH_INTERVAL = float(setting("BOARD_WATCH_INTERVAL", "2")) EVENTS_CAP = int(setting("BOARD_EVENTS_CAP", "800")) diff --git a/manager/core/httpd.py b/manager/core/httpd.py index 817e8ac..56dd2b5 100644 --- a/manager/core/httpd.py +++ b/manager/core/httpd.py @@ -18,6 +18,7 @@ import drive import events import github import state +import sync import taskfiles @@ -40,6 +41,7 @@ def state_payload() -> dict: "commandRuns": commands.public(), "checks": config.checks(), "archivedCount": taskfiles.archived_count(), + "sync": sync.status(), "boardEvents": board_events, "now": time.time(), } diff --git a/manager/core/state.py b/manager/core/state.py index d387f43..ef4171e 100644 --- a/manager/core/state.py +++ b/manager/core/state.py @@ -21,6 +21,7 @@ EVENTS: dict[str, list[dict]] = {} # session_id -> slim events BOARD_EVENTS: list[dict] = [] # moves + agent lifecycle AGENTS: dict[str, dict] = {} # agent_id -> launch record EXPECTED_MOVES: dict[tuple[str, str], tuple[str, float]] = {} # (file, to) -> (actor, ts) +COMMIT_HOOKS: list = [] # run after a board-made task commit # The port actually being served; board.py sets it from --port at startup so # launched agents know where to report events. @@ -59,6 +60,19 @@ def record_board_event(event: dict) -> None: broadcast({"type": "board_event", "event": event}) +def task_committed(filename: str) -> None: + """A board-made move committed itself. Registered hooks turn that into + whatever else should follow — sync.py's push, when the gate is on. The + hook is a registry rather than an import so taskfiles stays to the left + of everything that reacts to it; a hook that raises must never break a + move that has already happened on disk.""" + for hook in list(COMMIT_HOOKS): + try: + hook(filename) + except Exception: # noqa: BLE001 — the move is done; nothing may undo it + pass + + def expect_move(filename: str, target: str, actor: str) -> None: """Tell the watcher who is about to move a file so it can attribute it.""" with LOCK: diff --git a/manager/core/sync.py b/manager/core/sync.py new file mode 100644 index 0000000..587937f --- /dev/null +++ b/manager/core/sync.py @@ -0,0 +1,448 @@ +"""Boards converge through origin/main: push what this board commits, pull +what the other boards published. + +Gated on `BOARD_SYNC` (which implies `BOARD_COMMIT_MOVES` — a move that +never commits has nothing to publish). Off, nothing here runs: no fetch, +no push, no thread. + +The shape of it: + +- **push** is event-driven. `taskfiles` fires `state.task_committed` after + a board-made move commits; the hook installed here publishes it. A + rejected push means another board got there first, so the whole converge + runs and pushes again. +- **pull** is a beat: fetch, then integrate. Purely behind → fast-forward. + Diverged → the board's own commits are rebased on top, never merged + past; a rebase that conflicts on a task file means the local move lost + the race, and it is dropped with a toast naming who took the card. +- **the piggyback guard** stands in front of every push: each local-ahead + commit on main must be `board: `-prefixed. A human's unpushed work is + never published as a side effect of a card moving. +- **offline** is not an error. The first unreachable fetch says so once, + the rest are silent, commits queue on local main and the next reachable + fetch catches up. + +Git is the lock server and main the linearizer — that is the whole +concurrency control. Nothing here reacts to synced state beyond narrating +it: replicas render, they do not act. +""" + +from __future__ import annotations + +import subprocess +import threading +import time +from pathlib import Path + +import config +import state +from taskfiles import NUMBER_RE + +REMOTE = "origin" # one remote, one branch — by design +BRANCH = "main" +UPSTREAM = f"{REMOTE}/{BRANCH}" +BOARD_COMMIT = "board: " # the prefix taskfiles messages its own commits with +PUSH_TIMEOUT = 120 +REBASE_TIMEOUT = 120 +ARRIVED_TTL = 60.0 # the watcher polls every 2s; this is generous + +_LOCK = threading.Lock() # one git operation on the checkout at a time +_ARRIVED_LOCK = threading.Lock() +ARRIVED: dict[str, tuple[str, float]] = {} # filename -> (author, ts) from the last pull +_NOTES: dict[str, tuple[str, str]] = {} # key -> (summary, level) already narrated + + +def _git(*args: str, timeout: float = 30) -> subprocess.CompletedProcess: + """Never raises: a timeout or a missing binary is just a failed run.""" + try: + return subprocess.run(["git", "-C", str(config.REPO), *args], + capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(args, 128, "", "timed out") + except OSError as exc: + return subprocess.CompletedProcess(args, 128, "", str(exc)) + + +# ── narration ────────────────────────────────────────────────────────── +# Every condition here repeats on every beat, so each one is narrated once +# and then held: the ticker says it, the header chip keeps saying it. + + +def status() -> dict: + """What the header shows: ok while converging, otherwise the reason.""" + if not config.SYNC: + return {"enabled": False, "state": "off", "detail": ""} + for level in ("offline", "stalled"): + for summary, note_level in _NOTES.values(): + if note_level == level: + return {"enabled": True, "state": level, "detail": summary} + return {"enabled": True, "state": "ok", "detail": ""} + + +def _note(key: str, summary: str, level: str = "stalled") -> None: + if _NOTES.get(key) == (summary, level): + return # same condition as last time: said once is enough + _NOTES[key] = (summary, level) + state.record_board_event({"kind": "sync", "actor": "sync", "summary": summary}) + state.broadcast({"type": "board"}) + + +def _clear(key: str, recovery: str = "") -> None: + if _NOTES.pop(key, None) is None: + return + if recovery: + state.record_board_event({"kind": "sync", "actor": "sync", "summary": recovery}) + state.broadcast({"type": "board"}) + + +# ── the checkout ─────────────────────────────────────────────────────── + + +def _origin_present() -> bool: + return REMOTE in _git("remote").stdout.split() + + +def _head() -> str: + return _git("rev-parse", "HEAD").stdout.strip() + + +def _on_main() -> bool: + return _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() == BRANCH + + +def _clean() -> bool: + """Tracked files only: an untracked scratch file is nobody's business, + but a modified one is what a fast-forward would run over.""" + return not _git("status", "--porcelain", "--untracked-files=no").stdout.strip() + + +def _count(rng: str) -> int: + out = _git("rev-list", "--count", rng).stdout.strip() + return int(out) if out.isdigit() else 0 + + +def _tasks_prefix() -> str: + try: + return config.TASKS.resolve().relative_to(config.REPO.resolve()).as_posix() + "/" + except ValueError: + return "tasks/" + + +def _fetch() -> bool: + result = _git("fetch", REMOTE, BRANCH, timeout=config.FETCH_TIMEOUT) + if result.returncode != 0: + if "couldn't find remote ref" in (result.stderr or "").lower(): + _note("no-branch", f"sync stalled: {REMOTE} has no {BRANCH} branch — " + f"sync rides {UPSTREAM} and nothing else") + return False + _note("offline", + f"sync is behind: {REMOTE} is unreachable — this board keeps " + f"working locally and catches up when it returns", "offline") + return False + _clear("no-branch") + _clear("offline", f"sync caught up: {REMOTE} is reachable again") + return True + + +# ── publishing ───────────────────────────────────────────────────────── + + +def _ahead() -> list[str]: + """` ` for every commit local main has and + origin/main does not — newest first.""" + out = _git("log", "--format=%h %s", f"{UPSTREAM}..{BRANCH}").stdout + return [line for line in out.splitlines() if line.strip()] + + +def _stray(commits: list[str]) -> str: + """The piggyback hazard: pushing publishes *every* local-ahead commit, + so one that the board did not make is a human's private work and stops + the push. Oldest first — that is the one to deal with.""" + for line in reversed(commits): + subject = line.split(" ", 1)[1] if " " in line else "" + if not subject.startswith(BOARD_COMMIT): + return line + return "" + + +def _publish() -> str: + """Push local main if — and only if — everything on it is the board's. + + ok | nothing | stray | not-on-main | retry | offline | stalled + """ + if not _on_main(): + branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD" + _note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — " + f"board commits are not landing where sync publishes from") + return "not-on-main" + _clear("branch") + if _git("rev-parse", "--verify", "--quiet", UPSTREAM).returncode != 0: + return "retry" # never fetched: converge first, then publish + commits = _ahead() + stray = _stray(commits) + if stray: + _note("stray", f"not pushing: {stray} is not a board commit — sync will not " + f"publish it for you. Push main yourself, or move that commit " + f"off main, and sync resumes") + return "stray" + _clear("stray") # nothing stray left to refuse, however that happened + if not commits: + return "nothing" + + result = _git("push", REMOTE, f"{BRANCH}:{BRANCH}", timeout=PUSH_TIMEOUT) + if result.returncode == 0: + _clear("push") + _clear("offline", f"sync caught up: {REMOTE} is reachable again") + state.record_board_event({ + "kind": "sync", "actor": "sync", + "summary": f"pushed {len(commits)} board commit" + f"{'s' if len(commits) > 1 else ''} to {UPSTREAM}"}) + return "ok" + stderr = (result.stderr or result.stdout).strip() + if _rejected(stderr): + return "retry" + if _unreachable(stderr): + _note("offline", + f"sync is behind: {REMOTE} is unreachable — this board keeps " + f"working locally and catches up when it returns", "offline") + return "offline" + detail = stderr.splitlines()[-1][:140] if stderr else "git said nothing" + _note("push", f"sync could not push to {UPSTREAM}: {detail}") + return "stalled" + + +def _rejected(stderr: str) -> bool: + text = stderr.lower() + return "non-fast-forward" in text or "fetch first" in text or "! [rejected]" in text + + +def _unreachable(stderr: str) -> bool: + text = stderr.lower() + return any(mark in text for mark in ( + "could not read from remote", "could not resolve", "unable to access", + "does not appear to be a git repository", "connection", "timed out", + "no such file or directory", "permission denied")) + + +# ── integrating what arrived ─────────────────────────────────────────── + + +def _conflicted() -> list[str]: + out = _git("diff", "--name-only", "--diff-filter=U").stdout + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _is_task_file(path: str) -> bool: + return path.startswith(_tasks_prefix()) and path.endswith(".md") + + +def _author_of(filename: str, rev: str) -> str: + """Who wrote the newest commit touching this card in `rev` — a range for + what a pull brought, a ref for what origin already holds.""" + return _git("log", "-1", "--format=%an", rev, "--", + f"{_tasks_prefix()}*/{filename}").stdout.strip() + + +def _number(filename: str) -> str: + match = NUMBER_RE.match(filename) + return match.group(1) if match else filename[:-3] if filename.endswith(".md") else filename + + +def _lost(filename: str) -> None: + """The local move lost the race. Say who took the card — the file itself + reverts to origin's version when the rebase drops our commit.""" + who = _author_of(filename, UPSTREAM) or "someone else" + message = f"{_number(filename)} claimed by {who} — your move was undone" + state.record_board_event({"kind": "sync", "actor": "sync", "file": filename, + "summary": message}) + state.broadcast({"type": "toast", "message": message, "error": True}) + state.broadcast({"type": "board"}) + + +def _replay() -> str: + """Rebase this board's commits onto origin/main. Conflicts on a task + file are resolved by dropping our commit: origin is the linearizer, and + a card someone else moved first is theirs. Anything conflicting outside + tasks/ is a real collision — abort and wait for a human. + + ok | dirty | stalled + """ + if not _clean(): + _note("dirty", "sync paused: main has uncommitted changes — commit or stash " + "them and sync resumes (code work belongs in a worktree)") + return "dirty" + _clear("dirty") + + result = _git("rebase", UPSTREAM, timeout=REBASE_TIMEOUT) + for _ in range(50): # bounded: one round per replayed commit + if result.returncode == 0: + _clear("replay") + return "ok" + conflicted = _conflicted() + if not conflicted or not all(_is_task_file(p) for p in conflicted): + _git("rebase", "--abort") + detail = ", ".join(conflicted[:3]) or (result.stderr or result.stdout).strip()[-140:] + _note("replay", f"sync stalled: replaying this board's commits onto {UPSTREAM} " + f"collides outside tasks/ ({detail}) — a human has to settle it") + return "stalled" + for name in dict.fromkeys(Path(p).name for p in conflicted): + _lost(name) + result = _git("rebase", "--skip", timeout=REBASE_TIMEOUT) + _git("rebase", "--abort") + _note("replay", f"sync stalled: replaying onto {UPSTREAM} did not settle — " + f"a human has to settle it") + return "stalled" + + +def _integrate() -> str: + """Bring local main to origin/main without ever merging past a + divergence. + + up-to-date | pulled | not-on-main | dirty | diverged | stalled + """ + if _count(f"{BRANCH}..{UPSTREAM}") == 0: + return "up-to-date" + if not _on_main(): + branch = _git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "a detached HEAD" + _note("branch", f"sync paused: this checkout is on '{branch}', not {BRANCH} — " + f"switch back and the board catches up with {UPSTREAM}") + return "not-on-main" + _clear("branch") + + # Diverged. The board's own bookkeeping can be replayed on top of what + # arrived — that is how a lost race resolves. A human's commit cannot, + # and the guard that refuses to push it refuses to rebase it too. + commits = _ahead() + stray = _stray(commits) + if stray: + _note("diverged", f"sync stalled: main and {UPSTREAM} have diverged and " + f"{stray} is not a board commit — pull or rebase it by " + f"hand, and this board starts converging again") + return "diverged" + _clear("diverged") + if commits: + outcome = _replay() + return "pulled" if outcome == "ok" else outcome + + if not _clean(): + _note("dirty", "sync paused: main has uncommitted changes — commit or stash " + "them and sync resumes (code work belongs in a worktree)") + return "dirty" + _clear("dirty") + result = _git("merge", "--ff-only", UPSTREAM, timeout=REBASE_TIMEOUT) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip().splitlines() + _note("merge", f"sync stalled: fast-forwarding to {UPSTREAM} failed " + f"({detail[-1][:140] if detail else 'no detail'})") + return "stalled" + _clear("merge") + return "pulled" + + +def _record_arrivals(before: str) -> None: + """Attribute what the pull brought: each task file it touched is filed + under the name of whoever committed it, for the watcher to use instead + of "disk" when the move surfaces on the next poll.""" + head = _head() + if not before or not head or head == before: + return + rng = f"{before}..{head}" + changed = _git("diff", "--name-only", rng, "--", _tasks_prefix()).stdout.splitlines() + names = sorted({Path(p).name for p in changed if p.strip().endswith(".md")}) + if not names: + return + now = time.time() + authors = set() + with _ARRIVED_LOCK: + for name in names: + who = _author_of(name, rng) + if who: + ARRIVED[name] = (who, now) + authors.add(who) + for name in [n for n, (_, ts) in ARRIVED.items() if now - ts > ARRIVED_TTL]: + ARRIVED.pop(name, None) + count = _count(rng) + state.record_board_event({ + "kind": "sync", "actor": "sync", + "summary": f"pulled {count} commit{'s' if count != 1 else ''} from {UPSTREAM}" + + (f" ({', '.join(sorted(authors))})" if authors else "")}) + state.broadcast({"type": "board"}) + + +def arrived_actor(filename: str) -> str: + """Who moved this card, if a pull just brought it. Consumed once — the + watcher asks exactly when it notices the move.""" + with _ARRIVED_LOCK: + entry = ARRIVED.pop(filename, None) + if not entry: + return "" + who, ts = entry + return who if time.time() - ts <= ARRIVED_TTL else "" + + +# ── the two entry points ─────────────────────────────────────────────── + + +def _converge() -> str: + """One full beat: fetch, integrate what arrived, publish what is ours.""" + if not _origin_present(): + return "no-origin" + if not _fetch(): + return "offline" + before = _head() + outcome = _integrate() + _record_arrivals(before) + if outcome in ("up-to-date", "pulled"): + published = _publish() + if published in ("stray", "offline", "stalled", "not-on-main"): + return published + return outcome + + +def push_now() -> str: + """A board commit just landed — publish it. The fast path skips the + fetch; a rejection means another board pushed first, and then the full + converge (fetch, replay, push) runs.""" + if not config.SYNC: + return "off" + with _LOCK: + if not _origin_present(): + return "no-origin" + outcome = _publish() + if outcome != "retry": + return outcome + return _converge() + + +def pull_now() -> str: + """The beat. Also the offline catch-up: a fetch that works again is + followed by the push that could not happen while origin was gone.""" + if not config.SYNC: + return "off" + with _LOCK: + return _converge() + + +def on_commit(filename: str) -> None: + """The `state.task_committed` hook: publish off the caller's thread, so + a card move never waits on the network.""" + if not config.SYNC: + return + threading.Thread(target=push_now, name="sync-push", daemon=True).start() + + +def install() -> None: + """Wire the push hook. Called once at startup, only with the gate on.""" + if on_commit not in state.COMMIT_HOOKS: + state.COMMIT_HOOKS.append(on_commit) + + +def beat(interval: float | None = None) -> None: + interval = config.SYNC_INTERVAL if interval is None else interval + while True: + try: + pull_now() + except Exception as exc: # noqa: BLE001 — the beat outlives a bad cycle + state.record_board_event({"kind": "sync", "actor": "sync", + "summary": f"sync cycle failed: {str(exc)[:140]}"}) + time.sleep(interval) diff --git a/manager/core/taskfiles.py b/manager/core/taskfiles.py index 6ad70b2..3d42ce8 100644 --- a/manager/core/taskfiles.py +++ b/manager/core/taskfiles.py @@ -201,6 +201,7 @@ def _commit_move(filename: str, target: str, src: Path, dst: Path, who: str, if result.returncode == 0: result = _git("commit", "--no-verify", "-m", message, "--", *spec, timeout=60) if result.returncode == 0: + state.task_committed(filename) # sync (when on) publishes it return lines = (result.stderr or result.stdout).strip().splitlines() detail = lines[-1] if lines else f"git exited {result.returncode}" diff --git a/manager/core/watch.py b/manager/core/watch.py index 0195ac1..14a3680 100644 --- a/manager/core/watch.py +++ b/manager/core/watch.py @@ -1,7 +1,8 @@ """Disk watcher: the directories are the source of truth, so poll and narrate. -Catches moves the HTTP API never saw — a file dragged by hand, an agent, or -another tool — and attributes them via the expectations registered in state. +Catches moves the HTTP API never saw — a file dragged by hand, an agent, +another tool, or a pull from origin/main — and attributes them via the +expectations registered in state and the arrivals registered by sync. """ from __future__ import annotations @@ -11,6 +12,7 @@ import time import config import github import state +import sync def _board_sig() -> dict[str, set[str]]: @@ -21,6 +23,40 @@ def _board_sig() -> dict[str, set[str]]: return sig +def _actor(filename: str, stage: str) -> str: + """Who did this. A move this board made is claimed from the + expectations; one a pull brought carries its commit author's name; a + plain mv on this disk is nobody in particular.""" + actor = state.claim_expected(filename, stage) + if actor == "disk": + return sync.arrived_actor(filename) or "disk" + return actor + + +def narrate(prev: dict[str, set[str]], cur: dict[str, set[str]]) -> None: + """Two board signatures → the events between them.""" + prev_loc = {f: s for s, files in prev.items() for f in files} + cur_loc = {f: s for s, files in cur.items() for f in files} + for f, stage in sorted(cur_loc.items()): + if f in prev_loc and prev_loc[f] != stage: + actor = _actor(f, stage) + state.record_board_event({ + "kind": "move", "file": f, "from": prev_loc[f], "to": stage, + "actor": actor, + "summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})", + }) + if stage == "review": + # a card entering review with a work branch gets a PR + github.open_pr_async(f) + elif f not in prev_loc: + actor = _actor(f, stage) + state.record_board_event({ + "kind": "new", "file": f, "to": stage, "actor": actor, + "summary": f"{f} appeared in {stage}/" + + (f" ({actor})" if actor != "disk" else ""), + }) + + def watcher(interval: float | None = None) -> None: interval = config.WATCH_INTERVAL if interval is None else interval prev = _board_sig() @@ -32,23 +68,6 @@ def watcher(interval: float | None = None) -> None: continue if cur == prev: continue - prev_loc = {f: s for s, files in prev.items() for f in files} - cur_loc = {f: s for s, files in cur.items() for f in files} - for f, stage in sorted(cur_loc.items()): - if f in prev_loc and prev_loc[f] != stage: - actor = state.claim_expected(f, stage) - state.record_board_event({ - "kind": "move", "file": f, "from": prev_loc[f], "to": stage, - "actor": actor, - "summary": f"{f} moved {prev_loc[f]} → {stage} ({actor})", - }) - if stage == "review": - # a card entering review with a work branch gets a PR - github.open_pr_async(f) - elif f not in prev_loc: - state.record_board_event({ - "kind": "new", "file": f, "to": stage, "actor": "disk", - "summary": f"{f} appeared in {stage}/", - }) + narrate(prev, cur) prev = cur state.broadcast({"type": "board"}) diff --git a/tests/test_boards_sync.py b/tests/test_boards_sync.py new file mode 100644 index 0000000..1265b2f --- /dev/null +++ b/tests/test_boards_sync.py @@ -0,0 +1,565 @@ +"""Boards sync through origin/main (task 19): a move pushes, a beat pulls, +a lost race is a toast, and a human's unpushed commit is never published. + +Everything runs against real clones of a real bare upstream — the whole +point of the card is what git actually does under a race, so nothing here +is mocked except the SSE fan-out (captured, to read what the board said). + + python3 -m unittest discover -s tests -v +""" + +from __future__ import annotations + +import importlib +import os +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "manager" / "core")) + +import config # noqa: E402 +import state # noqa: E402 +import sync # noqa: E402 +import taskfiles # noqa: E402 +import watch # noqa: E402 + +FILENAME = "07-shared-card.md" +CARD = ("# 07 — A card two boards can reach\n\n" + "**Status:** Backlog\n" + "**Priority:** High\n" + "**Type:** Feature\n\n" + "Body text long enough that git sees a rename rather than a delete\n" + "and an add when the file moves between two stage directories, which\n" + "is what turns a same-card race into a conflict it can report.\n") + + +def git(cwd: Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(cwd), *args], + capture_output=True, text=True) + + +class TwoBoards(unittest.TestCase): + """One bare upstream, two clones — 'ada' and 'elena', each with its own + board. config.REPO/TASKS point at whichever board is acting.""" + + def setUp(self): + # resolve(): macOS tempdirs sit behind the /var → /private/var + # symlink and git reports the resolved path. + self.tmp = Path(tempfile.mkdtemp(prefix="bench-sync-")).resolve() + self.addCleanup(shutil.rmtree, self.tmp, True) + self.origin = self.tmp / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(self.origin)], + check=True, capture_output=True) + + self.ada = self._clone("ada") + for slug in config.STAGE_DIRS: + (self.ada / "tasks" / slug).mkdir(parents=True) + (self.ada / "tasks" / "backlog" / FILENAME).write_text(CARD, encoding="utf-8") + (self.ada / "code.txt").write_text("shipped\n", encoding="utf-8") + git(self.ada, "add", "-A") + git(self.ada, "commit", "-q", "-m", "root") + git(self.ada, "push", "-q", "origin", "main") + self.elena = self._clone("elena") + + # REPO/TASKS are patched here (not just assigned by use()) so the + # checkout under test is restored for every other test module. + self.patch(SYNC=True, COMMIT_MOVES=True, FETCH_TIMEOUT=10.0, + SESSIONS_DIR=self.tmp / "sessions", + REPO=self.ada, TASKS=self.ada / "tasks") + self.use(self.ada) + + state.BOARD_EVENTS.clear() + state.EXPECTED_MOVES.clear() + state.COMMIT_HOOKS.clear() + self.addCleanup(state.COMMIT_HOOKS.clear) + sync.ARRIVED.clear() + sync._NOTES.clear() + self.addCleanup(sync._NOTES.clear) + self.addCleanup(sync.ARRIVED.clear) + + self.broadcasts: list[dict] = [] + self.addCleanup(setattr, state, "broadcast", state.broadcast) + state.broadcast = self.broadcasts.append + + def _clone(self, who: str) -> Path: + path = self.tmp / who + subprocess.run(["git", "clone", "-q", str(self.origin), str(path)], + check=True, capture_output=True) + git(path, "config", "user.name", who) + git(path, "config", "user.email", f"{who}@example.com") + return path + + def patch(self, **values) -> None: + for attr, value in values.items(): + self.addCleanup(setattr, config, attr, getattr(config, attr)) + setattr(config, attr, value) + + # — acting as one board or the other — + + def use(self, board: Path) -> None: + config.REPO, config.TASKS = board, board / "tasks" + + def move(self, board: Path, source: str, target: str) -> None: + self.use(board) + taskfiles.move_task(FILENAME, source, target) + + def stage_of(self, board: Path) -> str | None: + for slug in config.STAGE_DIRS: + if (board / "tasks" / slug / FILENAME).is_file(): + return slug + return None + + def card(self, board: Path) -> str: + stage = self.stage_of(board) + return (board / "tasks" / stage / FILENAME).read_text(encoding="utf-8") + + def head(self, board: Path) -> str: + return git(board, "rev-parse", "HEAD").stdout.strip() + + def origin_head(self) -> str: + return git(self.origin, "rev-parse", "main").stdout.strip() + + def summaries(self) -> list[str]: + return [e["summary"] for e in state.BOARD_EVENTS] + + def toasts(self) -> list[str]: + return [b["message"] for b in self.broadcasts if b.get("type") == "toast"] + + def sig(self, board: Path) -> dict[str, set[str]]: + self.use(board) + return {slug: {p.name for p in (board / "tasks" / slug).glob("*.md")} + for slug in config.STAGE_DIRS + if (board / "tasks" / slug).is_dir()} + + # — a move reaching the other board — + + def test_a_move_pushes_and_the_other_board_pulls_it(self): + self.move(self.ada, "backlog", "to-do") + self.assertEqual(sync.push_now(), "ok") + + self.use(self.elena) + self.assertEqual(sync.pull_now(), "pulled") + + self.assertEqual(self.stage_of(self.elena), "to-do") + self.assertEqual(self.card(self.elena), self.card(self.ada), + "both boards hold the same bytes") + self.assertIn("**Assignee:** ada", self.card(self.elena)) + + def test_the_pull_attributes_the_move_to_its_author(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + + self.use(self.elena) + before = self.sig(self.elena) + sync.pull_now() + after = self.sig(self.elena) + + # Two boards are two processes: elena's has no memory of ada moving + # anything, so nothing but the pull can attribute this. + state.EXPECTED_MOVES.clear() + state.BOARD_EVENTS.clear() + watch.narrate(before, after) + moves = [e for e in state.BOARD_EVENTS if e["kind"] == "move"] + self.assertEqual(len(moves), 1) + self.assertEqual(moves[0]["actor"], "ada", + "a move that arrived over origin is not 'disk'") + self.assertEqual((moves[0]["from"], moves[0]["to"]), ("backlog", "to-do")) + + def test_attribution_is_consumed_once_and_expires(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + self.use(self.elena) + sync.pull_now() + + self.assertEqual(sync.arrived_actor(FILENAME), "ada") + self.assertEqual(sync.arrived_actor(FILENAME), "", + "the next hand-move on this disk is not ada's") + + def test_a_pull_that_brings_nothing_says_nothing(self): + self.use(self.elena) + state.BOARD_EVENTS.clear() + + self.assertEqual(sync.pull_now(), "up-to-date") + self.assertEqual(self.summaries(), []) + + def test_the_commit_hook_publishes_without_being_asked(self): + sync.install() + self.assertIn(sync.on_commit, state.COMMIT_HOOKS) + + before = self.origin_head() + self.move(self.ada, "backlog", "to-do") + for _ in range(100): # the push runs off-thread + if self.origin_head() != before: + break + time.sleep(0.05) + + self.assertEqual(self.origin_head(), self.head(self.ada), + "the move published itself") + + # — the same-card race — + + def test_losing_a_race_undoes_the_move_and_names_who_took_the_card(self): + self.move(self.ada, "backlog", "to-do") + self.assertEqual(sync.push_now(), "ok") + + # elena never saw ada's move: her board still shows the card in + # backlog/ and she moves it somewhere else entirely. + self.move(self.elena, "backlog", "in-progress") + self.assertEqual(sync.push_now(), "pulled") + + self.assertEqual(self.stage_of(self.elena), "to-do", + "the loser's board holds the winner's version") + self.assertEqual(self.card(self.elena), self.card(self.ada)) + self.assertIn("**Assignee:** ada", self.card(self.elena)) + self.assertEqual(self.origin_head(), self.head(self.elena)) + self.assertIn("07 claimed by ada — your move was undone", self.toasts()) + self.assertIn("07 claimed by ada — your move was undone", self.summaries()) + + def test_exactly_one_claim_survives_when_both_claim_the_same_card(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + self.move(self.elena, "backlog", "to-do") # same target, other name + sync.push_now() + + self.use(self.ada) + sync.pull_now() + + self.assertEqual(self.card(self.ada), self.card(self.elena), + "both boards converge on the same file bytes") + self.assertEqual(self.card(self.elena).count("**Assignee:**"), 1) + self.assertIn("**Assignee:** ada", self.card(self.elena)) + self.assertEqual(self.origin_head(), self.head(self.ada)) + self.assertEqual(self.origin_head(), self.head(self.elena)) + + def test_the_winner_keeps_moving_after_the_loser_gave_way(self): + """Convergence is not a dead end: the board that lost re-reads the + card and can move it on, and that move publishes normally.""" + self.move(self.ada, "backlog", "to-do") + sync.push_now() + self.move(self.elena, "backlog", "in-progress") + sync.push_now() + + self.move(self.elena, "to-do", "in-progress") + self.assertEqual(sync.push_now(), "ok") + + self.use(self.ada) + sync.pull_now() + self.assertEqual(self.stage_of(self.ada), "in-progress") + + def test_a_race_on_two_different_cards_keeps_both_moves(self): + other = "08-another-card.md" + (self.ada / "tasks" / "backlog" / other).write_text( + CARD.replace("# 07", "# 08"), encoding="utf-8") + git(self.ada, "add", "-A") + git(self.ada, "commit", "-q", "-m", "board: 08 → backlog (ada)") + git(self.ada, "push", "-q", "origin", "main") + self.use(self.elena) + sync.pull_now() + + self.move(self.ada, "backlog", "to-do") + sync.push_now() + self.use(self.elena) + taskfiles.move_task(other, "backlog", "to-do") + + self.assertEqual(sync.push_now(), "pulled") + self.assertTrue((self.elena / "tasks" / "to-do" / other).is_file(), + "elena's own move survives a rebase it does not collide with") + self.assertTrue((self.elena / "tasks" / "to-do" / FILENAME).is_file()) + self.assertEqual(self.toasts(), [], "nothing was undone") + + def test_a_drag_started_before_the_card_moved_underneath_is_refused(self): + """The mid-drag race. The browser sends the stage it picked the card + up from, so a move that arrived meanwhile makes the drop stale — and + a stale drop must fail, not resurrect the card in two places.""" + self.move(self.ada, "backlog", "to-do") + sync.push_now() + self.use(self.elena) + sync.pull_now() + + with self.assertRaises(ValueError) as caught: + taskfiles.move_task(FILENAME, "backlog", "in-progress") + + self.assertIn("no longer in backlog/", str(caught.exception)) + self.assertEqual(self.stage_of(self.elena), "to-do") + self.assertFalse((self.elena / "tasks" / "in-progress" / FILENAME).exists()) + + # — the piggyback guard — + + def test_a_human_commit_on_main_stops_the_push(self): + self.use(self.ada) + (self.ada / "code.txt").write_text("my unpushed experiment\n", encoding="utf-8") + git(self.ada, "commit", "-qam", "wip: not ready for anyone else") + before = self.origin_head() + + self.move(self.ada, "backlog", "to-do") + + self.assertEqual(sync.push_now(), "stray") + self.assertEqual(self.origin_head(), before, "origin never saw it") + self.assertNotIn("wip: not ready for anyone else", + git(self.origin, "log", "--format=%s", "main").stdout) + warnings = [s for s in self.summaries() if "not a board commit" in s] + self.assertEqual(len(warnings), 1) + self.assertIn("wip: not ready for anyone else", warnings[0]) + self.assertEqual(sync.status()["state"], "stalled") + + def test_the_guard_warns_once_however_often_the_beat_runs(self): + self.use(self.ada) + git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine") + self.move(self.ada, "backlog", "to-do") + + for _ in range(4): + sync.push_now() + sync.pull_now() + + self.assertEqual(len([s for s in self.summaries() if "not a board commit" in s]), 1) + + def test_a_human_commit_also_blocks_the_rebase_and_says_so(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + + self.use(self.elena) + (self.elena / "code.txt").write_text("elena's experiment\n", encoding="utf-8") + git(self.elena, "commit", "-qam", "wip: elena's own work") + before = self.head(self.elena) + + self.assertEqual(sync.pull_now(), "diverged") + self.assertEqual(self.head(self.elena), before, + "nothing was rebased over the human's commit") + self.assertEqual(self.stage_of(self.elena), "backlog") + self.assertTrue(any("diverged" in s for s in self.summaries())) + self.assertEqual(sync.status()["state"], "stalled") + + def test_the_guard_stands_down_once_the_human_commit_is_gone(self): + self.use(self.ada) + git(self.ada, "commit", "-q", "--allow-empty", "-m", "wip: mine") + self.move(self.ada, "backlog", "to-do") + self.assertEqual(sync.push_now(), "stray") + + git(self.ada, "push", "-q", "origin", "main") # the human pushes it themselves + self.assertEqual(sync.push_now(), "nothing") + self.assertEqual(sync.status()["state"], "ok") + + # — offline — + + def test_an_unreachable_origin_is_quiet_and_catches_up(self): + self.use(self.ada) + git(self.ada, "remote", "set-url", "origin", str(self.tmp / "gone.git")) + self.move(self.ada, "backlog", "to-do") + + self.assertEqual(sync.push_now(), "offline") + for _ in range(3): + self.assertEqual(sync.pull_now(), "offline") + self.assertEqual(len([s for s in self.summaries() if "unreachable" in s]), 1, + "one quiet note, not one per beat") + self.assertEqual(sync.status()["state"], "offline") + self.assertEqual(self.stage_of(self.ada), "to-do", + "the board kept working while origin was gone") + + git(self.ada, "remote", "set-url", "origin", str(self.origin)) + self.assertEqual(sync.pull_now(), "up-to-date") + + self.assertEqual(self.origin_head(), self.head(self.ada), + "the queued commit went out on the next reachable beat") + self.assertTrue(any("caught up" in s for s in self.summaries())) + self.assertEqual(sync.status()["state"], "ok") + self.use(self.elena) + sync.pull_now() + self.assertEqual(self.stage_of(self.elena), "to-do") + + def test_no_origin_at_all_is_simply_nothing_to_do(self): + self.use(self.ada) + git(self.ada, "remote", "remove", "origin") + self.move(self.ada, "backlog", "to-do") + + self.assertEqual(sync.push_now(), "no-origin") + self.assertEqual(sync.pull_now(), "no-origin") + self.assertEqual(self.summaries(), []) + + # — never pulling into a checkout that is not ready — + + def test_uncommitted_changes_stall_the_pull_loudly(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + + self.use(self.elena) + (self.elena / "code.txt").write_text("half-finished\n", encoding="utf-8") + before = self.head(self.elena) + + self.assertEqual(sync.pull_now(), "dirty") + self.assertEqual(self.head(self.elena), before) + self.assertEqual((self.elena / "code.txt").read_text(encoding="utf-8"), + "half-finished\n") + self.assertTrue(any("uncommitted changes" in s for s in self.summaries())) + self.assertEqual(sync.status()["state"], "stalled") + + git(self.elena, "checkout", "--", "code.txt") + self.assertEqual(sync.pull_now(), "pulled") + self.assertEqual(sync.status()["state"], "ok") + + def test_untracked_files_do_not_stall_anything(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + + self.use(self.elena) + (self.elena / "scratch.txt").write_text("mine\n", encoding="utf-8") + + self.assertEqual(sync.pull_now(), "pulled") + + def test_a_checkout_off_main_pauses_sync(self): + self.move(self.ada, "backlog", "to-do") + sync.push_now() + + self.use(self.elena) + git(self.elena, "checkout", "-q", "-b", "side") + before = self.head(self.elena) + + self.assertEqual(sync.pull_now(), "not-on-main") + self.assertEqual(self.head(self.elena), before) + self.assertTrue(any("not main" in s for s in self.summaries())) + + # — the gate — + + def test_the_gate_off_does_not_touch_the_network(self): + self.patch(SYNC=False) + self.use(self.ada) + # A remote that never answers: anything that fetched or pushed here + # would hang instead of returning at once. + git(self.ada, "config", "protocol.ext.allow", "always") + git(self.ada, "remote", "set-url", "origin", "ext::sleep 30") + + started = time.monotonic() + self.assertEqual(sync.push_now(), "off") + self.assertEqual(sync.pull_now(), "off") + sync.on_commit(FILENAME) + self.assertLess(time.monotonic() - started, 2) + self.assertEqual(self.summaries(), []) + self.assertEqual(sync.status(), {"enabled": False, "state": "off", "detail": ""}) + + def test_the_gate_off_leaves_moves_exactly_as_they_were(self): + self.patch(SYNC=False, COMMIT_MOVES=False) + before = self.origin_head() + + self.move(self.ada, "backlog", "to-do") + + self.assertEqual(self.stage_of(self.ada), "to-do") + self.assertEqual(self.head(self.ada), before, "no commit, no push") + self.assertEqual(self.origin_head(), before) + self.assertNotIn("**Assignee:**", self.card(self.ada)) + + def test_a_registered_hook_is_inert_with_the_gate_off(self): + sync.install() + self.patch(SYNC=False) + before = self.origin_head() + + self.move(self.ada, "backlog", "to-do") + time.sleep(0.2) + + self.assertEqual(self.origin_head(), before) + + def test_the_watcher_still_says_disk_for_a_plain_hand_move(self): + self.use(self.ada) + before = self.sig(self.ada) + shutil.move(str(self.ada / "tasks" / "backlog" / FILENAME), + str(self.ada / "tasks" / "to-do" / FILENAME)) + state.BOARD_EVENTS.clear() + + watch.narrate(before, self.sig(self.ada)) + + self.assertEqual(state.BOARD_EVENTS[0]["actor"], "disk") + + +class TheGateImpliesCommitMoves(unittest.TestCase): + """BOARD_SYNC=1 turns BOARD_COMMIT_MOVES on: there is nothing to publish + until moves commit themselves.""" + + def reload(self, **env) -> None: + saved = {k: os.environ.get(k) for k in ("BOARD_SYNC", "BOARD_COMMIT_MOVES")} + + def restore(): + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + importlib.reload(config) + + self.addCleanup(restore) + for key in saved: + os.environ.pop(key, None) + os.environ.update(env) + importlib.reload(config) + + def test_sync_on_implies_commit_moves(self): + self.reload(BOARD_SYNC="1") + self.assertTrue(config.SYNC) + self.assertTrue(config.COMMIT_MOVES) + + def test_both_are_off_by_default(self): + self.reload() + self.assertFalse(config.SYNC) + self.assertFalse(config.COMMIT_MOVES) + self.assertEqual(config.SYNC_INTERVAL, 30.0) + + def test_commit_moves_alone_stays_alone(self): + self.reload(BOARD_COMMIT_MOVES="1") + self.assertTrue(config.COMMIT_MOVES) + self.assertFalse(config.SYNC) + + +class TheStrayCommitTest(unittest.TestCase): + """The piggyback guard reads commit subjects — the one thing standing + between a human's private work and origin.""" + + def test_board_commits_pass(self): + self.assertEqual(sync._stray(["abc board: 07 → to-do (ada)", + "def board: 08 → done (elena)"]), "") + + def test_the_oldest_stray_is_the_one_named(self): + self.assertEqual( + sync._stray(["abc board: 07 → to-do (ada)", "def wip: older", "aaa wip: oldest"]), + "aaa wip: oldest") + + def test_a_commit_merely_mentioning_the_board_is_still_stray(self): + self.assertEqual(sync._stray(["abc fix the board: really"]), + "abc fix the board: really") + + +class TheSyncChip(unittest.TestCase): + """board.html is a single file with no frontend runner — these are the + source-level invariants of the surface this card adds.""" + + @classmethod + def setUpClass(cls): + cls.html = (REPO / "manager" / "core" / "board.html").read_text(encoding="utf-8") + + def test_a_server_toast_reaches_the_person(self): + self.assertIn("msg.type === 'toast'", self.html) + self.assertIn("toast(msg.message, !!msg.error)", self.html) + + def test_the_chip_hides_itself_while_sync_is_healthy(self): + self.assertIn("if (!s || !s.enabled || s.state === 'ok') { el.hidden = true;", self.html) + self.assertIn(".livechip[hidden]{display:none}", self.html, + "the chip's own display:flex would beat the UA's [hidden]") + + def test_a_refused_move_re_reads_the_board(self): + """What makes the mid-drag race safe in the browser: the drop sends + the stage it started from, and a rejected move reloads disk state + rather than leaving the stale card on screen.""" + self.assertIn("const { file, from } = JSON.parse(e.dataTransfer.getData(" + "'application/json'));", self.html) + self.assertIn("if (!res.ok) { toast(data.error || 'move failed', true); " + "await loadState(); return false; }", self.html) + + def test_sync_events_have_a_glyph_and_a_filter(self): + self.assertIn("sync: '⇅'", self.html) + self.assertIn("new Set(['move', 'new', 'agent', 'sync'])", self.html) + + +if __name__ == "__main__": + unittest.main()