From f3acc57015418dafa06b7ee5726a27efcac1800e Mon Sep 17 00:00:00 2001 From: Wyatt Date: Tue, 21 Jul 2026 22:41:46 -0400 Subject: [PATCH 1/4] feat(db,control): dormant headless-runner schema, stream parser, and fixtures (phase 1) Groundwork for replacing tmux-TUI agent runs with worker-owned 'claude -p --output-format stream-json' subprocesses (Postgres as the single source of truth; no shared files between workers): - migration 0008: workers (heartbeat registry), agent_runs (one row per headless invocation), agent_events (persisted stream-json event log), session_archives (tar.gz'd claude session per agent for cross-worker --resume); agents gains session_id/worker_id, commands gains target_worker; 'crashed' joins the agent status vocabulary - control/headless.py: munged-path helper, argv builders, tolerant stream parser, archive/materialize round-trip, RunSupervisor + launch (nothing calls it yet - runner config still defaults to tmux) - repository: run/event/archive/worker accessors; claim_next_command learns target_worker pinning and type exclusion for full slots - tests/fixtures/fake_claude.py: scripted stream-json stand-in binary - scripts/validate_claude_headless.sh: manual real-binary validation checklist (resume-on-clean-HOME linchpin, hook behavior under -p) No behavior change; suite 245 -> 270 green. --- scripts/validate_claude_headless.sh | 138 +++++++ src/handler/config.py | 22 + src/handler/control/headless.py | 385 ++++++++++++++++++ src/handler/db/repository.py | 221 +++++++++- src/handler/db/tables.py | 91 ++++- .../migrations/versions/0008_headless_runs.py | 128 ++++++ tests/fixtures/fake_claude.py | 163 ++++++++ tests/test_headless_parser.py | 178 ++++++++ tests/test_repository_runs.py | 174 ++++++++ 9 files changed, 1496 insertions(+), 4 deletions(-) create mode 100755 scripts/validate_claude_headless.sh create mode 100644 src/handler/control/headless.py create mode 100644 src/handler/migrations/versions/0008_headless_runs.py create mode 100755 tests/fixtures/fake_claude.py create mode 100644 tests/test_headless_parser.py create mode 100644 tests/test_repository_runs.py diff --git a/scripts/validate_claude_headless.sh b/scripts/validate_claude_headless.sh new file mode 100755 index 0000000..ff46c7d --- /dev/null +++ b/scripts/validate_claude_headless.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Phase-1 validation of the real `claude` binary's headless behavior (plan §0). +# Run MANUALLY inside the control container (needs real claude + credentials); findings +# go in the phase-1 PR description. Never wired into CI — this probes the real binary. +# +# CLAUDE_BIN=claude ./scripts/validate_claude_headless.sh /tmp/headless-validation +# +# Each check prints PASS/FAIL/INFO; the script keeps going so one failure doesn't hide +# the rest. Items map to the plan's "must validate" list: +# 1. --verbose requirement with -p --output-format stream-json +# 2. exact stream-json event shapes for this CLI version +# 3. --resume from a transcript materialized onto a clean $HOME (THE LINCHPIN) +# 4. PreToolUse deny behavior under -p +# 5. Stop-hook decision:block behavior under -p +# 6. -p exit-code semantics + credential env var name + +set -uo pipefail + +CLAUDE_BIN="${CLAUDE_BIN:-claude}" +WORK="${1:-/tmp/headless-validation}" +SID="$(python3 -c 'import uuid; print(uuid.uuid4())')" + +mkdir -p "$WORK/repo" "$WORK/home-a" "$WORK/home-b" +cd "$WORK/repo" + +note() { printf '\n=== %s ===\n' "$*"; } +result() { printf -- '--> %s\n' "$*"; } + +note "0. version" +"$CLAUDE_BIN" --version + +note "1. does -p --output-format stream-json work WITHOUT --verbose?" +if HOME="$WORK/home-a" "$CLAUDE_BIN" -p --output-format stream-json \ + --session-id "$SID" -- 'Reply with the single word: ping' >"$WORK/no-verbose.out" 2>&1; then + result "PASS without --verbose (flag optional — keep passing it anyway)" +else + result "FAIL without --verbose (required, as the runner assumes): $(tail -1 "$WORK/no-verbose.out")" +fi + +note "2. event shapes (fresh run, --verbose)" +SID2="$(python3 -c 'import uuid; print(uuid.uuid4())')" +HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \ + --session-id "$SID2" -- 'Reply with the single word: pong' \ + | tee "$WORK/stream.jsonl" \ + | python3 -c ' +import json, sys +for line in sys.stdin: + line = line.strip() + if not line: continue + try: + e = json.loads(line) + keys = ",".join(sorted(e.keys())) + print(f" type={e.get(\"type\")}/{e.get(\"subtype\")} keys=[{keys}]") + except Exception: + print(f" UNPARSEABLE: {line[:100]}") +' +result "exit code: $? — full stream saved to $WORK/stream.jsonl" + +note "3. cross-worker resume: materialize transcript onto a clean HOME (LINCHPIN)" +MUNGED="$(python3 -c "import sys; print(sys.argv[1].replace('/', '-').replace('.', '-'))" "$WORK/repo")" +SRC="$WORK/home-a/.claude/projects/$MUNGED" +DST="$WORK/home-b/.claude/projects/$MUNGED" +mkdir -p "$DST" +if cp -r "$SRC/$SID2.jsonl" "$DST/" 2>/dev/null; then + [ -d "$SRC/$SID2" ] && cp -r "$SRC/$SID2" "$DST/" + if HOME="$WORK/home-b" "$CLAUDE_BIN" -p --verbose --output-format stream-json \ + --resume "$SID2" -- 'What word did you reply with before? Answer with just that word.' \ + >"$WORK/resume.jsonl" 2>"$WORK/resume.err"; then + if grep -q 'pong' "$WORK/resume.jsonl"; then + result "PASS: resume on clean HOME retained context (found 'pong')" + else + result "PARTIAL: resume ran but context unclear — inspect $WORK/resume.jsonl" + fi + else + result "FAIL: resume on clean HOME errored — $(tail -1 "$WORK/resume.err") (fallback re-injection will be load-bearing)" + fi +else + result "FAIL: no transcript found at $SRC — munge algorithm wrong for this version?" +fi + +note "4. PreToolUse deny under -p" +mkdir -p .claude +cat > .claude/settings.json <<'EOF' +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + {"type": "command", + "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"validation: always deny\"}}'"} + ] + } + ] + }, + "permissions": {"defaultMode": "acceptEdits", "allow": ["Bash(echo *)"]} +} +EOF +HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \ + --settings .claude/settings.json \ + -- 'Run the bash command `echo hello` and tell me its output.' \ + >"$WORK/deny.jsonl" 2>&1 +result "exit=$? — grep the stream for the deny reason:" +grep -o 'validation: always deny' "$WORK/deny.jsonl" | head -1 \ + && result "PASS: deny reason surfaced in stream" \ + || result "INSPECT $WORK/deny.jsonl: deny reason not found" + +note "5. Stop hook decision:block under -p" +cat > .claude/settings.json <<'EOF' +{ + "hooks": { + "Stop": [ + {"hooks": [ + {"type": "command", + "command": "if [ -f /tmp/.hv-stop-once ]; then echo '{}'; else touch /tmp/.hv-stop-once; echo '{\"decision\":\"block\",\"reason\":\"validation: say BLOCKED-ONCE then finish\"}'; fi"} + ]} + ] + } +} +EOF +rm -f /tmp/.hv-stop-once +HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \ + --settings .claude/settings.json -- 'Reply with the word: initial' \ + >"$WORK/stopblock.jsonl" 2>&1 +result "exit=$? — expect a second turn mentioning BLOCKED-ONCE:" +grep -o 'BLOCKED-ONCE' "$WORK/stopblock.jsonl" | head -1 \ + && result "PASS: Stop block re-prompted under -p" \ + || result "INSPECT $WORK/stopblock.jsonl: no evidence of re-prompt" +rm -f /tmp/.hv-stop-once + +note "6. exit codes" +HOME="$WORK/home-a" "$CLAUDE_BIN" -p --verbose --output-format stream-json \ + --resume "$(python3 -c 'import uuid; print(uuid.uuid4())')" -- 'x' \ + >"$WORK/badresume.out" 2>&1 +result "resume of nonexistent session exit=$? (runner treats nonzero-before-assistant as resume failure)" +result "INFO: check credential env name with: $CLAUDE_BIN setup-token --help (expect CLAUDE_CODE_OAUTH_TOKEN)" + +note "done — artifacts in $WORK" diff --git a/src/handler/config.py b/src/handler/config.py index 7f5718d..eaebd14 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -51,6 +51,28 @@ class Settings(BaseSettings): forge_bin: str = "forge" git_bin: str = "git" + # ---- Headless runner (claude -p --output-format stream-json). ``runner`` selects the + # launch path: "tmux" (legacy interactive session, the default until the headless path + # is validated) or "headless" (worker-owned subprocess streaming events to the DB). + runner: str = "tmux" + # How many concurrent claude runs one worker container supervises; commands that would + # start a run are left queued (for another worker) while all slots are busy. + max_concurrent_runs: int = 4 + # Heartbeats older than this many seconds mark a worker dead; the reaper flips its + # running runs (and their agents) to ``crashed``. + worker_stale_after: float = 60.0 + # Per-run spend cap passed as ``--max-budget-usd``. 0 disables the flag. + run_budget_usd: float = 0.0 + # Refuse to upload a claude session archive larger than this (a runaway sidecar dir + # shouldn't balloon the DB); the run still works, only cross-worker resume degrades. + session_archive_max_bytes: int = 32 * 1024 * 1024 + # settings.json ``permissions.defaultMode`` for headless runs. ``-p`` auto-denies + # anything that would prompt interactively, so this plus the allowlist below is what + # lets normal work proceed; the PreToolUse/Stop hooks stay the hard gate. + headless_permission_mode: str = "acceptEdits" + # Comma-separated permission allow rules added to generated settings for headless runs. + headless_allowed_tools: str = "Bash(git *),Bash(mise *)" + # The pinned `forge` version (README 3.6 / Phase 2: pin, never float on @latest). # When set, spawn verifies the injected forge matches and records a mismatch; when # empty the check is skipped. Operators align this with what their base image installs. diff --git a/src/handler/control/headless.py b/src/handler/control/headless.py new file mode 100644 index 0000000..3582810 --- /dev/null +++ b/src/handler/control/headless.py @@ -0,0 +1,385 @@ +"""The headless runner: worker-owned ``claude -p`` subprocesses streaming to the DB. + +This is the tmux replacement seam for agent *runs* (tmux stays only for the interactive +``/login`` flow). Each invocation is ``claude -p --output-format stream-json`` with a +pre-assigned ``--session-id``; a :class:`RunSupervisor` owns the child process, appends +every stdout JSON line to ``agent_events`` as it arrives, derives ``agents.last_output`` +from the latest assistant text (so the existing UI keeps working), and reconciles the +agent's status from the *process* — exit code and EOF are positive liveness, replacing +the old pane scraping that could neither see a dead process nor populate the log. + +Resume continuity without shared files: claude persists its session under +``~/.claude/projects//``; the supervisor tars that into ``session_archives`` +(periodically and at exit), and whichever worker later claims a resume materializes the +archive at the same munged path before running ``claude -p --resume``. Workers therefore +need the same ``projects_root`` layout — a deployment invariant — but share nothing. + +``launch()`` is the function ``spawn``/``worker`` call and tests mock (via the +``claude_bin`` setting pointing at a fake, same pattern as the tmux fakes). +""" + +from __future__ import annotations + +import io +import json +import os +import subprocess +import tarfile +import tempfile +import threading +import time +import uuid +from pathlib import Path + +from ..config import get_settings +from ..db import repository as repo +from ..db.engine import connection + +# Stream types we recognize from ``--output-format stream-json``; anything else (or an +# unparseable line) is stored as-is so no output is ever dropped. ``worker`` is our own: +# runner-generated notices (crashes, archive failures, degraded resumes). +_KNOWN_TYPES = ("system", "assistant", "user", "result", "hook") + +# How long a SIGTERM'd claude gets to die before SIGKILL. +_TERM_GRACE_SECONDS = 10.0 + + +def munged_project_dir(working_dir: str) -> str: + """The directory name claude uses for a cwd under ``~/.claude/projects/``. + + Claude munges the absolute path by mapping ``/`` and ``.`` to ``-`` (verified against + real session state: ``/root/handler`` → ``-root-handler``, + ``/root/Talos/.claude/worktrees/x`` → ``-root-Talos--claude-worktrees-x``). + """ + return working_dir.replace("/", "-").replace(".", "-") + + +def session_dir(working_dir: str) -> Path: + """Where claude persists sessions for ``working_dir`` (under the *current* $HOME).""" + return Path(os.path.expanduser("~")) / ".claude" / "projects" / munged_project_dir(working_dir) + + +def build_spawn_argv(task: str, settings_path: str, session_id: str) -> list[str]: + """The headless spawn invocation. ``--verbose`` is required with stream-json in + print mode; ``--session-id`` pre-assigns the UUID so the session is addressable + (and archivable) from the first event.""" + s = get_settings() + argv = [ + s.claude_bin, "-p", "--verbose", + "--output-format", "stream-json", + "--session-id", session_id, + "--settings", settings_path, + ] + if s.run_budget_usd > 0: + argv += ["--max-budget-usd", str(s.run_budget_usd)] + argv += ["--", task] + return argv + + +def build_resume_argv(session_id: str, answer: str, settings_path: str) -> list[str]: + """The headless resume invocation — a brand-new process continuing ``session_id``.""" + s = get_settings() + argv = [ + s.claude_bin, "-p", "--verbose", + "--output-format", "stream-json", + "--resume", session_id, + "--settings", settings_path, + ] + if s.run_budget_usd > 0: + argv += ["--max-budget-usd", str(s.run_budget_usd)] + argv += ["--", answer] + return argv + + +def parse_stream_line(line: str) -> tuple[str, dict]: + """One stdout line → ``(event_type, payload)``. Never raises: malformed JSON or an + unrecognized shape comes back as ``("raw", {"line": ...})`` so the event log keeps + everything the process said, even across CLI format drift.""" + text = line.strip() + if not text: + return "raw", {"line": line} + try: + payload = json.loads(text) + except (ValueError, TypeError): + return "raw", {"line": line} + if not isinstance(payload, dict): + return "raw", {"line": line} + etype = payload.get("type") + if not isinstance(etype, str) or not etype: + return "raw", payload + if etype not in _KNOWN_TYPES: + # Future/unknown top-level types still store under their own name — the UI + # ignores what it doesn't know, but nothing is lost. + return etype, payload + return etype, payload + + +def assistant_text(payload: dict) -> str | None: + """The concatenated text blocks of an ``assistant`` event, or None when it carries + none (e.g. a pure tool_use turn). Feeds ``agents.last_output``.""" + message = payload.get("message") + if not isinstance(message, dict): + return None + content = message.get("content") + if isinstance(content, str): + return content or None + if not isinstance(content, list): + return None + parts = [ + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ] + text = "\n".join(p for p in parts if p) + return text or None + + +def archive_session(working_dir: str, session_id: str, max_bytes: int | None = None) -> bytes | None: + """Tar.gz the session transcript + sidecar dir, or None when nothing exists yet or + the result would exceed ``max_bytes`` (the caller records a worker event; the run + itself is unaffected — only cross-worker resume degrades).""" + base = session_dir(working_dir) + jsonl = base / f"{session_id}.jsonl" + sidecar = base / session_id + if not jsonl.exists() and not sidecar.exists(): + return None + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + if jsonl.exists(): + tar.add(jsonl, arcname=jsonl.name) + if sidecar.is_dir(): + tar.add(sidecar, arcname=sidecar.name) + data = buf.getvalue() + limit = max_bytes if max_bytes is not None else get_settings().session_archive_max_bytes + if limit and len(data) > limit: + return None + return data + + +def materialize_session(working_dir: str, archive: bytes) -> None: + """Unpack a session archive where claude will look for it on ``--resume``. + + ``filter="data"`` rejects path traversal and special members — the archive came from + our own DB, but a defense-in-depth default costs nothing. + """ + base = session_dir(working_dir) + base.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:gz") as tar: + tar.extractall(base, filter="data") + + +class RunSupervisor: + """Owns one headless claude subprocess for its whole life. + + A reader thread pumps stdout lines into ``agent_events``; the supervisor thread + watches the process, polls ``cancel_requested`` (cross-worker kill), uploads the + session archive periodically, and on exit reconciles run + agent status. Threads are + daemons: if the whole worker dies, the reaper — not us — settles the record. + """ + + def __init__( + self, + agent: dict, + run: dict, + argv: list[str], + cwd: str, + env: dict[str, str], + *, + cancel_poll: float = 5.0, + archive_interval: float = 60.0, + on_exit=None, + ) -> None: + self.agent = agent + self.run = run + self.argv = argv + self.cwd = cwd + self.env = env + self.cancel_poll = cancel_poll + self.archive_interval = archive_interval + self.on_exit = on_exit # worker's slot-release callback + self._seq = 0 + self._result_payload: dict | None = None + self._canceled = False + self.thread: threading.Thread | None = None + + def start(self) -> None: + self.thread = threading.Thread( + target=self._supervise, name=f"run-{self.run['id']}", daemon=True + ) + self.thread.start() + + # ---------------------------------------------------------------- internals + + def _insert_event(self, etype: str, payload: dict) -> None: + self._seq += 1 + with connection() as conn: + repo.insert_agent_event( + conn, + self.agent["id"], + self.run["id"], + seq=self._seq, + type=etype, + payload=payload, + session_id=self.run["session_id"], + ) + + def _pump_stdout(self, stream) -> None: + for line in stream: + etype, payload = parse_stream_line(line) + try: + self._insert_event(etype, payload) + if etype == "result": + self._result_payload = payload + elif etype == "assistant": + text = assistant_text(payload) + if text: + with connection() as conn: + repo.update_agent_output(conn, self.agent["id"], text) + except Exception: # noqa: BLE001 - a DB hiccup must not sever the pipe + continue + + def _upload_archive(self) -> None: + try: + data = archive_session(self.cwd, self.run["session_id"]) + if data is None: + return + with connection() as conn: + repo.upsert_session_archive( + conn, self.agent["id"], self.run["session_id"], data + ) + except Exception as exc: # noqa: BLE001 - archiving is best-effort + try: + self._insert_event( + "worker", {"notice": "session archive failed", "error": str(exc)} + ) + except Exception: # noqa: BLE001 + pass + + def _terminate(self, proc: subprocess.Popen) -> None: + self._canceled = True + proc.terminate() + try: + proc.wait(timeout=_TERM_GRACE_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + + def _supervise(self) -> None: + stderr_file = tempfile.TemporaryFile() + try: + proc = subprocess.Popen( + self.argv, + cwd=self.cwd, + env={**os.environ, **self.env}, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=stderr_file, + text=True, + ) + except OSError as exc: + stderr_file.close() + self._settle(exit_code=None, stderr_tail=f"failed to launch claude: {exc}") + return + reader = threading.Thread( + target=self._pump_stdout, args=(proc.stdout,), daemon=True + ) + reader.start() + last_archive = time.monotonic() + next_cancel_check = time.monotonic() + self.cancel_poll + while proc.poll() is None: + time.sleep(0.2) + now = time.monotonic() + if now >= next_cancel_check: + next_cancel_check = now + self.cancel_poll + with connection() as conn: + if repo.get_cancel_requested(conn, self.run["id"]): + self._terminate(proc) + break + if self.archive_interval > 0 and now - last_archive >= self.archive_interval: + self._upload_archive() + last_archive = now + proc.wait() + reader.join(timeout=30.0) + stderr_file.seek(0) + stderr_tail = stderr_file.read()[-4000:].decode("utf-8", "replace") + stderr_file.close() + self._settle(exit_code=proc.returncode, stderr_tail=stderr_tail) + + def _settle(self, exit_code: int | None, stderr_tail: str) -> None: + """Reconcile run + agent status once the process is gone, then final-archive.""" + result = self._result_payload + clean = ( + exit_code == 0 + and result is not None + and not result.get("is_error", False) + ) + if self._canceled: + run_status = "canceled" + elif clean: + run_status = "completed" + else: + run_status = "failed" + try: + with connection() as conn: + finished = repo.finish_run( + conn, self.run["id"], run_status, exit_code=exit_code, result=result + ) + # Hooks are the authority on agent status — they ran inside the run and + # may have set paused_for_input/blocked/done already. Only an agent still + # marked ``working`` needs the process's verdict. + agent = repo.get_agent_by_id(conn, self.agent["id"]) + if finished and agent is not None and agent["status"] == "working": + if self._canceled: + repo.set_agent_status(conn, self.agent["id"], "done") + elif clean: + repo.set_agent_status(conn, self.agent["id"], "done") + else: + repo.set_agent_status(conn, self.agent["id"], "blocked") + if not clean and not self._canceled: + detail = {"notice": "run failed", "exit_code": exit_code} + if stderr_tail.strip(): + detail["stderr_tail"] = stderr_tail + self._insert_event("worker", detail) + except Exception: # noqa: BLE001 - never let bookkeeping raise out of the thread + pass + self._upload_archive() + if self.on_exit is not None: + try: + self.on_exit(self) + except Exception: # noqa: BLE001 + pass + + +def launch( + agent: dict, + *, + kind: str, + prompt: str, + settings_path: str, + env: dict[str, str], + worker_id: str, + on_exit=None, +) -> dict: + """Start a headless run for ``agent`` and return its ``agent_runs`` row. + + ``kind`` is ``spawn`` (fresh session, new UUID) or ``resume`` (materialize the stored + archive, continue the agent's existing session). Fire-and-forget from the caller's + perspective — the returned run row is already ``running`` and a daemon supervisor + owns the process from here. + """ + working_dir = agent["working_dir"] + if kind == "spawn": + session_id = str(uuid.uuid4()) + argv = build_spawn_argv(prompt, settings_path, session_id) + else: + session_id = agent.get("session_id") + if not session_id: + raise ValueError(f"agent '{agent['name']}' has no session to resume") + argv = build_resume_argv(session_id, prompt, settings_path) + with connection() as conn: + run = repo.create_run(conn, agent["id"], session_id, worker_id, kind) + repo.set_agent_session(conn, agent["id"], session_id, worker_id) + supervisor = RunSupervisor( + agent, run, argv, cwd=working_dir, env=env, on_exit=on_exit + ) + supervisor.start() + return run diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py index c737be0..ecf4dc0 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -22,6 +22,8 @@ from typing import Any from sqlalchemy import Connection, select from .tables import ( + agent_events, + agent_runs, agents, approvals, checkmarks, @@ -30,7 +32,9 @@ from .tables import ( log_entries, projects, schedules, + session_archives, shared_context, + workers, ) from .upsert import upsert_checkmark @@ -373,6 +377,10 @@ def _purge_agent_dependents(conn: Connection, agent_ids: list[int]) -> None: conn.execute(approvals.delete().where(approvals.c.approved_by_agent_id.in_(agent_ids))) conn.execute(checkmarks.delete().where(checkmarks.c.agent_id.in_(agent_ids))) conn.execute(log_entries.delete().where(log_entries.c.agent_id.in_(agent_ids))) + # Headless-runner rows: events reference runs, so they go first. + conn.execute(agent_events.delete().where(agent_events.c.agent_id.in_(agent_ids))) + conn.execute(agent_runs.delete().where(agent_runs.c.agent_id.in_(agent_ids))) + conn.execute(session_archives.delete().where(session_archives.c.agent_id.in_(agent_ids))) def delete_project(conn: Connection, project_id: str) -> bool: @@ -424,8 +432,13 @@ def enqueue_command( agent_name: str | None = None, payload: dict | None = None, requested_by: str | None = None, + target_worker: str | None = None, ) -> dict: - """Insert a ``queued`` control command for the worker to pick up. Returns the row.""" + """Insert a ``queued`` control command for the worker to pick up. Returns the row. + + ``target_worker`` pins the command to one worker id (used by login_submit, which must + reach the container holding the live login tmux session); null = any worker. + """ result = conn.execute( commands.insert().values( project_id=project_id, @@ -434,6 +447,7 @@ def enqueue_command( payload=payload, status="queued", requested_by=requested_by, + target_worker=target_worker, created_at=_now(), ) ) @@ -457,20 +471,34 @@ def list_commands( return [dict(r._mapping) for r in rows] -def claim_next_command(conn: Connection, worker_id: str) -> dict | None: +def claim_next_command( + conn: Connection, + worker_id: str, + types_excluded: tuple[str, ...] = (), +) -> dict | None: """Atomically claim the oldest queued command, flipping it to ``running``. Postgres uses ``FOR UPDATE SKIP LOCKED`` so multiple workers never grab the same row; on SQLite (single writer per transaction) the guarded ``WHERE status='queued'`` update plus a rowcount check is enough. Returns the claimed row, or ``None`` when the queue is empty or another worker won the race. + + A command whose ``target_worker`` is set is only claimable by that worker (the login + flow's two steps must land on the same container). ``types_excluded`` lets a worker + with no free run slots skip claiming commands that would launch a new run, leaving + them for a less-loaded worker. """ sel = ( select(commands.c.id) - .where(commands.c.status == "queued") + .where( + commands.c.status == "queued", + (commands.c.target_worker.is_(None)) | (commands.c.target_worker == worker_id), + ) .order_by(commands.c.id.asc()) .limit(1) ) + if types_excluded: + sel = sel.where(commands.c.type.not_in(types_excluded)) if conn.dialect.name == "postgresql": sel = sel.with_for_update(skip_locked=True) row = conn.execute(sel).first() @@ -659,3 +687,190 @@ def mark_schedule_run( last_run_at=last_run_at, next_run_at=next_run_at, last_command_id=last_command_id ) ) + + +# ------------------------------------------------- headless runner (workers/runs/events) + + +def upsert_worker_heartbeat( + conn: Connection, + worker_id: str, + *, + hostname: str | None = None, + pid: int | None = None, + max_runs: int | None = None, + active_runs: int | None = None, +) -> None: + """Register a worker or refresh its heartbeat — the reaper's liveness signal.""" + now = _now() + result = conn.execute( + workers.update() + .where(workers.c.id == worker_id) + .values( + hostname=hostname, pid=pid, max_runs=max_runs, + active_runs=active_runs, heartbeat_at=now, + ) + ) + if result.rowcount == 0: + conn.execute( + workers.insert().values( + id=worker_id, hostname=hostname, pid=pid, max_runs=max_runs, + active_runs=active_runs, started_at=now, heartbeat_at=now, + ) + ) + + +def list_stale_workers(conn: Connection, cutoff: datetime) -> list[dict]: + """Workers whose heartbeat predates ``cutoff`` — reaper input.""" + rows = conn.execute(select(workers).where(workers.c.heartbeat_at < cutoff)).all() + return [dict(r._mapping) for r in rows] + + +def create_run( + conn: Connection, agent_id: int, session_id: str, worker_id: str, kind: str +) -> dict: + """Open an ``agent_runs`` row for a launching headless invocation.""" + result = conn.execute( + agent_runs.insert().values( + agent_id=agent_id, + session_id=session_id, + worker_id=worker_id, + kind=kind, + status="running", + cancel_requested=False, + started_at=_now(), + ) + ) + return get_run(conn, result.inserted_primary_key[0]) + + +def get_run(conn: Connection, run_id: int) -> dict | None: + row = conn.execute(select(agent_runs).where(agent_runs.c.id == run_id)).first() + return _row_to_dict(row) + + +def finish_run( + conn: Connection, + run_id: int, + status: str, + exit_code: int | None = None, + result: dict | None = None, +) -> bool: + """Close a run — only if still ``running``, so a reaper's ``crashed`` verdict and the + supervisor's own exit reconciliation can race without the loser clobbering the winner.""" + res = conn.execute( + agent_runs.update() + .where(agent_runs.c.id == run_id, agent_runs.c.status == "running") + .values(status=status, exit_code=exit_code, result=result, finished_at=_now()) + ) + return res.rowcount > 0 + + +def request_run_cancel(conn: Connection, run_id: int) -> bool: + """Flag a running run for cancellation; its owning supervisor polls and SIGTERMs.""" + result = conn.execute( + agent_runs.update() + .where(agent_runs.c.id == run_id, agent_runs.c.status == "running") + .values(cancel_requested=True) + ) + return result.rowcount > 0 + + +def get_cancel_requested(conn: Connection, run_id: int) -> bool: + row = conn.execute( + select(agent_runs.c.cancel_requested).where(agent_runs.c.id == run_id) + ).first() + return bool(row[0]) if row is not None else False + + +def list_running_runs(conn: Connection, worker_id: str | None = None) -> list[dict]: + """Runs currently ``running`` — all of them, or one worker's (the reaper's sweep).""" + stmt = select(agent_runs).where(agent_runs.c.status == "running") + if worker_id is not None: + stmt = stmt.where(agent_runs.c.worker_id == worker_id) + rows = conn.execute(stmt.order_by(agent_runs.c.id)).all() + return [dict(r._mapping) for r in rows] + + +def get_latest_run(conn: Connection, agent_id: int) -> dict | None: + row = conn.execute( + select(agent_runs) + .where(agent_runs.c.agent_id == agent_id) + .order_by(agent_runs.c.id.desc()) + .limit(1) + ).first() + return _row_to_dict(row) + + +def set_agent_session( + conn: Connection, agent_id: int, session_id: str, worker_id: str +) -> None: + """Record the claude session UUID + supervising worker on the agent row.""" + conn.execute( + agents.update() + .where(agents.c.id == agent_id) + .values(session_id=session_id, worker_id=worker_id) + ) + + +def insert_agent_event( + conn: Connection, + agent_id: int, + run_id: int, + seq: int, + type: str, + payload: dict | None = None, + session_id: str | None = None, +) -> int: + result = conn.execute( + agent_events.insert().values( + agent_id=agent_id, + run_id=run_id, + session_id=session_id, + seq=seq, + type=type, + payload=payload, + created_at=_now(), + ) + ) + return result.inserted_primary_key[0] + + +def list_agent_events( + conn: Connection, agent_id: int, after_id: int = 0, limit: int = 200 +) -> list[dict]: + """Events for an agent in insertion order, cursor-paged by row id (the UI polls with + ``after_id`` = the last id it has, so each poll returns only what's new).""" + rows = conn.execute( + select(agent_events) + .where(agent_events.c.agent_id == agent_id, agent_events.c.id > after_id) + .order_by(agent_events.c.id.asc()) + .limit(limit) + ).all() + return [dict(r._mapping) for r in rows] + + +def upsert_session_archive( + conn: Connection, agent_id: int, session_id: str, archive: bytes +) -> None: + """Store (replace) the agent's latest claude session archive.""" + now = _now() + result = conn.execute( + session_archives.update() + .where(session_archives.c.agent_id == agent_id) + .values(session_id=session_id, archive=archive, bytes=len(archive), updated_at=now) + ) + if result.rowcount == 0: + conn.execute( + session_archives.insert().values( + agent_id=agent_id, session_id=session_id, archive=archive, + bytes=len(archive), updated_at=now, + ) + ) + + +def get_session_archive(conn: Connection, agent_id: int) -> dict | None: + row = conn.execute( + select(session_archives).where(session_archives.c.agent_id == agent_id) + ).first() + return _row_to_dict(row) diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 3e62f04..843839c 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -14,6 +14,7 @@ from sqlalchemy import ( Column, ForeignKey, Index, + LargeBinary, MetaData, String, Table, @@ -27,7 +28,9 @@ metadata = MetaData() # Status vocabularies kept as free TEXT (README uses plain strings, not PG enums, so # both dialects match). CheckConstraints make the allowed sets explicit and portable. -AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done") +# ``crashed`` is reserved for the reaper: it marks an agent whose owning worker went +# silent mid-run — never a normal exit, which reconciles to done/blocked instead. +AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done", "crashed") GATE_STATUSES = ("pass", "fail", "unknown") CI_STATUSES = ("not_applicable", "pending", "pass", "fail") VISIBILITIES = ("project", "global") @@ -55,6 +58,10 @@ COMMAND_TYPES = ( COMMAND_STATUSES = ("queued", "running", "done", "failed") # Forge families a host can belong to (drives per-host token env conventions). FORGE_TYPES = ("github", "gitlab", "gitea", "forgejo", "bitbucket") +# One agent_runs row per headless ``claude -p`` invocation (spawn or resume). +# ``canceled`` = an operator kill; ``crashed`` = the reaper found the owning worker dead. +RUN_KINDS = ("spawn", "resume") +RUN_STATUSES = ("running", "completed", "failed", "crashed", "canceled") def _in(column: str, values: tuple[str, ...]) -> str: @@ -87,8 +94,14 @@ agents = Table( # A periodic snapshot of the agent's live tmux pane tail (last ~40 lines), refreshed by # the control worker's poll loop. The tmux socket lives only in the control container, # so this DB column is how the API/UI see what a running — or wedged — agent is doing. + # Headless runs reuse it, derived from the latest assistant text instead of a pane tail. Column("last_output", String), Column("output_at", PortableTimestamp), + # Headless runner (null on legacy tmux agents — the rollout discriminator): the claude + # session UUID pre-assigned at spawn (stable across resumes), and the worker currently + # (or last) supervising a run for this agent. + Column("session_id", String), + Column("worker_id", String), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), UniqueConstraint("project_id", "name", name="uq_agents_project_name"), CheckConstraint(_in("status", AGENT_STATUSES), name="ck_agents_status"), @@ -192,6 +205,9 @@ commands = Table( Column("error", String), Column("requested_by", String), # actor label, e.g. "operator:web" Column("claimed_by", String), # worker id that claimed the command + # Pins a command to one worker (null = any). login_submit must run on the worker that + # ran login_start — the live tmux login session exists only in that container. + Column("target_worker", String), Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), Column("claimed_at", PortableTimestamp), Column("finished_at", PortableTimestamp), @@ -227,6 +243,79 @@ forge_hosts = Table( CheckConstraint(_in("forge_type", FORGE_TYPES), name="ck_forge_hosts_type"), ) +# Worker registry + heartbeat (headless runner). Each worker container upserts its row +# every loop pass; the reaper marks a worker's running runs (and their agents) ``crashed`` +# when ``heartbeat_at`` goes stale — the positive-liveness replacement for tmux scraping. +workers = Table( + "workers", + metadata, + Column("id", String, primary_key=True), # "worker---" + Column("hostname", String), + Column("pid", BigInteger), + Column("max_runs", BigInteger), + Column("active_runs", BigInteger), + Column("started_at", PortableTimestamp, nullable=False, server_default=func.now()), + Column("heartbeat_at", PortableTimestamp, nullable=False, server_default=func.now()), +) + +# One row per headless ``claude -p`` invocation. The spawn/resume *command* finishes at +# launch (fire-and-forget, matching tmux semantics); the run row is what tracks the +# process's actual life — status, exit code, and the final result event. +agent_runs = Table( + "agent_runs", + metadata, + Column("id", PortableBigInt, primary_key=True, autoincrement=True), + Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False), + Column("session_id", String, nullable=False), + Column("worker_id", String, nullable=False), + Column("kind", String, nullable=False), + Column("status", String, nullable=False, server_default="running"), + # Cross-worker kill: any worker/API sets this; the owning supervisor polls it and + # SIGTERMs its own child — nobody signals a process they don't own. + Column("cancel_requested", Boolean, nullable=False, server_default="0"), + Column("exit_code", BigInteger), + Column("result", PortableJSON), # the stream's result event (cost/turns/is_error/text) + Column("started_at", PortableTimestamp, nullable=False, server_default=func.now()), + Column("finished_at", PortableTimestamp), + CheckConstraint(_in("kind", RUN_KINDS), name="ck_agent_runs_kind"), + CheckConstraint(_in("status", RUN_STATUSES), name="ck_agent_runs_status"), + Index("ix_agent_runs_status_worker", "status", "worker_id"), +) + +# The persisted event stream — one row per stream-json stdout line of a run, in order. +# This is what the UI's log/event panel reads; ``type`` mirrors the stream's top-level +# type (system/assistant/user/result), plus ``hook`` (--include-hook-events), ``worker`` +# (runner-generated notices: crash marks, archive failures, fallback resumes) and ``raw`` +# (an unparseable line, stored verbatim — the parser never drops data). +agent_events = Table( + "agent_events", + metadata, + Column("id", PortableBigInt, primary_key=True, autoincrement=True), + Column("agent_id", BigInteger, ForeignKey("agents.id"), nullable=False), + Column("run_id", BigInteger, ForeignKey("agent_runs.id"), nullable=False), + Column("session_id", String), + Column("seq", BigInteger, nullable=False), # per-run stdout line counter + Column("type", String, nullable=False), + Column("payload", PortableJSON), + Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()), + Index("ix_agent_events_agent_id", "agent_id", "id"), +) + +# Latest claude session archive per agent — the tar.gz of ``.jsonl`` + its sidecar +# dir from ``~/.claude/projects//``. Uploaded by the supervising worker +# (periodically and at run end) and materialized by whichever worker claims the next +# resume, so ``--resume`` works cross-worker with no shared filesystem (README: DB is the +# single source of truth; observed sizes are KBs-to-low-MBs). +session_archives = Table( + "session_archives", + metadata, + Column("agent_id", BigInteger, ForeignKey("agents.id"), primary_key=True), + Column("session_id", String, nullable=False), + Column("archive", LargeBinary, nullable=False), + Column("bytes", BigInteger, nullable=False), + Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()), +) + # Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues # an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity # audit trail like any other control action). Agent names must be unique per project, so diff --git a/src/handler/migrations/versions/0008_headless_runs.py b/src/handler/migrations/versions/0008_headless_runs.py new file mode 100644 index 0000000..eaf86be --- /dev/null +++ b/src/handler/migrations/versions/0008_headless_runs.py @@ -0,0 +1,128 @@ +"""headless runner: workers, runs, events, session archives + +Revision ID: 0008_headless_runs +Revises: 0007_agent_pane_output +Create Date: 2026-07-21 + +Dormant schema for the headless ``claude -p --output-format stream-json`` runner +(nothing writes these until the runner is enabled): + +- ``workers`` — worker registry + heartbeat, the reaper's liveness input. +- ``agent_runs`` — one row per headless invocation (spawn or resume), tracking the + process's real life: status, exit code, cancel flag, final result event. +- ``agent_events`` — the persisted stream-json event log the UI reads. +- ``session_archives`` — latest claude session tar.gz per agent, so ``--resume`` works + from any worker with no shared filesystem. +- ``agents`` gains ``session_id`` (null = legacy tmux agent, the rollout discriminator) + and ``worker_id``; ``commands`` gains ``target_worker`` (pins login_submit to the + worker holding the live login session). +- ``crashed`` joins the agent status vocabulary (reaper-set); the CHECK constraint + swaps go through ``batch_alter_table`` so SQLite recreates while Postgres alters in + place (same pattern as 0006's command type). +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from handler.db.types import PortableBigInt, PortableJSON, PortableTimestamp + +revision: str = "0008_headless_runs" +down_revision: str | None = "0007_agent_pane_output" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +OLD_AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done'" +NEW_AGENT_STATUSES = "'working', 'paused_for_input', 'blocked', 'done', 'crashed'" +RUN_KINDS = "'spawn', 'resume'" +RUN_STATUSES = "'running', 'completed', 'failed', 'crashed', 'canceled'" + + +def upgrade() -> None: + op.create_table( + "workers", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("hostname", sa.String()), + sa.Column("pid", sa.BigInteger()), + sa.Column("max_runs", sa.BigInteger()), + sa.Column("active_runs", sa.BigInteger()), + sa.Column("started_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + sa.Column("heartbeat_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + op.create_table( + "agent_runs", + sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True), + sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("worker_id", sa.String(), nullable=False), + sa.Column("kind", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False, server_default="running"), + sa.Column("cancel_requested", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("exit_code", sa.BigInteger()), + sa.Column("result", PortableJSON), + sa.Column("started_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + sa.Column("finished_at", PortableTimestamp), + sa.CheckConstraint(f"kind IN ({RUN_KINDS})", name="ck_agent_runs_kind"), + sa.CheckConstraint(f"status IN ({RUN_STATUSES})", name="ck_agent_runs_status"), + ) + op.create_index( + "ix_agent_runs_status_worker", "agent_runs", ["status", "worker_id"] + ) + op.create_table( + "agent_events", + sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True), + sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), nullable=False), + sa.Column("run_id", sa.BigInteger(), sa.ForeignKey("agent_runs.id"), nullable=False), + sa.Column("session_id", sa.String()), + sa.Column("seq", sa.BigInteger(), nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("payload", PortableJSON), + sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_agent_events_agent_id", "agent_events", ["agent_id", "id"]) + op.create_table( + "session_archives", + sa.Column("agent_id", sa.BigInteger(), sa.ForeignKey("agents.id"), primary_key=True), + sa.Column("session_id", sa.String(), nullable=False), + sa.Column("archive", sa.LargeBinary(), nullable=False), + sa.Column("bytes", sa.BigInteger(), nullable=False), + sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + op.add_column("agents", sa.Column("session_id", sa.String())) + op.add_column("agents", sa.Column("worker_id", sa.String())) + op.add_column("commands", sa.Column("target_worker", sa.String())) + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_constraint("ck_agents_status", type_="check") + batch_op.create_check_constraint( + "ck_agents_status", f"status IN ({NEW_AGENT_STATUSES})" + ) + with op.batch_alter_table("checkmarks", schema=None) as batch_op: + batch_op.drop_constraint("ck_checkmarks_status", type_="check") + batch_op.create_check_constraint( + "ck_checkmarks_status", f"status IN ({NEW_AGENT_STATUSES})" + ) + + +def downgrade() -> None: + with op.batch_alter_table("checkmarks", schema=None) as batch_op: + batch_op.drop_constraint("ck_checkmarks_status", type_="check") + batch_op.create_check_constraint( + "ck_checkmarks_status", f"status IN ({OLD_AGENT_STATUSES})" + ) + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_constraint("ck_agents_status", type_="check") + batch_op.create_check_constraint( + "ck_agents_status", f"status IN ({OLD_AGENT_STATUSES})" + ) + op.drop_column("commands", "target_worker") + op.drop_column("agents", "worker_id") + op.drop_column("agents", "session_id") + op.drop_table("session_archives") + op.drop_index("ix_agent_events_agent_id", table_name="agent_events") + op.drop_table("agent_events") + op.drop_index("ix_agent_runs_status_worker", table_name="agent_runs") + op.drop_table("agent_runs") + op.drop_table("workers") diff --git a/tests/fixtures/fake_claude.py b/tests/fixtures/fake_claude.py new file mode 100755 index 0000000..126fe63 --- /dev/null +++ b/tests/fixtures/fake_claude.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""A stand-in ``claude`` binary for headless-runner tests. + +Wired in via the existing ``claude_bin`` setting (the same seam the tmux fakes used). +Parses the real headless argv, emits a scripted ``--output-format stream-json`` stream on +stdout, and writes a genuine session transcript + sidecar under +``$HOME/.claude/projects//`` so archive/materialize/resume paths exercise the +real filesystem layout. Behavior is selected with ``FAKE_CLAUDE_MODE``: + +- ``success`` (default): init + assistant + result, exit 0. +- ``error``: init + assistant + one garbage line, then exit 2 with no result event. +- ``hang``: init, then sleep forever (the kill/cancel/reaper tests SIGTERM it). +- ``slow``: like success with a pause between events (concurrency tests). +- ``resume-fail``: a ``--resume`` invocation exits 1 before any assistant event + (exercises the context re-injection fallback). + +On ``--resume`` (outside resume-fail) the transcript materialized by the worker MUST +already exist at the expected path — missing means cross-worker materialization broke, +so the fake fails loudly, exit 3. +""" + +from __future__ import annotations + +import json +import os +import signal +import sys +import time +from pathlib import Path + + +def _parse_argv(argv: list[str]) -> dict: + opts = { + "print": False, + "verbose": False, + "output_format": None, + "session_id": None, + "resume": None, + "settings": None, + "budget": None, + "prompt": None, + } + i = 0 + while i < len(argv): + arg = argv[i] + if arg == "-p": + opts["print"] = True + elif arg == "--verbose": + opts["verbose"] = True + elif arg == "--output-format": + i += 1 + opts["output_format"] = argv[i] + elif arg == "--session-id": + i += 1 + opts["session_id"] = argv[i] + elif arg in ("--resume", "-r"): + i += 1 + opts["resume"] = argv[i] + elif arg == "--settings": + i += 1 + opts["settings"] = argv[i] + elif arg == "--max-budget-usd": + i += 1 + opts["budget"] = argv[i] + elif arg == "--": + opts["prompt"] = " ".join(argv[i + 1 :]) + break + else: + opts["prompt"] = arg + i += 1 + return opts + + +def _munged(cwd: str) -> str: + return cwd.replace("/", "-").replace(".", "-") + + +def _emit(event: dict) -> None: + sys.stdout.write(json.dumps(event) + "\n") + sys.stdout.flush() + + +def _write_transcript(session_id: str, prompt: str) -> None: + base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd()) + base.mkdir(parents=True, exist_ok=True) + jsonl = base / f"{session_id}.jsonl" + with jsonl.open("a") as fh: + fh.write(json.dumps({"type": "user", "prompt": prompt}) + "\n") + fh.write(json.dumps({"type": "assistant", "text": f"handled: {prompt}"}) + "\n") + sidecar = base / session_id / "tool-results" + sidecar.mkdir(parents=True, exist_ok=True) + (sidecar / "result-1.txt").write_text("fake tool output\n") + + +def main() -> int: + signal.signal(signal.SIGTERM, signal.SIG_DFL) + mode = os.environ.get("FAKE_CLAUDE_MODE", "success") + opts = _parse_argv(sys.argv[1:]) + if not opts["print"] or opts["output_format"] != "stream-json": + sys.stderr.write("fake_claude: expected -p --output-format stream-json\n") + return 64 + + session_id = opts["session_id"] or opts["resume"] or "fake-session" + prompt = opts["prompt"] or "" + + if mode == "resume-fail" and opts["resume"]: + sys.stderr.write("fake_claude: no conversation found to resume\n") + return 1 + + if opts["resume"]: + base = Path(os.path.expanduser("~")) / ".claude" / "projects" / _munged(os.getcwd()) + if not (base / f"{session_id}.jsonl").exists(): + sys.stderr.write(f"fake_claude: transcript missing at {base}\n") + return 3 + + _emit( + { + "type": "system", + "subtype": "init", + "session_id": session_id, + "cwd": os.getcwd(), + "tools": ["Bash", "Read", "Edit"], + } + ) + if mode == "hang": + time.sleep(3600) + return 0 + if mode == "slow": + time.sleep(float(os.environ.get("FAKE_CLAUDE_SLOW_SECONDS", "1.0"))) + + _emit( + { + "type": "assistant", + "session_id": session_id, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": f"working on: {prompt}"}], + }, + } + ) + _write_transcript(session_id, prompt) + + if mode == "error": + sys.stdout.write("this is not json\n") + sys.stdout.flush() + return 2 + + _emit( + { + "type": "result", + "subtype": "success", + "session_id": session_id, + "is_error": False, + "num_turns": 1, + "total_cost_usd": 0.01, + "result": f"done: {prompt}", + } + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_headless_parser.py b/tests/test_headless_parser.py new file mode 100644 index 0000000..0b5863b --- /dev/null +++ b/tests/test_headless_parser.py @@ -0,0 +1,178 @@ +"""Pure-helper tests for the headless runner: stream parsing, path munging, argv +construction, and the session archive round-trip. No subprocess is launched here — +the process-level tests live in test_headless_run.py (phase 2).""" + +from __future__ import annotations + +import io +import tarfile + +from handler.control import headless + + +# ------------------------------------------------------------------ parse_stream_line + + +def test_parse_valid_event_types(): + for etype in ("system", "assistant", "user", "result", "hook"): + parsed_type, payload = headless.parse_stream_line(f'{{"type": "{etype}", "x": 1}}\n') + assert parsed_type == etype + assert payload == {"type": etype, "x": 1} + + +def test_parse_unknown_type_keeps_its_name(): + parsed_type, payload = headless.parse_stream_line('{"type": "telemetry", "n": 2}') + assert parsed_type == "telemetry" + assert payload["n"] == 2 + + +def test_parse_garbage_becomes_raw(): + parsed_type, payload = headless.parse_stream_line("this is not json\n") + assert parsed_type == "raw" + assert payload == {"line": "this is not json\n"} + + +def test_parse_non_dict_json_becomes_raw(): + parsed_type, payload = headless.parse_stream_line('["a", "b"]') + assert parsed_type == "raw" + + +def test_parse_missing_type_becomes_raw(): + parsed_type, payload = headless.parse_stream_line('{"message": "no type field"}') + assert parsed_type == "raw" + assert payload == {"message": "no type field"} + + +def test_parse_blank_line_becomes_raw(): + parsed_type, _ = headless.parse_stream_line(" \n") + assert parsed_type == "raw" + + +# -------------------------------------------------------------------- assistant_text + + +def test_assistant_text_joins_text_blocks(): + payload = { + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "first"}, + {"type": "tool_use", "name": "Bash", "input": {}}, + {"type": "text", "text": "second"}, + ] + }, + } + assert headless.assistant_text(payload) == "first\nsecond" + + +def test_assistant_text_none_for_pure_tool_use(): + payload = { + "type": "assistant", + "message": {"content": [{"type": "tool_use", "name": "Bash", "input": {}}]}, + } + assert headless.assistant_text(payload) is None + + +def test_assistant_text_string_content(): + assert headless.assistant_text({"message": {"content": "plain"}}) == "plain" + + +def test_assistant_text_missing_message(): + assert headless.assistant_text({"type": "assistant"}) is None + + +# ---------------------------------------------------------------- munged_project_dir + + +def test_munge_matches_recorded_real_examples(): + # Recorded from a real ~/.claude/projects/ (see plan): '/' and '.' both map to '-'. + assert headless.munged_project_dir("/root/handler") == "-root-handler" + assert ( + headless.munged_project_dir("/root/Talos/.claude/worktrees/mise-tooling") + == "-root-Talos--claude-worktrees-mise-tooling" + ) + + +# ------------------------------------------------------------------- argv builders + + +def test_spawn_argv_shape(env): + argv = headless.build_spawn_argv("do the task", "/wd/.claude/settings.json", "sid-1") + assert argv[0] == "claude" + assert "-p" in argv and "--verbose" in argv + assert argv[argv.index("--output-format") + 1] == "stream-json" + assert argv[argv.index("--session-id") + 1] == "sid-1" + assert argv[argv.index("--settings") + 1] == "/wd/.claude/settings.json" + assert "--max-budget-usd" not in argv # default budget is 0 = off + assert argv[-2:] == ["--", "do the task"] + assert "--resume" not in argv + + +def test_resume_argv_shape(env): + argv = headless.build_resume_argv("sid-2", "the answer", "/wd/.claude/settings.json") + assert argv[argv.index("--resume") + 1] == "sid-2" + assert argv[-2:] == ["--", "the answer"] + assert "--session-id" not in argv + + +def test_spawn_argv_includes_budget_when_set(env, monkeypatch): + monkeypatch.setenv("RUN_BUDGET_USD", "2.5") + from handler import config + + config.get_settings.cache_clear() + argv = headless.build_spawn_argv("t", "/s.json", "sid") + assert argv[argv.index("--max-budget-usd") + 1] == "2.5" + config.get_settings.cache_clear() + + +# ------------------------------------------------------------- archive round-trip + + +def _write_fake_session(home, working_dir: str, session_id: str) -> None: + base = home / ".claude" / "projects" / headless.munged_project_dir(working_dir) + base.mkdir(parents=True) + (base / f"{session_id}.jsonl").write_text('{"type": "user", "prompt": "hi"}\n') + sidecar = base / session_id / "tool-results" + sidecar.mkdir(parents=True) + (sidecar / "r1.txt").write_text("tool output") + + +def test_archive_and_materialize_round_trip(env, tmp_path, monkeypatch): + working_dir = "/projects/demo" + _write_fake_session(tmp_path, working_dir, "sid-rt") + + data = headless.archive_session(working_dir, "sid-rt") + assert data is not None + + # Materialize onto a *different* worker: a fresh HOME with no session state. + other_home = tmp_path / "other-worker" + other_home.mkdir() + monkeypatch.setenv("HOME", str(other_home)) + headless.materialize_session(working_dir, data) + + base = other_home / ".claude" / "projects" / headless.munged_project_dir(working_dir) + assert (base / "sid-rt.jsonl").read_text() == '{"type": "user", "prompt": "hi"}\n' + assert (base / "sid-rt" / "tool-results" / "r1.txt").read_text() == "tool output" + + +def test_archive_none_when_no_session(env): + assert headless.archive_session("/projects/none", "missing-sid") is None + + +def test_archive_refuses_oversize(env, tmp_path): + working_dir = "/projects/big" + _write_fake_session(tmp_path, working_dir, "sid-big") + assert headless.archive_session(working_dir, "sid-big", max_bytes=10) is None + + +def test_archive_contains_only_session_members(env, tmp_path): + working_dir = "/projects/demo2" + _write_fake_session(tmp_path, working_dir, "sid-a") + # A sibling session must not leak into sid-a's archive. + base = tmp_path / ".claude" / "projects" / headless.munged_project_dir(working_dir) + (base / "sid-other.jsonl").write_text("{}\n") + + data = headless.archive_session(working_dir, "sid-a") + with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar: + names = tar.getnames() + assert all(n == "sid-a.jsonl" or n.startswith("sid-a") for n in names) diff --git a/tests/test_repository_runs.py b/tests/test_repository_runs.py new file mode 100644 index 0000000..3616320 --- /dev/null +++ b/tests/test_repository_runs.py @@ -0,0 +1,174 @@ +"""Repository coverage for the headless-runner tables (workers / agent_runs / +agent_events / session_archives) and the new claim filters. The ``env`` fixture runs the +real ``alembic upgrade head``, so migration 0008 itself is under test here too.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from handler.db import repository as repo + + +def _agent(conn, name="a1"): + repo.create_project(conn, "p", "/projects/p") + return repo.create_agent(conn, "p", name, "/projects/p") + + +# ------------------------------------------------------------------------ agent_runs + + +def test_run_lifecycle(conn): + agent = _agent(conn) + run = repo.create_run(conn, agent["id"], "sid-1", "worker-a", "spawn") + assert run["status"] == "running" + assert run["kind"] == "spawn" + assert run["cancel_requested"] is False + + assert repo.finish_run(conn, run["id"], "completed", exit_code=0, result={"ok": True}) + done = repo.get_run(conn, run["id"]) + assert done["status"] == "completed" + assert done["exit_code"] == 0 + assert done["result"] == {"ok": True} + assert done["finished_at"] is not None + + +def test_finish_run_only_once(conn): + """The supervisor's verdict and a racing reaper can't clobber each other.""" + agent = _agent(conn) + run = repo.create_run(conn, agent["id"], "sid", "w", "spawn") + assert repo.finish_run(conn, run["id"], "crashed") is True + assert repo.finish_run(conn, run["id"], "completed", exit_code=0) is False + assert repo.get_run(conn, run["id"])["status"] == "crashed" + + +def test_cancel_request_roundtrip(conn): + agent = _agent(conn) + run = repo.create_run(conn, agent["id"], "sid", "w", "resume") + assert repo.get_cancel_requested(conn, run["id"]) is False + assert repo.request_run_cancel(conn, run["id"]) is True + assert repo.get_cancel_requested(conn, run["id"]) is True + # A finished run can't be re-flagged. + repo.finish_run(conn, run["id"], "canceled") + assert repo.request_run_cancel(conn, run["id"]) is False + + +def test_list_running_runs_scoped_by_worker(conn): + agent = _agent(conn) + r1 = repo.create_run(conn, agent["id"], "s1", "worker-a", "spawn") + r2 = repo.create_run(conn, agent["id"], "s2", "worker-b", "spawn") + repo.finish_run(conn, r1["id"], "completed") + running = repo.list_running_runs(conn) + assert [r["id"] for r in running] == [r2["id"]] + assert repo.list_running_runs(conn, worker_id="worker-a") == [] + assert [r["id"] for r in repo.list_running_runs(conn, worker_id="worker-b")] == [r2["id"]] + + +def test_latest_run_and_agent_session(conn): + agent = _agent(conn) + repo.create_run(conn, agent["id"], "s1", "w", "spawn") + latest = repo.create_run(conn, agent["id"], "s1", "w", "resume") + assert repo.get_latest_run(conn, agent["id"])["id"] == latest["id"] + + repo.set_agent_session(conn, agent["id"], "s1", "worker-a") + updated = repo.get_agent_by_id(conn, agent["id"]) + assert updated["session_id"] == "s1" + assert updated["worker_id"] == "worker-a" + + +def test_agent_status_crashed_allowed(conn): + """Migration 0008 widened ck_agents_status — 'crashed' must insert cleanly.""" + agent = _agent(conn) + repo.set_agent_status(conn, agent["id"], "crashed") + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "crashed" + + +# ---------------------------------------------------------------------- agent_events + + +def test_events_cursor_pagination(conn): + agent = _agent(conn) + run = repo.create_run(conn, agent["id"], "sid", "w", "spawn") + for seq in range(1, 4): + repo.insert_agent_event( + conn, agent["id"], run["id"], seq=seq, type="assistant", + payload={"n": seq}, session_id="sid", + ) + first = repo.list_agent_events(conn, agent["id"], limit=2) + assert [e["payload"]["n"] for e in first] == [1, 2] + rest = repo.list_agent_events(conn, agent["id"], after_id=first[-1]["id"]) + assert [e["payload"]["n"] for e in rest] == [3] + assert repo.list_agent_events(conn, agent["id"], after_id=rest[-1]["id"]) == [] + + +# ------------------------------------------------------------------ session_archives + + +def test_session_archive_upsert_replaces(conn): + agent = _agent(conn) + repo.upsert_session_archive(conn, agent["id"], "s1", b"v1") + repo.upsert_session_archive(conn, agent["id"], "s2", b"v2-longer") + row = repo.get_session_archive(conn, agent["id"]) + assert row["session_id"] == "s2" + assert bytes(row["archive"]) == b"v2-longer" + assert row["bytes"] == len(b"v2-longer") + + +def test_session_archive_missing(conn): + agent = _agent(conn) + assert repo.get_session_archive(conn, agent["id"]) is None + + +# ------------------------------------------------------------------------- workers + + +def test_worker_heartbeat_upsert_and_staleness(conn): + repo.upsert_worker_heartbeat(conn, "worker-a", hostname="h1", pid=42, max_runs=4) + repo.upsert_worker_heartbeat(conn, "worker-a", hostname="h1", pid=42, active_runs=2) + + future = datetime.now(UTC) + timedelta(seconds=1) + stale = repo.list_stale_workers(conn, cutoff=future) + assert [w["id"] for w in stale] == ["worker-a"] + assert stale[0]["active_runs"] == 2 + + past = datetime.now(UTC) - timedelta(minutes=5) + assert repo.list_stale_workers(conn, cutoff=past) == [] + + +# ------------------------------------------------------------------- claim filters + + +def test_claim_respects_target_worker(conn): + repo.create_project(conn, "p", "/projects/p") + pinned = repo.enqueue_command(conn, "login_submit", payload={"code": "x"}, + target_worker="worker-b") + # worker-a can't see worker-b's pinned command... + assert repo.claim_next_command(conn, "worker-a") is None + # ...but worker-b claims it. + claimed = repo.claim_next_command(conn, "worker-b") + assert claimed["id"] == pinned["id"] + assert claimed["status"] == "running" + + +def test_claim_excludes_types_when_slots_full(conn): + repo.create_project(conn, "p", "/projects/p") + spawn_cmd = repo.enqueue_command(conn, "spawn", project_id="p", agent_name="a") + sync_cmd = repo.enqueue_command(conn, "sync", project_id="p") + # With run-starting types excluded, the older spawn is skipped for the sync. + claimed = repo.claim_next_command( + conn, "w", types_excluded=("spawn", "resume", "mise_init") + ) + assert claimed["id"] == sync_cmd["id"] + # The spawn stays queued for a worker with a free slot. + assert repo.claim_next_command(conn, "w2")["id"] == spawn_cmd["id"] + + +def test_delete_agent_cascades_headless_rows(conn): + agent = _agent(conn) + run = repo.create_run(conn, agent["id"], "sid", "w", "spawn") + repo.insert_agent_event(conn, agent["id"], run["id"], seq=1, type="system", payload={}) + repo.upsert_session_archive(conn, agent["id"], "sid", b"data") + + assert repo.delete_agent(conn, "p", agent["name"]) is True + assert repo.get_latest_run(conn, agent["id"]) is None + assert repo.list_agent_events(conn, agent["id"]) == [] + assert repo.get_session_archive(conn, agent["id"]) is None From 650f376934eece8ecdc11f122976ace09c3f1444 Mon Sep 17 00:00:00 2001 From: Wyatt Date: Tue, 21 Jul 2026 22:55:28 -0400 Subject: [PATCH 2/4] feat(control): flag-gated headless runner with cross-worker resume (phase 2) Wires the phase-1 headless machinery behind runner=headless (default stays tmux; legacy agents, session_id null, keep the tmux paths): - spawn: branches tmux vs headless.launch; extracts _agent_env (shared with resume - a headless resume is a new process needing identity/ credential env); headless spawns require a task (no idle-REPL mode), enforced at spawn and as a 400 in the API - resume: headless path materializes the session archive from the DB onto whichever worker claimed the command, then claude -p --resume; falls back to a fresh session with DB-re-injected context (visible worker event) when no transcript survives anywhere; refuses while a run is live. Undeliverable resumes now raise -> command FAILED, fixing silent input loss on both runners - kill: headless path flags cancel_requested; the owning supervisor SIGTERMs its own child (cross-worker safe) - worker: stable per-container ids, DB-driven run slots (full workers skip claiming spawn/resume/mise_init, leaving them for less-loaded workers), credsync refresh in the main loop - settings_gen: permissions block (defaultMode + allowlist) for headless runs - -p auto-denies anything that would prompt; hooks remain the hard gate - credsync + migration 0009 (runtime_secrets): login publishes the Fernet-encrypted claude credential bundle; every worker materializes it (merge-safe for local trust state); login_submit pinned to the login_start worker via commands.target_worker Suite 270 -> 290 green, including the cross-worker resume linchpin (clean-HOME materialize + --resume against the fake binary). --- .gitignore | 3 + src/handler/api/routes/agents.py | 11 +- src/handler/api/routes/login.py | 10 +- src/handler/config.py | 4 + src/handler/control/credsync.py | 174 +++++++++++ src/handler/control/settings_gen.py | 21 +- src/handler/control/spawn.py | 193 +++++++++++-- src/handler/control/worker.py | 73 ++++- src/handler/db/repository.py | 32 +++ src/handler/db/tables.py | 12 + .../versions/0009_runtime_secrets.py | 39 +++ tests/test_credsync.py | 110 +++++++ tests/test_headless_run.py | 272 ++++++++++++++++++ tests/test_worker.py | 24 +- tests/test_worker_concurrency.py | 81 ++++++ 15 files changed, 1016 insertions(+), 43 deletions(-) create mode 100644 src/handler/control/credsync.py create mode 100644 src/handler/migrations/versions/0009_runtime_secrets.py create mode 100644 tests/test_credsync.py create mode 100644 tests/test_headless_run.py create mode 100644 tests/test_worker_concurrency.py diff --git a/.gitignore b/.gitignore index e42ddb6..ff72444 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml .omc/ + +# Handler runtime artifacts +/handler.db diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py index 7de33e3..056eae8 100644 --- a/src/handler/api/routes/agents.py +++ b/src/handler/api/routes/agents.py @@ -12,6 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Connection from sqlalchemy.exc import IntegrityError +from ...config import get_settings from ...db import repository as repo from ..deps import db_conn, require_admin, require_auth from ..schemas import AgentIn, AgentOut, CheckmarkOut, CommandOut, LogEntryOut, SpawnIn @@ -63,13 +64,21 @@ def create_agent(project: str, body: AgentIn, conn: Connection = Depends(db_conn dependencies=[Depends(require_admin)], ) def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_conn)) -> dict: - """Enqueue a spawn; the worker creates the agent row + tmux session and reports back.""" + """Enqueue a spawn; the worker creates the agent row + claude process and reports back.""" _require_project(conn, project) if repo.get_agent_by_name(conn, project, body.name) is not None: raise HTTPException( status.HTTP_409_CONFLICT, detail=f"agent '{body.name}' already exists in project '{project}'", ) + if get_settings().runner == "headless" and not body.task: + # A tmux agent can idle at the REPL awaiting input; a headless `claude -p` run + # with no prompt exits immediately. Reject here (400) instead of letting the + # command fail asynchronously in the worker. + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail="a task is required: the headless runner has no idle-REPL mode", + ) payload = body.model_dump(exclude={"name"}, exclude_none=True) return repo.enqueue_command( conn, diff --git a/src/handler/api/routes/login.py b/src/handler/api/routes/login.py index a682d02..fbec472 100644 --- a/src/handler/api/routes/login.py +++ b/src/handler/api/routes/login.py @@ -43,10 +43,18 @@ def enqueue_login_start(conn: Connection = Depends(db_conn)) -> dict: dependencies=[Depends(require_admin)], ) def enqueue_login_submit(body: LoginSubmitIn, conn: Connection = Depends(db_conn)) -> dict: - """Feed the pasted authorization code back into the waiting login session.""" + """Feed the pasted authorization code back into the waiting login session. + + Pinned to the worker that ran ``login_start`` — the live tmux login session exists + only in that container, so with multiple workers any other claimant would find + nothing to paste into. No prior login_start leaves the pin empty (single-worker + deployments behave exactly as before). + """ + started = repo.get_latest_finished_command(conn, "login_start") return repo.enqueue_command( conn, "login_submit", payload={"code": body.code}, requested_by="operator:web", + target_worker=started["claimed_by"] if started else None, ) diff --git a/src/handler/config.py b/src/handler/config.py index eaebd14..bb618a7 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -95,6 +95,10 @@ class Settings(BaseSettings): def protected_branch_set(self) -> set[str]: return {b.strip() for b in self.protected_branches.split(",") if b.strip()} + @property + def headless_allowed_tools_list(self) -> list[str]: + return [t.strip() for t in self.headless_allowed_tools.split(",") if t.strip()] + @property def cors_origin_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",") if o.strip()] diff --git a/src/handler/control/credsync.py b/src/handler/control/credsync.py new file mode 100644 index 0000000..f253b41 --- /dev/null +++ b/src/handler/control/credsync.py @@ -0,0 +1,174 @@ +"""Distribute claude's OAuth credentials to every worker through the database. + +The interactive ``/login`` flow (see :mod:`~handler.control.login`) completes on exactly +one worker container and writes credentials under that container's ``$HOME``. With +multiple headless workers, the others need those files too — and the deployment rule is +"no shared filesystems, the DB is the single source of truth". So: + +- ``upload()`` bundles the credential files, encrypts the bundle with the existing + Fernet secret store (``HANDLER_SECRET_KEY``), and upserts it into ``runtime_secrets``. + Called after a confirmed login, and after a run if claude refreshed the token on disk. +- ``refresh()`` (workers, at startup and periodically) materializes the bundle locally + when the DB copy is newer than what this worker last saw. ``~/.claude.json`` is merged + (only credential-ish top-level keys are taken) so a worker's own onboarding/trust + state written by ``claude_config.ensure_onboarded`` survives; the pure credential file + is written verbatim. + +Everything is best-effort and silently off when no ``HANDLER_SECRET_KEY`` is configured +(single-worker deployments work exactly as before). +""" + +from __future__ import annotations + +import json +import os + +from .. import secretstore +from ..db import repository as repo +from ..db.engine import connection + +SECRET_KEY = "claude_credentials" + +# ~/.claude.json keys that belong to the *account*, not this machine's UI/trust state. +_ACCOUNT_KEYS = ("oauthAccount", "userID", "organization", "account") + + +def _credential_files() -> dict[str, str]: + """Relative path -> absolute path of every claude credential file we bundle.""" + home = os.path.expanduser("~") + return { + ".claude.json": os.path.join(home, ".claude.json"), + ".claude/.credentials.json": os.path.join(home, ".claude", ".credentials.json"), + } + + +def fingerprint() -> tuple: + """(path, mtime_ns, size) of the on-disk credential files — cheap change detection.""" + fp = [] + for path in sorted(_credential_files().values()): + try: + st = os.stat(path) + fp.append((path, st.st_mtime_ns, st.st_size)) + except OSError: + continue + return tuple(fp) + + +def upload() -> bool: + """Encrypt the local credential files into ``runtime_secrets``. Returns whether a + bundle was stored (False when disabled or there is nothing to store).""" + if not secretstore.enabled(): + return False + bundle: dict[str, str] = {} + for rel, path in _credential_files().items(): + try: + with open(path) as fh: + bundle[rel] = fh.read() + except OSError: + continue + if not bundle: + return False + value_enc = secretstore.encrypt(json.dumps(bundle)) + with connection() as conn: + repo.upsert_runtime_secret(conn, SECRET_KEY, value_enc) + return True + + +def _merge_claude_json(path: str, incoming: str) -> None: + """Take the account keys from ``incoming`` into ``path``, preserving local state. + + A worker's ``~/.claude.json`` also carries per-directory trust + onboarding flags for + *its* clones; clobbering those would re-wedge spawns on the trust dialog. + """ + try: + new_data = json.loads(incoming) + except (ValueError, TypeError): + return + if not isinstance(new_data, dict): + return + current: dict = {} + if os.path.exists(path): + try: + with open(path) as fh: + current = json.load(fh) + except (OSError, ValueError): + current = {} + if not isinstance(current, dict): + current = {} + if not current: + merged = new_data + else: + merged = current + for key in _ACCOUNT_KEYS: + if key in new_data: + merged[key] = new_data[key] + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = f"{path}.credsync.tmp" + with open(tmp, "w") as fh: + json.dump(merged, fh, indent=2) + os.replace(tmp, path) + + +class _State: + """Per-process sync cursor: the DB updated_at we last materialized, and the local + file fingerprint after the last upload/materialize (to detect claude refreshing + the token mid-run so the next refresh() re-uploads).""" + + def __init__(self) -> None: + self.seen_updated_at = None + self.last_fingerprint: tuple | None = None + + +_state = _State() + + +def refresh() -> str | None: + """One sync pass; returns "materialized", "uploaded", or None (no-op). + + - A DB bundle newer than what we've seen wins: materialize it locally. + - Otherwise, local credential files that changed since our last pass (a login on + this worker, or claude refreshing a token during a run) get uploaded. + """ + if not secretstore.enabled(): + return None + with connection() as conn: + row = repo.get_runtime_secret(conn, SECRET_KEY) + + if row is not None and row["updated_at"] != _state.seen_updated_at: + try: + bundle = json.loads(secretstore.decrypt(row["value_enc"])) + except (secretstore.SecretStoreError, ValueError): + return None + files = _credential_files() + for rel, content in bundle.items(): + path = files.get(rel) + if path is None: + continue # never write outside the known credential set + if rel == ".claude.json": + _merge_claude_json(path, content) + else: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = f"{path}.credsync.tmp" + with open(tmp, "w") as fh: + fh.write(content) + os.replace(tmp, path) + _state.seen_updated_at = row["updated_at"] + _state.last_fingerprint = fingerprint() + return "materialized" + + current = fingerprint() + if current and current != _state.last_fingerprint: + if upload(): + _state.last_fingerprint = current + with connection() as conn: + stored = repo.get_runtime_secret(conn, SECRET_KEY) + _state.seen_updated_at = stored["updated_at"] if stored else None + return "uploaded" + return None + + +def note_local_write() -> None: + """Record that this process just changed local credentials deliberately (e.g. the + claude_config onboarding merge at spawn), so refresh() doesn't misread the mtime + bump as a new login and ping-pong uploads between workers.""" + _state.last_fingerprint = fingerprint() diff --git a/src/handler/control/settings_gen.py b/src/handler/control/settings_gen.py index c268e4a..f8c1ea1 100644 --- a/src/handler/control/settings_gen.py +++ b/src/handler/control/settings_gen.py @@ -12,6 +12,8 @@ import json import os import sys +from ..config import get_settings + def _hook_command(event: str) -> str: # Use the exact interpreter the control layer runs under, so the hook resolves the @@ -19,8 +21,8 @@ def _hook_command(event: str) -> str: return f"{sys.executable} -m handler.hooks {event}" -def build_settings() -> dict: - return { +def build_settings(headless: bool = False) -> dict: + settings = { "hooks": { "Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}], "SessionEnd": [ @@ -37,13 +39,24 @@ def build_settings() -> dict: ], } } + if headless: + # ``claude -p`` never prompts — anything that would ask for permission is + # auto-denied. The allowlist is therefore what lets normal work (git, mise, the + # project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard + # gate either way, since a hook deny overrides any allow. + s = get_settings() + settings["permissions"] = { + "defaultMode": s.headless_permission_mode, + "allow": s.headless_allowed_tools_list, + } + return settings -def write_settings(working_dir: str) -> str: +def write_settings(working_dir: str, headless: bool = False) -> str: """Write ``.claude/settings.json`` under the agent's working dir; return its path.""" claude_dir = os.path.join(working_dir, ".claude") os.makedirs(claude_dir, exist_ok=True) path = os.path.join(claude_dir, "settings.json") with open(path, "w") as fh: - json.dump(build_settings(), fh, indent=2) + json.dump(build_settings(headless=headless), fh, indent=2) return path diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 21f1573..008fd7c 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -17,8 +17,10 @@ from ..db.engine import connection from . import ( claude_config, credentials, + credsync, forge, gitops, + headless, mise, reposync, settings_gen, @@ -84,6 +86,7 @@ def spawn( role: str | None = None, require_tests: bool = True, mise_init: bool = False, + worker_id: str | None = None, ) -> dict: """Create and launch an agent. Returns the agent row. @@ -91,8 +94,13 @@ def spawn( it off, because a project with no ``.mise.toml`` yet is exactly what it exists to fix. ``mise_init`` marks the launched agent (via ``HANDLER_MISE_INIT``) so its hooks enforce the bootstrap contract — create the test task, commit, and push — instead of the normal - test gate. + test gate. ``worker_id`` identifies the calling worker container (headless runs record + it on the run row; the CLI defaults to a pid-scoped id). """ + if get_settings().runner == "headless" and not task: + # A tmux agent without a task idles at the REPL waiting for input; ``claude -p`` + # has no such mode — an empty prompt would exit immediately having done nothing. + raise SpawnError("a headless agent requires a task (the runner is 'headless')") sync_note = None with connection() as conn: project = repo.get_project(conn, project_id) @@ -146,14 +154,56 @@ def spawn( role=role, ) - settings_path = settings_gen.write_settings(working_dir) + headless_run = get_settings().runner == "headless" + settings_path = settings_gen.write_settings(working_dir, headless=headless_run) + env = _agent_env(project, agent, token, role=role, mise_init=mise_init) + # Verify the pinned forge version, if one is configured. Non-fatal: a version drift + # is recorded as a warning rather than blocking the spawn, since not every agent + # touches forge and the base image is the real pin (README 3.6, Phase 2). + forge_note = _check_forge_version(working_dir) + + # Mark Claude Code onboarding complete + trust the working dir before launching. The + # tmux path needs both (no human at the TTY to answer the theme/trust screens); + # ``-p`` skips the trust dialog but still reads onboarding state, so keep it for both. + claude_config.ensure_onboarded(working_dir) + credsync.note_local_write() + + if headless_run: + headless.launch( + agent, + kind="spawn", + prompt=task, + settings_path=settings_path, + env=env, + worker_id=worker_id or f"cli-{os.getpid()}", + ) + else: + session = tmux.session_name(project_id, name) + command = _claude_command(task, settings_path) + tmux.new_session(session, cwd=working_dir, command=command, env=env) + agent = {**agent, "forge_note": forge_note, "sync_note": sync_note} + return agent + + +def _agent_env( + project: dict, + agent: dict, + token: str | None, + *, + role: str | None = None, + mise_init: bool = False, +) -> dict[str, str]: + """The environment an agent process (and therefore its hooks) runs with: identity, + ``DATABASE_URL``, and resolved forge/git credentials. Shared by spawn and the + headless resume path (a resume is a brand-new process needing the same env).""" env = { - "HANDLER_PROJECT_ID": project_id, - "HANDLER_AGENT_NAME": name, + "HANDLER_PROJECT_ID": project["id"], + "HANDLER_AGENT_NAME": agent["name"], "HANDLER_AGENT_ID": str(agent["id"]), "DATABASE_URL": get_settings().database_url, } + role = role or agent.get("role") if role: env["HANDLER_AGENT_ROLE"] = role if mise_init: @@ -164,28 +214,13 @@ def spawn( with connection() as conn: env.update(credentials.credential_env(token, project.get("git_remote"), conn)) if token: - _install_git_credentials(working_dir, project.get("git_remote"), conn) + _install_git_credentials(agent["working_dir"], project.get("git_remote"), conn) # SSH remotes: pin the agent's git to the server's deploy key, when one is stored. try: env.update(reposync.ssh_env(project.get("git_remote"), conn)) except reposync.SyncError as exc: raise SpawnError(str(exc)) from exc - - # Verify the pinned forge version, if one is configured. Non-fatal: a version drift - # is recorded as a warning rather than blocking the spawn, since not every agent - # touches forge and the base image is the real pin (README 3.6, Phase 2). - forge_note = _check_forge_version(working_dir) - - # Mark Claude Code onboarding complete + trust the working dir before launching, so the - # detached agent boots straight to the REPL instead of wedging on the first-run theme - # picker / trust prompt with no human at the tmux TTY to answer it. - claude_config.ensure_onboarded(working_dir) - - session = tmux.session_name(project_id, name) - command = _claude_command(task, settings_path) - tmux.new_session(session, cwd=working_dir, command=command, env=env) - agent = {**agent, "forge_note": forge_note, "sync_note": sync_note} - return agent + return env def _check_forge_version(working_dir: str) -> str | None: @@ -203,20 +238,128 @@ def kill(project_id: str, name: str) -> None: agent = repo.get_agent_by_name(conn, project_id, name) if agent is None: raise SpawnError(f"agent '{name}' not found in project '{project_id}'") + if agent.get("session_id"): + # Headless agent: flag the running run for cancel; the owning worker's + # supervisor polls the flag and SIGTERMs its own child (cross-worker safe — + # nobody signals a process they don't own). No running run = already dead. + run = repo.get_latest_run(conn, agent["id"]) + if run is not None and run["status"] == "running": + repo.request_run_cancel(conn, run["id"]) + repo.set_agent_status(conn, agent["id"], "done") + return session = tmux.session_name(project_id, name) if tmux.has_session(session): tmux.kill_session(session) repo.set_agent_status(conn, agent["id"], "done") -def resume(agent: dict, answer: str) -> tuple[bool, str]: - """Feed an operator's answer back to a live agent. +def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool, str]: + """Feed an operator's answer back to an agent. - The seam the API's ``/resume`` route calls (and the one tests mock). Sends the - answer into the agent's tmux session so the waiting ``claude`` process receives it. + The seam the API's ``/resume`` route calls (and the one tests mock). Legacy tmux + agents (``session_id`` null) get the answer typed into their live session; headless + agents get a brand-new ``claude -p --resume`` run on this worker, with the session + transcript materialized from the DB archive first so any worker can serve the resume. """ + if agent.get("session_id"): + return _resume_headless(agent, answer, worker_id or f"cli-{os.getpid()}") session = tmux.session_name(agent["project_id"], agent["name"]) if not tmux.has_session(session): return False, f"no live session '{session}' to resume" tmux.send_keys(session, answer) return True, f"answer delivered to session '{session}'" + + +def _resume_headless(agent: dict, answer: str, worker_id: str) -> tuple[bool, str]: + """Launch a ``--resume`` run for a headless agent, materializing its session first. + + Refuses while a run is still live (two concurrent processes on one session would + corrupt it). When neither this worker nor the DB has the transcript — the owning + worker died before its first archive — falls back to a *fresh* session whose prompt + re-injects context from the DB (checkmark + open question + answer), recorded as a + ``worker`` event so the UI shows the degraded continuity. + """ + with connection() as conn: + run = repo.get_latest_run(conn, agent["id"]) + if run is not None and run["status"] == "running": + return False, "agent already has a live run; wait for it to finish or kill it" + project = repo.get_project(conn, agent["project_id"]) + archive = repo.get_session_archive(conn, agent["id"]) + if project is None: + return False, f"project '{agent['project_id']}' not registered" + + working_dir = agent["working_dir"] + settings_path = settings_gen.write_settings(working_dir, headless=True) + try: + token = None + with connection() as conn: + token = credentials.resolve_for_project(project, conn) + except credentials.CredentialError as exc: + return False, str(exc) + env = _agent_env(project, agent, token) + + transcript = headless.session_dir(working_dir) / f"{agent['session_id']}.jsonl" + if archive is not None: + try: + headless.materialize_session(working_dir, bytes(archive["archive"])) + except (OSError, ValueError) as exc: + return False, f"could not materialize session archive: {exc}" + elif not transcript.exists(): + return _resume_reinjected(agent, answer, settings_path, env, worker_id) + + run = headless.launch( + agent, + kind="resume", + prompt=answer, + settings_path=settings_path, + env=env, + worker_id=worker_id, + ) + return True, f"headless resume run {run['id']} started for session {agent['session_id']}" + + +def _resume_reinjected( + agent: dict, answer: str, settings_path: str, env: dict, worker_id: str +) -> tuple[bool, str]: + """Degraded resume: no transcript anywhere, so start a fresh session with the + context rebuilt from the DB. Continuity is approximate — say so in the event log.""" + with connection() as conn: + checkmark = repo.get_checkmark(conn, agent["id"]) + recent = repo.get_log(conn, agent["id"], limit=5) + parts = [ + "You are resuming work you started in an earlier session whose transcript is " + "unavailable. Reconstruct context from your checkpoint below, then continue.", + ] + if checkmark: + if checkmark.get("where_it_stopped"): + parts.append(f"Where you stopped: {checkmark['where_it_stopped']}") + if checkmark.get("next_steps"): + parts.append(f"Planned next steps: {checkmark['next_steps']}") + if checkmark.get("open_question"): + parts.append(f"You had asked: {checkmark['open_question']}") + for entry in reversed(recent): + if entry.get("summary"): + parts.append(f"Earlier log: {entry['summary']}") + parts.append(f"The operator's answer/instruction: {answer}") + run = headless.launch( + agent, + kind="spawn", # a genuinely new session (new UUID) — --resume has nothing to load + prompt="\n\n".join(parts), + settings_path=settings_path, + env=env, + worker_id=worker_id, + ) + with connection() as conn: + repo.insert_agent_event( + conn, + agent["id"], + run["id"], + seq=0, + type="worker", + payload={ + "notice": "resume without transcript — context re-injected from DB", + "previous_session_id": agent["session_id"], + }, + session_id=run["session_id"], + ) + return True, f"transcript unavailable; started fresh run {run['id']} with re-injected context" diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index 2adc1bd..aeff4e2 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -15,12 +15,24 @@ returns a JSON-safe result or raises); ``drain``/``run`` are the claim+finish pl from __future__ import annotations import os +import secrets +import socket import time from datetime import UTC, datetime, timedelta +from ..config import get_settings from ..db import repository as repo from ..db.engine import connection -from . import gitops, login, poller, reposync, skills_gen, spawn, tmux +from . import credsync, gitops, login, poller, reposync, skills_gen, spawn, tmux + +# Command types that launch a claude run and therefore need a free slot on this worker. +# A worker with all slots busy leaves these queued for a less-loaded worker to claim. +_RUN_COMMANDS = ("spawn", "resume", "mise_init") + + +def make_worker_id() -> str: + """A stable-for-this-process, unique-across-containers worker id.""" + return f"worker-{socket.gethostname()}-{os.getpid()}-{secrets.token_hex(2)}" class CommandError(Exception): @@ -43,6 +55,7 @@ def _cmd_spawn(command: dict) -> dict: worktree_branch=p.get("worktree"), task=p.get("task"), role=p.get("role"), + worker_id=command.get("claimed_by"), ) result = { "agent_id": agent["id"], @@ -73,11 +86,15 @@ def _cmd_resume(command: dict) -> dict: agent = repo.get_agent_by_name(conn, command["project_id"], name) if agent is None: raise CommandError(f"agent '{name}' not found in project '{command['project_id']}'") - ok, detail = spawn.resume(agent, answer) - if ok: - with connection() as conn: - repo.set_agent_status(conn, agent["id"], "working") - return {"resumed": ok, "detail": detail} + ok, detail = spawn.resume(agent, answer, worker_id=command.get("claimed_by")) + if not ok: + # A resume that couldn't be delivered is a FAILED command, not a quiet no-op — + # the operator's input must never vanish silently (the old tmux path typed into + # dead panes and reported success). + raise CommandError(detail) + with connection() as conn: + repo.set_agent_status(conn, agent["id"], "working") + return {"resumed": True, "detail": detail} def _record_verdict(command: dict, status: str) -> dict: @@ -219,6 +236,13 @@ def _cmd_login_submit(command: dict) -> dict: # Surface the pane tail so the operator can see why claude rejected the code. detail = result.get("output") or "claude did not confirm a successful login" raise CommandError(f"login not confirmed — {detail}") + # Publish the fresh credentials so every other worker container can materialize + # them (multi-worker: the login ran here, but any worker may run the next agent). + try: + result["credentials_published"] = credsync.upload() + except Exception as exc: # noqa: BLE001 - login succeeded; publishing is best-effort + result["credentials_published"] = False + result["credsync_note"] = str(exc) return result @@ -360,7 +384,9 @@ def drain(worker_id: str, limit: int | None = None) -> int: processed = 0 while limit is None or processed < limit: with connection() as conn: - command = repo.claim_next_command(conn, worker_id) + command = repo.claim_next_command( + conn, worker_id, types_excluded=_full_slot_exclusions(worker_id) + ) if command is None: break _run_one(command) @@ -368,22 +394,43 @@ def drain(worker_id: str, limit: int | None = None) -> int: return processed +def _full_slot_exclusions(worker_id: str) -> tuple[str, ...]: + """Command types this worker must not claim right now. + + Headless runs are supervised in-process, so a worker at ``max_concurrent_runs`` skips + claiming run-starting commands — they stay queued for a worker with a free slot. Slot + accounting is DB-driven (this worker's ``running`` runs), so it needs no in-memory + registry and survives restarts (a fresh process gets a fresh worker id; stale rows + belong to the old id and are the reaper's problem). Tmux runs are fire-and-forget and + never consume a slot. + """ + if get_settings().runner != "headless": + return () + with connection() as conn: + active = len(repo.list_running_runs(conn, worker_id=worker_id)) + if active >= get_settings().max_concurrent_runs: + return _RUN_COMMANDS + return () + + def run( worker_id: str | None = None, poll_interval: float = 2.0, ci_interval: float = 30.0, capture_interval: float = 2.0, + credsync_interval: float = 30.0, iterations: int | None = None, ) -> None: """The control-container main loop: drain the command queue, snapshot live agent - output, and sweep CI periodically. + output, sync claude credentials, and sweep CI periodically. ``iterations`` bounds the loop for tests; production runs unbounded. Sleeps ``poll_interval`` only when a pass found no commands, so bursts drain promptly. """ - worker_id = worker_id or f"worker-{os.getpid()}" + worker_id = worker_id or make_worker_id() last_ci = 0.0 last_capture = 0.0 + last_credsync = 0.0 count = 0 while iterations is None or count < iterations: try: @@ -398,6 +445,14 @@ def run( except Exception: # noqa: BLE001 - a capture hiccup must not kill the worker pass last_capture = now + if credsync_interval > 0 and (last_credsync == 0.0 or now - last_credsync >= credsync_interval): + # First pass runs immediately: a fresh worker container must materialize the + # claude credentials before it claims its first spawn. + try: + credsync.refresh() + except Exception: # noqa: BLE001 - cred sync must not kill the worker + pass + last_credsync = now if ci_interval > 0 and now - last_ci >= ci_interval: try: poller.sweep() diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py index ecf4dc0..3684c27 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -31,6 +31,7 @@ from .tables import ( forge_hosts, log_entries, projects, + runtime_secrets, schedules, session_archives, shared_context, @@ -874,3 +875,34 @@ def get_session_archive(conn: Connection, agent_id: int) -> dict | None: select(session_archives).where(session_archives.c.agent_id == agent_id) ).first() return _row_to_dict(row) + + +def upsert_runtime_secret(conn: Connection, key: str, value_enc: str) -> None: + """Store an encrypted control-plane secret (the caller encrypts via secretstore).""" + now = _now() + result = conn.execute( + runtime_secrets.update() + .where(runtime_secrets.c.key == key) + .values(value_enc=value_enc, updated_at=now) + ) + if result.rowcount == 0: + conn.execute( + runtime_secrets.insert().values(key=key, value_enc=value_enc, updated_at=now) + ) + + +def get_runtime_secret(conn: Connection, key: str) -> dict | None: + row = conn.execute(select(runtime_secrets).where(runtime_secrets.c.key == key)).first() + return _row_to_dict(row) + + +def get_latest_finished_command(conn: Connection, type: str) -> dict | None: + """The most recent ``done`` command of a type (login pinning reads login_start's + ``claimed_by`` to route login_submit to the same worker container).""" + row = conn.execute( + select(commands) + .where(commands.c.type == type, commands.c.status == "done") + .order_by(commands.c.id.desc()) + .limit(1) + ).first() + return _row_to_dict(row) diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 843839c..de180a3 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -243,6 +243,18 @@ forge_hosts = Table( CheckConstraint(_in("forge_type", FORGE_TYPES), name="ck_forge_hosts_type"), ) +# Control-plane key/value secrets, Fernet-encrypted like forge_hosts tokens (see +# ``handler.secretstore``). Holds the claude OAuth credential bundle so every worker +# container can materialize it locally — the login flow runs on ONE worker, but all of +# them need to run ``claude``. Never exposed by any API route. +runtime_secrets = Table( + "runtime_secrets", + metadata, + Column("key", String, primary_key=True), + Column("value_enc", String, nullable=False), + Column("updated_at", PortableTimestamp, nullable=False, server_default=func.now()), +) + # Worker registry + heartbeat (headless runner). Each worker container upserts its row # every loop pass; the reaper marks a worker's running runs (and their agents) ``crashed`` # when ``heartbeat_at`` goes stale — the positive-liveness replacement for tmux scraping. diff --git a/src/handler/migrations/versions/0009_runtime_secrets.py b/src/handler/migrations/versions/0009_runtime_secrets.py new file mode 100644 index 0000000..8d26401 --- /dev/null +++ b/src/handler/migrations/versions/0009_runtime_secrets.py @@ -0,0 +1,39 @@ +"""runtime_secrets: encrypted control-plane key/value store + +Revision ID: 0009_runtime_secrets +Revises: 0008_headless_runs +Create Date: 2026-07-21 + +A small Fernet-encrypted key/value table (values encrypted with ``HANDLER_SECRET_KEY`` +before insert, same policy as ``forge_hosts.token_enc``). First use: the claude OAuth +credential bundle — the interactive ``/login`` flow completes on one worker container, +and every other worker materializes the bundle from here at startup/refresh so any of +them can run headless ``claude -p`` without shared files. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +from handler.db.types import PortableTimestamp + +revision: str = "0009_runtime_secrets" +down_revision: str | None = "0008_headless_runs" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "runtime_secrets", + sa.Column("key", sa.String(), primary_key=True), + sa.Column("value_enc", sa.String(), nullable=False), + sa.Column("updated_at", PortableTimestamp, nullable=False, server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("runtime_secrets") diff --git a/tests/test_credsync.py b/tests/test_credsync.py new file mode 100644 index 0000000..cb748c4 --- /dev/null +++ b/tests/test_credsync.py @@ -0,0 +1,110 @@ +"""Credential distribution through runtime_secrets: the login completes on one worker, +every other worker materializes the encrypted bundle from the DB — no shared files.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest +from cryptography.fernet import Fernet + +from handler.control import credsync +from handler.db import repository as repo +from handler.db.engine import get_engine + + +@pytest.fixture +def secret_env(env, monkeypatch): + from handler import config + + monkeypatch.setenv("HANDLER_SECRET_KEY", Fernet.generate_key().decode()) + config.get_settings.cache_clear() + credsync._state.__init__() # fresh sync cursor per test + yield env + config.get_settings.cache_clear() + + +def _write_local_credentials(home: Path) -> None: + (home / ".claude").mkdir(parents=True, exist_ok=True) + (home / ".claude.json").write_text( + json.dumps({"oauthAccount": {"email": "op@example.com"}, "theme": "light"}) + ) + (home / ".claude" / ".credentials.json").write_text('{"token": "secret-oauth-token"}') + + +def test_upload_stores_encrypted_bundle(secret_env, tmp_path): + _write_local_credentials(tmp_path) + assert credsync.upload() is True + with get_engine().begin() as conn: + row = repo.get_runtime_secret(conn, credsync.SECRET_KEY) + assert row is not None + # Ciphertext at rest — the raw token must not appear in the DB value. + assert "secret-oauth-token" not in row["value_enc"] + + +def test_refresh_materializes_on_fresh_worker(secret_env, tmp_path, monkeypatch): + _write_local_credentials(tmp_path) + assert credsync.upload() is True + + other_home = tmp_path / "worker-b" + other_home.mkdir() + monkeypatch.setenv("HOME", str(other_home)) + credsync._state.__init__() # worker B's process state + + assert credsync.refresh() == "materialized" + creds = json.loads((other_home / ".claude" / ".credentials.json").read_text()) + assert creds["token"] == "secret-oauth-token" + data = json.loads((other_home / ".claude.json").read_text()) + assert data["oauthAccount"]["email"] == "op@example.com" + # A second pass is a no-op — nothing changed anywhere. + assert credsync.refresh() is None + + +def test_materialize_merges_claude_json_preserving_local_state(secret_env, tmp_path, monkeypatch): + _write_local_credentials(tmp_path) + credsync.upload() + + other_home = tmp_path / "worker-c" + (other_home / ".claude").mkdir(parents=True) + (other_home / ".claude.json").write_text( + json.dumps( + { + "hasCompletedOnboarding": True, + "theme": "dark", + "projects": {"/projects/p/a": {"hasTrustDialogAccepted": True}}, + } + ) + ) + monkeypatch.setenv("HOME", str(other_home)) + credsync._state.__init__() + + assert credsync.refresh() == "materialized" + data = json.loads((other_home / ".claude.json").read_text()) + # Account arrived... + assert data["oauthAccount"]["email"] == "op@example.com" + # ...but this worker's own onboarding/trust state (claude_config's writes) survived. + assert data["theme"] == "dark" + assert data["projects"]["/projects/p/a"]["hasTrustDialogAccepted"] is True + + +def test_refresh_uploads_local_change(secret_env, tmp_path): + _write_local_credentials(tmp_path) + assert credsync.refresh() == "uploaded" # bootstrap: local creds, empty DB + # A token refresh on disk (mtime/size change) re-publishes. + os.utime(tmp_path / ".claude" / ".credentials.json", ns=(1, 1)) + assert credsync.refresh() == "uploaded" + + +def test_disabled_without_secret_key(env, tmp_path): + _write_local_credentials(tmp_path) + assert credsync.upload() is False + assert credsync.refresh() is None + + +def test_note_local_write_suppresses_upload(secret_env, tmp_path): + _write_local_credentials(tmp_path) + credsync.note_local_write() + # The deliberate local write (e.g. ensure_onboarded at spawn) is not re-published. + assert credsync.refresh() is None diff --git a/tests/test_headless_run.py b/tests/test_headless_run.py new file mode 100644 index 0000000..ad9600c --- /dev/null +++ b/tests/test_headless_run.py @@ -0,0 +1,272 @@ +"""End-to-end headless runner tests against the fake ``claude`` binary. + +Real subprocesses, real threads, real SQLite: ``headless.launch`` starts +``tests/fixtures/fake_claude.py`` (selected via the ``claude_bin`` setting, the same +seam the tmux fakes used), the supervisor streams its stdout into ``agent_events``, and +the tests assert on what landed in the DB — exactly what the API/UI will read.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import pytest + +from handler.control import headless, settings_gen, spawn +from handler.db import repository as repo +from handler.db.engine import get_engine + +REPO_ROOT = Path(__file__).resolve().parents[1] +FAKE_CLAUDE = str(REPO_ROOT / "tests" / "fixtures" / "fake_claude.py") + + +@pytest.fixture +def headless_env(env, monkeypatch): + from handler import config + + monkeypatch.setenv("CLAUDE_BIN", FAKE_CLAUDE) + monkeypatch.setenv("RUNNER", "headless") + config.get_settings.cache_clear() + yield env + config.get_settings.cache_clear() + + +def _make_agent(tmp_path, name="h1"): + working_dir = tmp_path / "projects" / "p" / name + working_dir.mkdir(parents=True) + with get_engine().begin() as conn: + repo.create_project(conn, "p", str(tmp_path / "projects" / "p")) + agent = repo.create_agent(conn, "p", name, str(working_dir)) + return agent + + +def _wait_for(predicate, timeout=20.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(0.1) + return None + + +def _finished_run(run_id): + def check(): + with get_engine().begin() as conn: + run = repo.get_run(conn, run_id) + return run if run["status"] != "running" else None + + return check + + +def test_spawn_run_streams_events_and_completes(headless_env, tmp_path): + agent = _make_agent(tmp_path) + run = headless.launch( + agent, kind="spawn", prompt="build the thing", + settings_path=str(tmp_path / "settings.json"), env={}, worker_id="w1", + ) + finished = _wait_for(_finished_run(run["id"])) + assert finished is not None, "run never finished" + assert finished["status"] == "completed" + assert finished["exit_code"] == 0 + assert finished["result"]["is_error"] is False + + with get_engine().begin() as conn: + events = repo.list_agent_events(conn, agent["id"]) + updated = repo.get_agent_by_id(conn, agent["id"]) + archive = repo.get_session_archive(conn, agent["id"]) + + types = [e["type"] for e in events] + assert types == ["system", "assistant", "result"] + assert [e["seq"] for e in events] == [1, 2, 3] + # last_output is now derived from assistant text — the log the UI shows is real. + assert updated["last_output"] == "working on: build the thing" + assert updated["status"] == "done" + assert updated["session_id"] == run["session_id"] + assert updated["worker_id"] == "w1" + # The session archive was uploaded at exit for cross-worker resume. + assert archive is not None + assert archive["session_id"] == run["session_id"] + + +def test_failed_run_marks_blocked_with_worker_event(headless_env, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_MODE", "error") + agent = _make_agent(tmp_path, "h-err") + run = headless.launch( + agent, kind="spawn", prompt="boom", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", + ) + finished = _wait_for(_finished_run(run["id"])) + assert finished["status"] == "failed" + assert finished["exit_code"] == 2 + + with get_engine().begin() as conn: + events = repo.list_agent_events(conn, agent["id"]) + updated = repo.get_agent_by_id(conn, agent["id"]) + types = [e["type"] for e in events] + # The unparseable stdout line is preserved verbatim as a raw event, and the runner + # records why the run failed as a worker event. + assert "raw" in types + raw = next(e for e in events if e["type"] == "raw") + assert "this is not json" in raw["payload"]["line"] + worker_ev = next(e for e in events if e["type"] == "worker") + assert worker_ev["payload"]["exit_code"] == 2 + assert updated["status"] == "blocked" + + +def test_hook_written_status_survives_reconciliation(headless_env, tmp_path, monkeypatch): + """Hooks are the status authority: if one set paused_for_input during the run, the + supervisor's exit pass must not overwrite it with done.""" + monkeypatch.setenv("FAKE_CLAUDE_MODE", "slow") + monkeypatch.setenv("FAKE_CLAUDE_SLOW_SECONDS", "1.5") + agent = _make_agent(tmp_path, "h-hook") + run = headless.launch( + agent, kind="spawn", prompt="ask me something", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", + ) + # Simulate a hook (inside the run) recording an open question. + with get_engine().begin() as conn: + repo.set_agent_status(conn, agent["id"], "paused_for_input") + finished = _wait_for(_finished_run(run["id"])) + assert finished["status"] == "completed" + with get_engine().begin() as conn: + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "paused_for_input" + + +def test_cancel_terminates_hanging_run(headless_env, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang") + agent = _make_agent(tmp_path, "h-hang") + run = headless.launch( + agent, kind="spawn", prompt="hang forever", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", + ) + # Give the supervisor a moment to start the process, then flag the cancel the same + # way a cross-worker kill would. + _wait_for(lambda: _events_count(agent["id"]) >= 1) + with get_engine().begin() as conn: + assert repo.request_run_cancel(conn, run["id"]) is True + finished = _wait_for(_finished_run(run["id"]), timeout=30.0) + assert finished is not None, "cancel never terminated the run" + assert finished["status"] == "canceled" + + +def _events_count(agent_id): + with get_engine().begin() as conn: + return len(repo.list_agent_events(conn, agent_id)) + + +def test_kill_headless_agent_requests_cancel(headless_env, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang") + agent = _make_agent(tmp_path, "h-kill") + run = headless.launch( + agent, kind="spawn", prompt="hang", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", + ) + _wait_for(lambda: _events_count(agent["id"]) >= 1) + spawn.kill("p", "h-kill") + finished = _wait_for(_finished_run(run["id"]), timeout=30.0) + assert finished["status"] == "canceled" + with get_engine().begin() as conn: + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "done" + + +def test_cross_worker_resume_materializes_archive(headless_env, tmp_path, monkeypatch): + """The linchpin: worker B resumes a session it never ran, from the DB archive alone.""" + agent = _make_agent(tmp_path, "h-resume") + run = headless.launch( + agent, kind="spawn", prompt="first pass", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="worker-a", + ) + assert _wait_for(_finished_run(run["id"]))["status"] == "completed" + + # "Worker B": a clean HOME with no local claude state at all. + other_home = tmp_path / "worker-b-home" + other_home.mkdir() + monkeypatch.setenv("HOME", str(other_home)) + + with get_engine().begin() as conn: + agent = repo.get_agent_by_id(conn, agent["id"]) # refetch: has session_id now + ok, detail = spawn.resume(agent, "the operator's answer", worker_id="worker-b") + assert ok, detail + + with get_engine().begin() as conn: + resumed = repo.get_latest_run(conn, agent["id"]) + assert resumed["kind"] == "resume" + assert resumed["worker_id"] == "worker-b" + finished = _wait_for(_finished_run(resumed["id"])) + # fake_claude exits 3 if the transcript was NOT materialized where claude looks. + assert finished["status"] == "completed", f"exit={finished['exit_code']}" + assert finished["session_id"] == run["session_id"] # same session, continued + + +def test_resume_without_transcript_reinjects_context(headless_env, tmp_path, monkeypatch): + """Owning worker died before its first archive: resume degrades to a fresh session + with DB-rebuilt context, visibly marked as such.""" + agent = _make_agent(tmp_path, "h-fallback") + with get_engine().begin() as conn: + repo.set_agent_session(conn, agent["id"], "lost-session-uuid", "worker-dead") + repo.upsert_checkmark_row( + conn, agent["id"], status="paused_for_input", + where_it_stopped="mid-refactor", open_question="which db?", + ) + agent = repo.get_agent_by_id(conn, agent["id"]) + + ok, detail = spawn.resume(agent, "use postgres", worker_id="worker-b") + assert ok + assert "re-injected" in detail + + with get_engine().begin() as conn: + new_run = repo.get_latest_run(conn, agent["id"]) + events = repo.list_agent_events(conn, agent["id"]) + assert new_run["kind"] == "spawn" # genuinely new session + assert new_run["session_id"] != "lost-session-uuid" + notice = next(e for e in events if e["type"] == "worker") + assert notice["payload"]["previous_session_id"] == "lost-session-uuid" + finished = _wait_for(_finished_run(new_run["id"])) + assert finished["status"] == "completed" + with get_engine().begin() as conn: + assistant = [ + e for e in repo.list_agent_events(conn, agent["id"]) if e["type"] == "assistant" + ] + # The re-injected prompt (with the operator's answer) reached the fresh claude. + assert any("use postgres" in json.dumps(e["payload"]) for e in assistant) + + +def test_resume_refused_while_run_live(headless_env, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang") + agent = _make_agent(tmp_path, "h-busy") + run = headless.launch( + agent, kind="spawn", prompt="hang", + settings_path=str(tmp_path / "s.json"), env={}, worker_id="w1", + ) + with get_engine().begin() as conn: + agent = repo.get_agent_by_id(conn, agent["id"]) + ok, detail = spawn.resume(agent, "answer", worker_id="w1") + assert not ok + assert "live run" in detail + with get_engine().begin() as conn: + repo.request_run_cancel(conn, run["id"]) + _wait_for(_finished_run(run["id"]), timeout=30.0) + + +def test_headless_settings_include_permissions(headless_env, tmp_path): + path = settings_gen.write_settings(str(tmp_path / "wd"), headless=True) + data = json.loads(Path(path).read_text()) + assert data["permissions"]["defaultMode"] == "acceptEdits" + assert "Bash(git *)" in data["permissions"]["allow"] + assert "hooks" in data # the hard gate is untouched + + tmux_path = settings_gen.write_settings(str(tmp_path / "wd2")) + assert "permissions" not in json.loads(Path(tmux_path).read_text()) + + +def test_api_rejects_empty_task_headless_spawn(headless_env, client, auth): + client.post( + "/projects", json={"id": "p2", "root_dir": "/tmp/p2"}, headers=auth + ) + resp = client.post( + "/projects/p2/agents/spawn", json={"name": "idle"}, headers=auth + ) + assert resp.status_code == 400 + assert "task is required" in resp.json()["detail"] diff --git a/tests/test_worker.py b/tests/test_worker.py index bdbd7e6..6ac847f 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -65,8 +65,8 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch): _seed_project("api") seen = {} - def fake_resume(agent, ans): - seen.update(name=agent["name"], ans=ans) + def fake_resume(agent, ans, worker_id=None): + seen.update(name=agent["name"], ans=ans, worker_id=worker_id) return True, "ok" monkeypatch.setattr(spawn, "resume", fake_resume) @@ -74,11 +74,29 @@ def test_resume_command_feeds_answer_and_sets_working(env, monkeypatch): worker.drain("w") assert _get(cmd["id"])["status"] == "done" - assert seen == {"name": "api", "ans": "Postgres"} + assert seen == {"name": "api", "ans": "Postgres", "worker_id": "w"} with get_engine().begin() as conn: assert repo.get_agent_by_name(conn, "p", "api")["status"] == "working" +def test_resume_command_fails_loudly_when_undeliverable(env, monkeypatch): + """An undeliverable answer must surface as a FAILED command — never a silent 'done' + (the original bug: send-keys into a dead tmux pane reported success).""" + _seed_project("api") + monkeypatch.setattr( + spawn, "resume", lambda agent, ans, worker_id=None: (False, "no live session") + ) + cmd = _enqueue(type="resume", project_id="p", agent_name="api", payload={"answer": "x"}) + + worker.drain("w") + failed = _get(cmd["id"]) + assert failed["status"] == "failed" + assert "no live session" in failed["error"] + with get_engine().begin() as conn: + # The agent must NOT be flipped to working when nothing was delivered. + assert repo.get_agent_by_name(conn, "p", "api")["status"] != "working" + + def test_approve_command_records_operator_verdict_with_head_sha(env, fake_gitops): _seed_project("senior") cmd = _enqueue( diff --git a/tests/test_worker_concurrency.py b/tests/test_worker_concurrency.py new file mode 100644 index 0000000..b96fe9d --- /dev/null +++ b/tests/test_worker_concurrency.py @@ -0,0 +1,81 @@ +"""Slot-aware command claiming: a worker at max_concurrent_runs must leave run-starting +commands queued (for a less-loaded worker) while still processing everything else. Slot +accounting is DB-driven — this worker's ``running`` agent_runs rows — so it needs no +in-memory registry and is exercised here without real subprocesses.""" + +from __future__ import annotations + +import pytest + +from handler.control import spawn, worker +from handler.db import repository as repo +from handler.db.engine import get_engine + + +@pytest.fixture +def headless_env(env, monkeypatch): + from handler import config + + monkeypatch.setenv("RUNNER", "headless") + monkeypatch.setenv("MAX_CONCURRENT_RUNS", "2") + config.get_settings.cache_clear() + yield env + config.get_settings.cache_clear() + + +def _seed(conn_count_running_for=None): + with get_engine().begin() as conn: + repo.create_project(conn, "p", "/tmp/p") + agent = repo.create_agent(conn, "p", "a", "/tmp/p/a") + return agent + + +def _running_run(agent_id, worker_id): + with get_engine().begin() as conn: + return repo.create_run(conn, agent_id, f"sid-{worker_id}", worker_id, "spawn") + + +def test_full_worker_skips_run_commands_but_processes_others(headless_env, monkeypatch): + agent = _seed() + _running_run(agent["id"], "w-full") + _running_run(agent["id"], "w-full") # 2 running == MAX_CONCURRENT_RUNS + + spawned = {} + monkeypatch.setattr( + spawn, "spawn", + lambda project_id, name, **kw: spawned.update(name=name, **kw) + or {"id": 1, "name": name, "working_dir": "/tmp/p/x", "forge_note": None}, + ) + with get_engine().begin() as conn: + spawn_cmd = repo.enqueue_command(conn, "spawn", project_id="p", agent_name="x") + kill_cmd = repo.enqueue_command(conn, "kill", project_id="p", agent_name="a") + monkeypatch.setattr(spawn, "kill", lambda p, n: None) + + # The full worker processes the kill but leaves the spawn queued. + assert worker.drain("w-full") == 1 + with get_engine().begin() as conn: + assert repo.get_command(conn, kill_cmd["id"])["status"] == "done" + assert repo.get_command(conn, spawn_cmd["id"])["status"] == "queued" + assert spawned == {} + + # A worker with free slots picks the spawn up. + assert worker.drain("w-free") == 1 + with get_engine().begin() as conn: + assert repo.get_command(conn, spawn_cmd["id"])["status"] == "done" + assert spawned["name"] == "x" + assert spawned["worker_id"] == "w-free" + + +def test_slot_frees_when_run_finishes(headless_env, monkeypatch): + agent = _seed() + run1 = _running_run(agent["id"], "w1") + _running_run(agent["id"], "w1") + assert worker._full_slot_exclusions("w1") == worker._RUN_COMMANDS + + with get_engine().begin() as conn: + repo.finish_run(conn, run1["id"], "completed", exit_code=0) + assert worker._full_slot_exclusions("w1") == () + + +def test_tmux_runner_never_excludes(env): + assert worker._full_slot_exclusions("w") == () From 6c2e73d4ec3b0985060b29b4220d24bf6f0bc424 Mon Sep 17 00:00:00 2001 From: Wyatt Date: Tue, 21 Jul 2026 23:10:40 -0400 Subject: [PATCH 3/4] feat(control,api,ui): worker liveness + run event stream (phase 3) - worker: heartbeat every loop pass (workers registry); reaper pass every ~15s marks a silent worker's running runs crashed and flips agents stuck in 'working' to crashed (paused/blocked keep their still-accurate status). Idempotent via finish_run's running-guard; any surviving worker can reap; no auto-requeue (half-done runs may have pushed). Dead workers' registry rows are dropped once settled. - api: GET /projects/{p}/agents/{name}/events - the persisted stream-json event log, oldest-first, cursor-paged by row id; AgentOut exposes session_id/worker_id - frontend: Run events panel in the run detail (assistant text, tool chips, result footer with cost/turns, runner notices, raw lines), cursor-appended on the existing 5s poll; 'Crashed' filter + danger badge; crashed agents show their frozen last frame ('last output before crash'); static export regenerated Suite 290 -> 296 green; next build clean. --- .../components/sections/AgentsSection.tsx | 5 +- frontend/components/sections/RunsSection.tsx | 134 +++++++++++++++++- frontend/components/store.tsx | 21 +++ frontend/lib/api.ts | 22 ++- frontend/lib/format.ts | 1 + src/handler/api/routes/agents.py | 27 +++- src/handler/api/schemas.py | 27 +++- src/handler/api/static/404.html | 2 +- .../chunks/app/layout-b3a2b6165ef8a4d0.js | 1 - .../chunks/app/layout-c871fabc4fd47962.js | 1 + .../chunks/app/page-0637b8cf149a88ec.js | 1 + .../chunks/app/page-a511d608db773c67.js | 1 - ...c5a11e.js => main-app-cb8ae0c3c2bb8b85.js} | 2 +- .../_buildManifest.js | 0 .../_ssgManifest.js | 0 src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 4 +- src/handler/control/worker.py | 71 +++++++++- src/handler/db/repository.py | 6 + tests/test_reaper.py | 116 +++++++++++++++ 20 files changed, 428 insertions(+), 16 deletions(-) delete mode 100644 src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js create mode 100644 src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js create mode 100644 src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js delete mode 100644 src/handler/api/static/_next/static/chunks/app/page-a511d608db773c67.js rename src/handler/api/static/_next/static/chunks/{main-app-8321800782c5a11e.js => main-app-cb8ae0c3c2bb8b85.js} (58%) rename src/handler/api/static/_next/static/{tQnVLKX-Dr8H3frHzPrCa => ga21jhjKhYsutf8F3vo_-}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{tQnVLKX-Dr8H3frHzPrCa => ga21jhjKhYsutf8F3vo_-}/_ssgManifest.js (100%) create mode 100644 tests/test_reaper.py diff --git a/frontend/components/sections/AgentsSection.tsx b/frontend/components/sections/AgentsSection.tsx index be18a88..19c4b61 100644 --- a/frontend/components/sections/AgentsSection.tsx +++ b/frontend/components/sections/AgentsSection.tsx @@ -144,11 +144,12 @@ export function AgentsSection() { - {a.status === "working" && a.last_output?.trim() && ( + {(a.status === "working" || a.status === "crashed") && a.last_output?.trim() && (
- live output{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""} + {a.status === "crashed" ? "last output before crash" : "live output"} + {a.output_at ? ` · ${timeAgo(a.output_at)}` : ""}
         )}
 
+        {/* Headless run event stream (empty for legacy tmux agents) */}
+        {agent?.session_id && (
+          
+
+ Run events + {agent.worker_id ? ( + + on {agent.worker_id} + + ) : null} +
+ {s.events.length === 0 ? ( +
No events yet.
+ ) : ( +
+ {s.events.map((e) => ( + + ))} +
+ )} +
+ )} + {/* Log */}
Log · newest first
@@ -304,3 +340,99 @@ function RunDetail() { ); } + +/* One stream-json event, rendered by type: assistant text as prose, tool calls as chips, + * the result as a cost/turns footer, worker notices as callouts, raw lines verbatim. */ +function EventLine({ e }: { e: AgentEvent }) { + const p = (e.payload ?? {}) as Record; + const xs = { fontSize: "var(--text-xs)" } as const; + + if (e.type === "system") { + return ( +
+ ▸ session {p.subtype ?? "event"} + {p.session_id ? ` · ${String(p.session_id).slice(0, 8)}` : ""} + {Array.isArray(p.tools) ? ` · ${p.tools.length} tools` : ""} +
+ ); + } + if (e.type === "assistant") { + const content = p.message?.content; + const blocks: any[] = Array.isArray(content) ? content : []; + const text = blocks + .filter((b) => b?.type === "text" && b.text) + .map((b) => b.text) + .join("\n"); + const tools = blocks.filter((b) => b?.type === "tool_use"); + return ( +
+ {text && ( +
{text}
+ )} + {tools.length > 0 && ( +
+ {tools.map((t, i) => ( + + {t.name} + {t.input ? `: ${oneLine(t.input)}` : ""} + + ))} +
+ )} +
+ ); + } + if (e.type === "result") { + const err = Boolean(p.is_error); + return ( +
+ {err ? "run errored" : "run finished"} + + {p.num_turns != null ? `${p.num_turns} turns` : ""} + {p.total_cost_usd != null ? ` · $${Number(p.total_cost_usd).toFixed(4)}` : ""} + + {typeof p.result === "string" && p.result && ( + + {p.result} + + )} +
+ ); + } + if (e.type === "worker") { + return ( + + {p.notice ?? "runner notice"} + {p.stderr_tail ? ( +
+            {p.stderr_tail}
+          
+ ) : null} +
+ ); + } + if (e.type === "raw") { + return ( +
+ {typeof p.line === "string" ? p.line.trimEnd() : JSON.stringify(p)} +
+ ); + } + // user (tool results) and anything future: a quiet one-liner, nothing lost, no noise. + return ( +
+ ▸ {e.type} +
+ ); +} + +/* Compact single-line preview of a tool_use input object. */ +function oneLine(input: unknown): string { + const s = + typeof input === "string" + ? input + : (input as Record)?.command + ? String((input as Record).command) + : JSON.stringify(input); + return s.length > 80 ? `${s.slice(0, 77)}…` : s; +} diff --git a/frontend/components/store.tsx b/frontend/components/store.tsx index aece0dd..62532d0 100644 --- a/frontend/components/store.tsx +++ b/frontend/components/store.tsx @@ -17,6 +17,7 @@ import { AuthError, createClient, type Agent, + type AgentEvent, type ApiError, type Approval, type Checkmark, @@ -83,6 +84,9 @@ interface StoreValue { log: LogEntry[]; logOffset: number; pageLog: (dir: 1 | -1) => void; + /* Headless run event stream for the selected run, oldest-first, appended by cursor + * polls (empty for legacy tmux agents). */ + events: AgentEvent[]; approvals: Approval[]; hosts: Host[]; @@ -204,6 +208,9 @@ export function DashboardProvider({ const [checkmarkMissing, setCheckmarkMissing] = useState(false); const [log, setLog] = useState([]); const [logOffset, setLogOffset] = useState(0); + const [events, setEvents] = useState([]); + const eventsRef = useRef([]); + eventsRef.current = events; const [approvals, setApprovals] = useState([]); const [hosts, setHosts] = useState([]); const [commands, setCommands] = useState([]); @@ -282,6 +289,17 @@ export function DashboardProvider({ } catch (e) { swallow(e); } + try { + // Cursor poll: only events newer than what we already hold come back. + const cur = eventsRef.current; + const after = cur.length ? cur[cur.length - 1].id : 0; + const fresh = await clientRef.current.api( + `${path}/events?after_id=${after}&limit=500`, + ); + if (fresh.length) setEvents((prev) => [...prev, ...fresh]); + } catch (e) { + swallow(e); + } }, []); const loadApprovals = useCallback(async (projectId: string) => { @@ -407,6 +425,8 @@ export function DashboardProvider({ setCheckmark(null); setCheckmarkMissing(false); setLog([]); + setEvents([]); + eventsRef.current = []; void loadRun(projectId, name); }, [loadRun], @@ -925,6 +945,7 @@ export function DashboardProvider({ log, logOffset, pageLog, + events, approvals, hosts, commands, diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d54d333..1cd937b 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -30,10 +30,28 @@ export interface Agent { working_dir: string; status: string; role?: string | null; - /* Latest tmux pane-tail snapshot from the worker, so the UI can show what a running - * agent is actually doing (and expose one wedged on an interactive prompt). */ + /* Latest output snapshot from the worker: the tmux pane tail for legacy agents, the + * latest assistant text for headless runs. For a crashed agent this is the evidence + * frame — the last thing the process said. */ last_output?: string | null; output_at?: string | null; + /* Headless runner: claude session UUID (null = legacy tmux agent) + supervising worker. */ + session_id?: string | null; + worker_id?: string | null; + created_at: string; +} + +/* One persisted stream-json event of a headless run (GET .../events, cursor-paged by id). + * `type` mirrors the stream (system/assistant/user/result) plus `worker` (runner notices) + * and `raw` (unparseable line kept verbatim). */ +export interface AgentEvent { + id: number; + agent_id: number; + run_id: number; + session_id?: string | null; + seq: number; + type: string; + payload?: Record | null; created_at: string; } diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts index e60fb80..05e219a 100644 --- a/frontend/lib/format.ts +++ b/frontend/lib/format.ts @@ -21,6 +21,7 @@ export function statusTone(status: string | null | undefined): Tone { case "blocked": case "rejected": case "error": + case "crashed": return "danger"; case "pending": case "queued": diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py index 056eae8..80b9683 100644 --- a/src/handler/api/routes/agents.py +++ b/src/handler/api/routes/agents.py @@ -15,7 +15,15 @@ from sqlalchemy.exc import IntegrityError from ...config import get_settings from ...db import repository as repo from ..deps import db_conn, require_admin, require_auth -from ..schemas import AgentIn, AgentOut, CheckmarkOut, CommandOut, LogEntryOut, SpawnIn +from ..schemas import ( + AgentEventOut, + AgentIn, + AgentOut, + CheckmarkOut, + CommandOut, + LogEntryOut, + SpawnIn, +) from .common import resolve_agent router = APIRouter( @@ -123,6 +131,23 @@ def get_checkmark(project: str, name: str, conn: Connection = Depends(db_conn)) return checkmark +@router.get("/{name}/events", response_model=list[AgentEventOut]) +def get_events( + project: str, + name: str, + after_id: int = Query(0, ge=0), + limit: int = Query(200, ge=1, le=1000), + conn: Connection = Depends(db_conn), +) -> list[dict]: + """The headless run event stream, oldest-first, cursor-paged by row id. + + The UI polls with ``after_id`` = the largest id it has seen, so each poll returns + only new events (an empty list for a legacy tmux agent or an idle one). + """ + agent = resolve_agent(conn, project, name) + return repo.list_agent_events(conn, agent["id"], after_id=after_id, limit=limit) + + @router.get("/{name}/log", response_model=list[LogEntryOut]) def get_log( project: str, diff --git a/src/handler/api/schemas.py b/src/handler/api/schemas.py index cbf825b..29fe621 100644 --- a/src/handler/api/schemas.py +++ b/src/handler/api/schemas.py @@ -128,10 +128,14 @@ class AgentOut(BaseModel): working_dir: str status: str role: Role | None = None - # Latest tmux pane-tail snapshot (worker poll loop) so the UI can show what a running - # agent is doing — including one wedged on an interactive prompt no one can answer. + # Latest output snapshot so the UI can show what a running agent is doing: the tmux + # pane tail for legacy agents, the latest assistant text for headless runs. last_output: str | None = None output_at: datetime | None = None + # Headless runner: the claude session UUID (null = legacy tmux agent) and the worker + # container supervising (or last to supervise) this agent's runs. + session_id: str | None = None + worker_id: str | None = None created_at: datetime @@ -306,6 +310,25 @@ class LogEntryOut(BaseModel): ci_checked_at: datetime | None = None +class AgentEventOut(BaseModel): + """One persisted stream-json event of a headless run (the UI's live log panel). + + ``type`` mirrors the stream's top-level type (system/assistant/user/result), plus + ``worker`` (runner-generated notices) and ``raw`` (unparseable line, kept verbatim). + """ + + model_config = ConfigDict(from_attributes=True) + + id: int + agent_id: int + run_id: int + session_id: str | None = None + seq: int + type: str + payload: dict | None = None + created_at: datetime + + class AnswerIn(BaseModel): answer: str # If omitted, the answer targets the agent's latest open question. diff --git a/src/handler/api/static/404.html b/src/handler/api/static/404.html index cc28808..7ebb5db 100644 --- a/src/handler/api/static/404.html +++ b/src/handler/api/static/404.html @@ -1 +1 @@ -404: This page could not be found.Handler · Claude Activity

404

This page could not be found.

\ No newline at end of file +404: This page could not be found.Handler · Claude Activity

404

This page could not be found.

\ No newline at end of file diff --git a/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js b/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js deleted file mode 100644 index 942cb25..0000000 --- a/src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js +++ /dev/null @@ -1 +0,0 @@ -(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3156:function(n,e,u){Promise.resolve().then(u.t.bind(u,7960,23))},7960:function(){}},function(n){n.O(0,[587,971,117,744],function(){return n(n.s=3156)}),_N_E=n.O()}]); \ No newline at end of file diff --git a/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js b/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js new file mode 100644 index 0000000..607d9eb --- /dev/null +++ b/src/handler/api/static/_next/static/chunks/app/layout-c871fabc4fd47962.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{2385:function(n,e,u){Promise.resolve().then(u.t.bind(u,7960,23))},7960:function(){}},function(n){n.O(0,[587,971,117,744],function(){return n(n.s=2385)}),_N_E=n.O()}]); \ No newline at end of file diff --git a/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js b/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js new file mode 100644 index 0000000..493e613 --- /dev/null +++ b/src/handler/api/static/_next/static/chunks/app/page-0637b8cf149a88ec.js @@ -0,0 +1 @@ +(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[931],{3963:function(e,t,s){Promise.resolve().then(s.bind(s,9859))},9859:function(e,t,s){"use strict";s.r(t),s.d(t,{default:function(){return er}});var a,n=s(7437),r=s(2265);let l=null!==(a=s(257).env.NEXT_PUBLIC_API_BASE)&&void 0!==a?a:"";class i extends Error{constructor(e="unauthorized"){super(e),this.name="AuthError"}}let c=(0,r.createContext)(null);function o(){let e=(0,r.useContext)(c);if(!e)throw Error("useDashboard outside provider");return e}function d(e){let{token:t,onUnauthorized:s,children:a}=e,o=(0,r.useMemo)(()=>(function(e,t){async function s(s,a){var n;let r=(null==a?void 0:a.body)!==void 0&&(null==a?void 0:a.body)!==null,c=await fetch(l+s,{method:null!==(n=null==a?void 0:a.method)&&void 0!==n?n:r?"POST":"GET",headers:{Authorization:"Bearer ".concat(e),...r?{"Content-Type":"application/json"}:{}},body:r?JSON.stringify(a.body):void 0});if(401===c.status)throw t(),new i;if(!c.ok){let e=c.statusText;try{let t=await c.json();t&&void 0!==t.detail&&(e="string"==typeof t.detail?t.detail:JSON.stringify(t.detail))}catch(e){}let t=Error(e);throw t.status=c.status,t}if(204===c.status)return;let o=await c.text();return o?JSON.parse(o):void 0}async function a(e,t){var a,n;let r=null!==(a=null==t?void 0:t.attempts)&&void 0!==a?a:60,l=null!==(n=null==t?void 0:t.intervalMs)&&void 0!==n?n:500;for(let t=0;tsetTimeout(e,l))}return null}return{api:s,trackCommand:a}})(t,s),[t,s]),d=(0,r.useRef)(o);d.current=o;let[u,h]=(0,r.useState)("runs"),[m,p]=(0,r.useState)([]),[x,v]=(0,r.useState)([]),[j,g]=(0,r.useState)(""),[y,f]=(0,r.useState)(null),[b,k]=(0,r.useState)(null),[w,N]=(0,r.useState)(!1),[_,C]=(0,r.useState)([]),[S,R]=(0,r.useState)(0),[I,T]=(0,r.useState)([]),A=(0,r.useRef)([]);A.current=I;let[z,E]=(0,r.useState)([]),[P,O]=(0,r.useState)([]),[M,L]=(0,r.useState)([]),[D,U]=(0,r.useState)([]),[F,B]=(0,r.useState)({log:[],context:[]}),[H,W]=(0,r.useState)({text:"",error:!1,busy:!1}),[q,G]=(0,r.useState)(""),[J,K]=(0,r.useState)(!0),[Y,V]=(0,r.useState)({status:"idle",url:"",message:""}),Q=(0,r.useRef)(u);Q.current=u;let X=(0,r.useRef)(j);X.current=j;let $=(0,r.useRef)(y);$.current=y;let Z=(0,r.useRef)(S);Z.current=S;let ee=e=>{e instanceof i||G(e.message)},et=(0,r.useCallback)(async()=>{try{let e=await d.current.api("/projects");p(e),G(""),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")})}catch(e){ee(e)}},[]),es=(0,r.useCallback)(async e=>{try{let t=await Promise.all(e.map(e=>d.current.api("/projects/".concat(encodeURIComponent(e.id),"/agents")).catch(()=>[])));v(t.flat())}catch(e){ee(e)}},[]),ea=(0,r.useCallback)(async(e,t)=>{let s="/projects/".concat(encodeURIComponent(e),"/agents/").concat(encodeURIComponent(t));try{let e=await d.current.api("".concat(s,"/checkmark"));k(e),N(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),N(!0)):ee(e)}try{let e=await d.current.api("".concat(s,"/log?limit=").concat(100,"&offset=").concat(Z.current));C(e)}catch(e){ee(e)}try{let e=A.current,t=e.length?e[e.length-1].id:0,a=await d.current.api("".concat(s,"/events?after_id=").concat(t,"&limit=500"));a.length&&T(e=>[...e,...a])}catch(e){ee(e)}},[]),en=(0,r.useCallback)(async e=>{if(!e){E([]);return}try{E(await d.current.api("/projects/".concat(encodeURIComponent(e),"/approvals")))}catch(e){ee(e)}},[]),er=(0,r.useCallback)(async()=>{try{O(await d.current.api("/hosts"))}catch(e){ee(e)}},[]),el=(0,r.useCallback)(async()=>{try{L(await d.current.api("/commands?limit=50"))}catch(e){ee(e)}},[]),ei=(0,r.useCallback)(async()=>{try{U(await d.current.api("/schedules"))}catch(e){ee(e)}},[]),ec=(0,r.useCallback)(async()=>{try{let[e,t]=await Promise.all([d.current.api("/shared/log"),d.current.api("/shared/context")]);B({log:e,context:t})}catch(e){ee(e)}},[]),eo=(0,r.useCallback)(async()=>{let e=await d.current.api("/projects").catch(e=>(ee(e),null));e&&(p(e),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")}),await es(e));let t=Q.current,s=$.current;s&&await ea(s.projectId,s.name),"approvals"===t&&await en(X.current),"servers"===t&&await er(),"activity"===t&&await el(),"schedules"===t&&await ei(),"shared"===t&&await ec()},[es,ea,en,er,el,ei,ec]);(0,r.useEffect)(()=>{let e=!0;(async()=>{K(!0),await eo(),e&&K(!1)})();let t=setInterval(()=>{document.hidden||eo()},5e3);return()=>{e=!1,clearInterval(t)}},[eo]);let ed=(0,r.useCallback)(e=>{h(e),W({text:"",error:!1,busy:!1}),"approvals"===e&&en(X.current),"servers"===e&&er(),"activity"===e&&el(),"schedules"===e&&ei(),"shared"===e&&ec()},[en,er,el,ei,ec]),eu=(0,r.useCallback)(e=>{g(e),"approvals"===Q.current&&en(e)},[en]),eh=(0,r.useCallback)((e,t)=>{f({projectId:e,name:t}),R(0),Z.current=0,k(null),N(!1),C([]),T([]),A.current=[],ea(e,t)},[ea]),em=(0,r.useCallback)(e=>{let t=Math.max(0,S+100*e);if(t===S)return;R(t),Z.current=t;let s=$.current;s&&ea(s.projectId,s.name)},[S,ea]),ep=(0,r.useCallback)(()=>{eo()},[eo]),ex=(0,r.useCallback)(async(e,t,s)=>{W({text:"".concat(s,": queued…"),error:!1,busy:!0});try{let a=await d.current.api(e,{method:"POST",body:t}),n=await d.current.trackCommand(a.id);if(!n)return W({text:"".concat(s,": still running (see Activity). Is the worker up?"),error:!1,busy:!1}),null;let r="done"===n.status,l=n.error||(n.result?JSON.stringify(n.result):"");return W({text:"".concat(s," ").concat(r?"done":"failed").concat(l?" — "+l:""),error:!r,busy:!1}),n}catch(e){if(e instanceof i)return null;return W({text:"".concat(s," failed: ").concat(e.message),error:!0,busy:!1}),null}},[]),ev=(0,r.useCallback)(async e=>{let t={name:e.name.trim(),role:e.role||null,task:e.task.trim()||null};"worktree"===e.placement&&e.worktree.trim()&&(t.worktree=e.worktree.trim()),"subdir"===e.placement&&e.subdir.trim()&&(t.subdir=e.subdir.trim());let s=encodeURIComponent(X.current),a=await ex("/projects/".concat(s,"/agents/spawn"),t,"spawn ".concat(t.name));return await es(m),(null==a?void 0:a.status)==="done"},[ex,es,m]),ej=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);await ex("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t),"/kill"),void 0,"kill ".concat(t)),await es(m)},[ex,es,m]),eg=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);try{var a;await d.current.api("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t)),{method:"DELETE"}),W({text:"agent '".concat(t,"' row deleted"),error:!1,busy:!1}),(null===(a=$.current)||void 0===a?void 0:a.name)===t&&f(null),await es(m)}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[es,m]),ey=(0,r.useCallback)(async(e,t)=>{let s=$.current;if(!s)return!1;let a="/projects/".concat(encodeURIComponent(s.projectId),"/agents/").concat(encodeURIComponent(s.name));try{return await d.current.api("".concat(a,"/answer"),{method:"POST",body:{answer:e}}),t?await ex("".concat(a,"/resume"),{answer:e},"resume"):W({text:"Answer saved (agent still paused).",error:!1,busy:!1}),await es(m),await ea(s.projectId,s.name),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ex,es,ea,m]),ef=(0,r.useCallback)(async e=>{try{var t,s;let a="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise},n=await d.current.api("/projects",{method:"POST",body:a});if(await et(),null!=n.sync_command_id){W({text:"repository '".concat(n.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.sync_command_id);e?"done"===e.status?W({text:"repository '".concat(n.id,"' registered and cloned"),error:!1,busy:!1}):W({text:"repository '".concat(n.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1}):W({text:"repository '".concat(n.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else W({text:"repository '".concat(n.id,"' registered"),error:!1,busy:!1});if(null!=n.mise_init_command_id){W({text:"repository '".concat(n.id,"': launching a mise-init agent to create .mise.toml…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.mise_init_command_id);e?"done"===e.status?W({text:"repository '".concat(n.id,"' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs)."),error:!1,busy:!1}):W({text:"repository '".concat(n.id,"' registered but the mise-init agent failed to launch — ").concat(null!==(s=e.error)&&void 0!==s?s:""),error:!0,busy:!1}):W({text:"repository '".concat(n.id,"' registered; mise-init still starting (see Activity). Is the worker up?"),error:!1,busy:!1})}return!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[et]),eb=(0,r.useCallback)(async e=>{await ex("/projects/".concat(encodeURIComponent(e),"/sync"),void 0,"pull ".concat(e))},[ex]),ek=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"PATCH",body:{root_dir:t.root_dir.trim(),git_remote:t.git_remote.trim()||null,credential_ref:t.credential_ref.trim()||null}}),W({text:"repository '".concat(e,"' updated"),error:!1,busy:!1}),await et(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[et]),ew=(0,r.useCallback)(async e=>{try{await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"DELETE"}),W({text:"repository '".concat(e,"' removed"),error:!1,busy:!1}),g(t=>t===e?"":t),await et()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[et]),eN=(0,r.useCallback)(async e=>{let t=encodeURIComponent(X.current);await ex("/projects/".concat(t,"/approvals"),{branch:e.branch.trim(),status:e.status,agent_name:e.agent_name.trim()||null,sha:e.sha.trim()||null,note:e.note.trim()||null},"".concat(e.status," ").concat(e.branch)),await en(X.current)},[ex,en]),e_=(0,r.useCallback)(async e=>{try{return await d.current.api("/hosts",{method:"POST",body:{hostname:e.hostname.trim(),forge_type:e.forge_type,token_env_var:e.token_env_var.trim()||null,base_url:e.base_url.trim()||null,token:e.token.trim()||null,generate_ssh_key:e.generate_ssh_key}}),W({text:"git server '".concat(e.hostname,"' added"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[er]),eC=(0,r.useCallback)(async(e,t)=>{try{let s={forge_type:t.forge_type,token_env_var:t.token_env_var.trim()||null,base_url:t.base_url.trim()||null};return t.token.trim()&&(s.token=t.token.trim()),t.generate_ssh_key&&(s.regenerate_ssh_key=!0),await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"PATCH",body:s}),W({text:"git server '".concat(e,"' updated"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[er]),eS=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e),"/schedules"),{method:"POST",body:{name_prefix:t.name_prefix.trim(),task:t.task.trim(),interval_seconds:t.interval_seconds,role:t.role||null}}),W({text:"schedule '".concat(t.name_prefix,"' created — first run on the worker's next pass"),error:!1,busy:!1}),await ei(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ei]),eR=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/schedules/".concat(e),{method:"PATCH",body:t}),await ei(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ei]),eI=(0,r.useCallback)(async e=>{try{await d.current.api("/schedules/".concat(e),{method:"DELETE"}),W({text:"schedule ".concat(e," removed"),error:!1,busy:!1}),await ei()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[ei]),eT=(0,r.useCallback)(async e=>{try{await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"DELETE"}),W({text:"git server '".concat(e,"' removed"),error:!1,busy:!1}),await er()}catch(e){if(e instanceof i)return;W({text:e.message,error:!0,busy:!1})}},[er]),eA=(0,r.useCallback)(async()=>{await ex("/poll-ci",void 0,"poll-ci (all projects)"),await el()},[ex,el]),ez=(0,r.useCallback)(async()=>{V({status:"starting",url:"",message:"Opening `claude /login` in the control container and selecting the subscription account…"});try{let e=await d.current.api("/login/start",{method:"POST"}),t=await d.current.trackCommand(e.id,{attempts:180});if(!t){V({status:"error",url:"",message:"Still starting (see Activity). Is the control worker running?"});return}if("done"!==t.status){V({status:"error",url:"",message:t.error||"Failed to start login."});return}let s=t.result&&"string"==typeof t.result.url?t.result.url:"";if(!s){V({status:"error",url:"",message:"No login URL was returned by claude."});return}V({status:"awaiting",url:s,message:"Authorize in the window below (or open it in a new tab), then paste the code claude gives you."})}catch(e){if(e instanceof i)return;V({status:"error",url:"",message:e.message})}},[]),eE=(0,r.useCallback)(async e=>{let t=e.trim();if(!t)return!1;V(e=>({...e,status:"submitting",message:"Submitting the authorization code…"}));try{let e=await d.current.api("/login/submit",{method:"POST",body:{code:t}}),s=await d.current.trackCommand(e.id,{attempts:60});if(!s)return V(e=>({...e,status:"awaiting",message:"Submit still running (see Activity). Is the control worker running?"})),!1;if("done"===s.status)return V({status:"done",url:"",message:"Claude Code is now logged in on the host — new agents will use this account."}),!0;return V(e=>({...e,status:"awaiting",message:s.error||"Login was not confirmed. Re-check the code, or restart the flow."})),!1}catch(e){if(e instanceof i)return!1;return V(t=>({...t,status:"awaiting",message:e.message})),!1}},[]),eP=(0,r.useCallback)(()=>{V({status:"idle",url:"",message:""})},[]),eO=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/shared/context/".concat(encodeURIComponent(e)),{method:"PUT",body:{value:t}}),W({text:"shared context '".concat(e,"' set"),error:!1,busy:!1}),await ec(),!0}catch(e){if(e instanceof i)return!1;return W({text:e.message,error:!0,busy:!1}),!1}},[ec]);return(0,n.jsx)(c.Provider,{value:{section:u,setSection:ed,projects:m,agents:x,selectedProjectId:j,selectProject:eu,selectedRun:y,selectRun:eh,checkmark:b,checkmarkMissing:w,log:_,logOffset:S,pageLog:em,events:I,approvals:z,hosts:P,commands:M,schedules:D,shared:F,cmd:H,lastError:q,loading:J,refresh:ep,spawnAgent:ev,killAgent:ej,deleteAgent:eg,submitAnswer:ey,createProject:ef,updateProject:ek,deleteProject:ew,syncProject:eb,submitApproval:eN,createHost:e_,updateHost:eC,deleteHost:eT,createSchedule:eS,updateSchedule:eR,deleteSchedule:eI,pollCi:eA,setSharedKey:eO,claudeLogin:Y,startClaudeLogin:ez,submitClaudeCode:eE,resetClaudeLogin:eP},children:a})}function u(e){switch((null!=e?e:"").toLowerCase()){case"pass":case"done":case"completed":case"approved":case"success":return"success";case"fail":case"failed":case"blocked":case"rejected":case"error":case"crashed":return"danger";case"pending":case"queued":case"running":case"working":case"paused_for_input":return"warning";case"not_applicable":case"unknown":case"":return"neutral";default:return"info"}}let h={paused_for_input:"Needs input",not_applicable:"N/A"};function m(e){if(!e)return"—";let t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function p(e){return e?e.slice(0,7):"—"}function x(e){if(!e)return"—";let t=new Date(e).getTime();if(Number.isNaN(t))return"—";let s=Math.max(0,Math.floor((Date.now()-t)/1e3));if(s<60)return"".concat(s,"s");let a=Math.floor(s/60);if(a<60)return"".concat(a,"m");let n=Math.floor(a/60);if(n<24)return"".concat(n,"h");let r=Math.floor(n/24);if(r<30)return"".concat(r,"d");let l=Math.floor(r/30);return l<12?"".concat(l,"mo"):"".concat(Math.floor(l/12),"y")}function v(e){let{tone:t="neutral",pill:s=!1,dot:a=!1,children:r}=e;return(0,n.jsxs)("span",{className:"badge badge-".concat(t).concat(s?" pill":""),children:[a&&(0,n.jsx)("span",{className:"dot"}),r]})}function j(e){let{status:t}=e;return(0,n.jsx)(v,{tone:u(t),children:function(e){let t=(null!=e?e:"").trim();if(!t)return"—";let s=t.toLowerCase();return h[s]?h[s]:s.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())}(t)})}function g(e){let{children:t,interactive:s=!1,onClick:a,className:r=""}=e;return(0,n.jsx)("div",{className:"card".concat(s?" interactive":""," ").concat(r).trim(),onClick:a,role:s?"button":void 0,tabIndex:s?0:void 0,children:t})}function y(e){let{variant:t="secondary",size:s="md",onClick:a,disabled:r,type:l="button",children:i}=e;return(0,n.jsx)("button",{type:l,className:"btn btn-".concat(t).concat("sm"===s?" btn-sm":""),onClick:a,disabled:r,children:i})}function f(e){let{label:t,children:s}=e;return(0,n.jsxs)("label",{className:"field",children:[t&&(0,n.jsx)("span",{className:"field-label",children:t}),s]})}function b(e){let{label:t,value:s,onChange:a,placeholder:r,type:l="text",disabled:i}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("input",{className:"input",type:l,value:s,placeholder:r,disabled:i,onChange:e=>a(e.target.value)})})}function k(e){let{label:t,value:s,onChange:a,placeholder:r,rows:l=3}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("textarea",{className:"textarea",value:s,rows:l,placeholder:r,onChange:e=>a(e.target.value)})})}function w(e){let{label:t,value:s,onChange:a,options:r}=e;return(0,n.jsx)(f,{label:t,children:(0,n.jsx)("select",{className:"select",value:s,onChange:e=>a(e.target.value),children:r.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value))})})}function N(e){let{tabs:t,value:s,onChange:a}=e;return(0,n.jsx)("div",{className:"tabs",role:"tablist",children:t.map(e=>(0,n.jsx)("button",{role:"tab","aria-selected":s===e.value,className:"tab".concat(s===e.value?" active":""),onClick:()=>a(e.value),children:e.label},e.value))})}function _(e){let{value:t,label:s,sub:a,accent:r=!1}=e;return(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"stat-value".concat(r?" accent":""),children:t}),(0,n.jsx)("div",{className:"stat-label",children:s}),a&&(0,n.jsx)("div",{className:"stat-sub",children:a})]})}function C(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function S(e){let{on:t,onClick:s}=e;return(0,n.jsx)("button",{type:"button",className:"toggle".concat(t?" on":""),"aria-pressed":t,onClick:s,children:(0,n.jsx)("span",{className:"knob"})})}let R=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"},{value:"crashed",label:"Crashed"}];function I(){let e=o(),[t,s]=(0,r.useState)("all"),a=(0,r.useMemo)(()=>[...e.agents.filter(e=>{var s;return s=e.status,"all"===t||("needs"===t?"paused_for_input"===s:"working"===t?"working"===s||"running"===s:"done"===t?"done"===s||"completed"===s:"crashed"!==t||"crashed"===s||"blocked"===s)})].sort((e,t)=>e.created_at"paused_for_input"===e.status).length,c=e.agents.filter(e=>"working"===e.status||"running"===e.status).length;return(0,n.jsxs)("div",{className:"runs",children:[(0,n.jsx)("div",{className:"runs-stats",children:(0,n.jsxs)("div",{className:"stat-row",children:[(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.projects.length,label:"Repositories"})})]})}),(0,n.jsxs)("div",{className:"split",children:[(0,n.jsxs)("div",{className:"split-list",children:[(0,n.jsxs)("div",{className:"split-list-head",children:[(0,n.jsx)("div",{className:"section-title",style:{fontSize:"var(--text-lg)"},children:"Runs"}),(0,n.jsx)(N,{tabs:R,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(C,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(T,{agent:t,selected:(null==l?void 0:l.projectId)===t.project_id&&(null==l?void 0:l.name)===t.name,onSelect:()=>e.selectRun(t.project_id,t.name)},"".concat(t.project_id,"/").concat(t.name)))]})]}),(0,n.jsx)("div",{className:"split-detail",children:l?(0,n.jsx)(z,{}):(0,n.jsx)(A,{})})]})]})}function T(e){let{agent:t,selected:s,onSelect:a}=e;return(0,n.jsxs)("button",{className:"run-row".concat(s?" selected":""),onClick:a,children:[(0,n.jsxs)("div",{className:"run-row-top",children:[(0,n.jsx)("span",{className:"run-project",children:t.project_id}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:x(t.created_at)})]}),(0,n.jsxs)("div",{className:"truncate muted",style:{fontSize:"var(--text-sm)"},children:[t.name,t.role?" \xb7 ".concat(t.role):""]}),(0,n.jsx)("div",{className:"hstack",style:{gap:8},children:(0,n.jsx)(j,{status:t.status})})]})}function A(){return(0,n.jsx)("div",{style:{padding:"60px 32px",color:"var(--text-muted)"},children:"Select a run to see its checkmark, log, and any open question."})}function z(){var e;let t=o(),s=t.selectedRun,a=t.agents.find(e=>e.project_id===s.projectId&&e.name===s.name),l=t.checkmark,[i,c]=(0,r.useState)(""),[d,h]=(0,r.useState)(!1),x=(null==a?void 0:a.status)==="paused_for_input",g=async e=>{if(!i.trim())return;h(!0);let s=await t.submitAnswer(i.trim(),e);h(!1),s&&c("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{style:{padding:"24px 28px",borderBottom:"1px solid var(--border-default)",display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)("span",{style:{color:"var(--accent)",fontWeight:"var(--fw-bold)",fontSize:"var(--text-xl)"},children:s.projectId}),(0,n.jsx)("span",{className:"faint",children:"/"}),(0,n.jsx)("span",{style:{color:"var(--text-heading)",fontWeight:"var(--fw-semibold)",fontSize:"var(--text-lg)"},children:s.name}),(0,n.jsx)(j,{status:null==a?void 0:a.status}),(null==a?void 0:a.role)&&(0,n.jsx)(v,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>t.deleteAgent(s.projectId,s.name),children:"Delete row"})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:[null!==(e=null==a?void 0:a.working_dir)&&void 0!==e?e:"—"," \xb7 created ",m(null==a?void 0:a.created_at)]})]}),(0,n.jsxs)("div",{style:{padding:"20px 28px",display:"flex",flexDirection:"column",gap:16},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Checkmark"}),t.checkmarkMissing&&(0,n.jsx)(C,{tone:"info",children:"No checkpoint recorded yet."}),l&&!t.checkmarkMissing&&(0,n.jsxs)("dl",{className:"kv",children:[(0,n.jsx)("dt",{children:"Status"}),(0,n.jsx)("dd",{children:(0,n.jsx)(j,{status:l.status})}),(0,n.jsx)("dt",{children:"Where it stopped"}),(0,n.jsx)("dd",{children:l.where_it_stopped||"—"}),(0,n.jsx)("dt",{children:"Open question"}),(0,n.jsx)("dd",{children:l.open_question||"—"}),(0,n.jsx)("dt",{children:"Next steps"}),(0,n.jsx)("dd",{children:l.next_steps&&l.next_steps.length>0?(0,n.jsx)("ul",{children:l.next_steps.map((e,t)=>(0,n.jsx)("li",{children:e},t))}):"—"}),(0,n.jsx)("dt",{children:"Tests"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(v,{tone:u(l.tests_status),children:l.tests_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.tested_at?m(l.tested_at):""})]}),(0,n.jsx)("dt",{children:"Build"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(v,{tone:u(l.build_status),children:l.build_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.built_at?m(l.built_at):""})]}),(0,n.jsx)("dt",{children:"Checkpoint at"}),(0,n.jsx)("dd",{className:"faint",children:m(l.checkpoint_at)})]})]}),x&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Answer this question"}),(0,n.jsx)(C,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(k,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>g(!1),children:"Answer"}),(0,n.jsx)(y,{variant:"primary",disabled:d||!i.trim(),onClick:()=>g(!0),children:"Answer & Resume"})]})]}),(null==a?void 0:a.session_id)&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"eyebrow",children:["Run events",a.worker_id?(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)",marginLeft:8},children:["on ",a.worker_id]}):null]}),0===t.events.length?(0,n.jsx)("div",{className:"empty",children:"No events yet."}):(0,n.jsx)("div",{style:{display:"flex",flexDirection:"column",gap:6,maxHeight:420,overflow:"auto",padding:"10px 12px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6},children:t.events.map(e=>(0,n.jsx)(E,{e:e},e.id))})]}),(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Log \xb7 newest first"}),0===t.log.length?(0,n.jsx)("div",{className:"empty",children:"No log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"Q / A"}),(0,n.jsx)("th",{children:"Push"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:t.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsxs)("td",{children:[e.question&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"Q:"})," ",e.question]}),e.answer&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"A:"})," ",e.answer]}),!e.question&&!e.answer&&"—"]}),(0,n.jsx)("td",{className:"mono",children:p(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{tone:u(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:0===t.logOffset,onClick:()=>t.pageLog(-1),children:"‹ Newer"}),(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["offset ",t.logOffset]}),(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}function E(e){var t,s,a,r;let{e:l}=e,i=null!==(t=l.payload)&&void 0!==t?t:{},c={fontSize:"var(--text-xs)"};if("system"===l.type)return(0,n.jsxs)("div",{className:"faint mono",style:c,children:["▸ session ",null!==(s=i.subtype)&&void 0!==s?s:"event",i.session_id?" \xb7 ".concat(String(i.session_id).slice(0,8)):"",Array.isArray(i.tools)?" \xb7 ".concat(i.tools.length," tools"):""]});if("assistant"===l.type){let e=null===(a=i.message)||void 0===a?void 0:a.content,t=Array.isArray(e)?e:[],s=t.filter(e=>(null==e?void 0:e.type)==="text"&&e.text).map(e=>e.text).join("\n"),r=t.filter(e=>(null==e?void 0:e.type)==="tool_use");return(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:4},children:[s&&(0,n.jsx)("div",{style:{fontSize:"var(--text-sm)",whiteSpace:"pre-wrap"},children:s}),r.length>0&&(0,n.jsx)("div",{className:"hstack",style:{gap:6,flexWrap:"wrap"},children:r.map((e,t)=>(0,n.jsxs)(v,{tone:"info",children:[e.name,e.input?": ".concat(function(e){let t="string"==typeof e?e:(null==e?void 0:e.command)?String(e.command):JSON.stringify(e);return t.length>80?"".concat(t.slice(0,77),"…"):t}(e.input)):""]},t))})]})}if("result"===l.type){let e=!!i.is_error;return(0,n.jsxs)("div",{className:"hstack",style:{gap:8,flexWrap:"wrap"},children:[(0,n.jsx)(v,{tone:e?"danger":"success",children:e?"run errored":"run finished"}),(0,n.jsxs)("span",{className:"faint mono",style:c,children:[null!=i.num_turns?"".concat(i.num_turns," turns"):"",null!=i.total_cost_usd?" \xb7 $".concat(Number(i.total_cost_usd).toFixed(4)):""]}),"string"==typeof i.result&&i.result&&(0,n.jsx)("span",{className:"muted",style:{...c,whiteSpace:"pre-wrap",width:"100%"},children:i.result})]})}return"worker"===l.type?(0,n.jsxs)(C,{tone:"danger",children:[null!==(r=i.notice)&&void 0!==r?r:"runner notice",i.stderr_tail?(0,n.jsx)("pre",{className:"mono",style:{...c,margin:"6px 0 0",whiteSpace:"pre-wrap"},children:i.stderr_tail}):null]}):"raw"===l.type?(0,n.jsx)("div",{className:"faint mono",style:{...c,whiteSpace:"pre-wrap"},children:"string"==typeof i.line?i.line.trimEnd():JSON.stringify(i)}):(0,n.jsxs)("div",{className:"faint mono",style:c,children:["▸ ",l.type]})}let P={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:"",init_mise:!1};function O(){let e=o(),[t,s]=(0,r.useState)(P),[a,l]=(0,r.useState)(!1),i=(0,r.useMemo)(()=>{var t;let s=new Map;for(let a of e.agents)s.set(a.project_id,(null!==(t=s.get(a.project_id))&&void 0!==t?t:0)+1);return s},[e.agents]),c=(0,r.useMemo)(()=>[{value:"",label:e.hosts.length?"Pick a git server…":"No git servers configured"},...e.hosts.map(e=>({value:e.hostname,label:"".concat(e.hostname," (").concat(e.forge_type,")")}))],[e.hosts]),d=()=>{s(P),l(!1)},u=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},h=e=>{var t,a;s({...P,mode:"manual",id:e.id,root_dir:e.root_dir,git_remote:null!==(t=e.git_remote)&&void 0!==t?t:"",credential_ref:null!==(a=e.credential_ref)&&void 0!==a?a:""}),l(!0)},p=a?!!t.root_dir.trim():"server"===t.mode?!!t.git_server&&/^[\w.-]+\/[\w.-]+$/.test(t.repo.trim()):!!t.id.trim()&&!!t.root_dir.trim();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Repositories"}),(0,n.jsx)("div",{className:"section-desc",children:"Repos Handler manages. Each carries its own agents, history, and credentials."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit repository \xb7 ".concat(t.id):"Add a repository"})}),!a&&(0,n.jsx)("div",{style:{marginBottom:14},children:(0,n.jsx)(N,{tabs:[{value:"server",label:"From a git server"},{value:"manual",label:"Manual (existing checkout)"}],value:t.mode,onChange:e=>s({...t,mode:e})})}),a||"server"!==t.mode?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(b,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(b,{label:"Git remote",value:t.git_remote,onChange:e=>s({...t,git_remote:e}),placeholder:"git@github.com:user/repo.git (optional)"}),(0,n.jsx)(b,{label:"Credential ref",value:t.credential_ref,onChange:e=>s({...t,credential_ref:e}),placeholder:"env:VAR / file:/path / db:host:github.com"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Optional override — projects on a configured git server use its stored token automatically. credential_ref is a pointer, never the token (env: / file: / db:host:)."})]}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(w,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(b,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(b,{label:"ID / slug (optional — defaults to the repo name)",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"coolproj"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"The repo is always pulled: Handler derives the remote from the server (ssh when it has a deploy key, https via the stored token otherwise), clones it under PROJECTS_ROOT, and keeps it fresh before every run."})]}),!a&&(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer",marginTop:14},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.init_mise,onChange:e=>s({...t,init_mise:e.target.checked})}),(0,n.jsxs)("span",{style:{fontSize:"var(--text-sm)"},children:["Initialize mise — after the clone, run an agent that writes a"," ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," with a"," ",(0,n.jsx)("span",{className:"mono",children:"[tasks.test]"})," task for this repo’s stack, then commits and pushes it. Needed for repos that don’t define one yet."]})]}),!a&&t.init_mise&&"manual"===t.mode&&!t.git_remote.trim()&&(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"6px 0 0"},children:["A git remote is required to push the new ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," — add one above, or mise won’t be initialized."]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!p,onClick:u,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:d,children:"Cancel"})]})]}),0===e.projects.length&&(0,n.jsx)("div",{className:"empty",children:"No repositories registered."}),e.projects.map(t=>{var s,a;return(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(v,{tone:"info",pill:!0,children:[null!==(s=i.get(t.id))&&void 0!==s?s:0," ",(null!==(a=i.get(t.id))&&void 0!==a?a:0)===1?"agent":"agents"]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:4},children:[t.root_dir,t.git_remote?" \xb7 ".concat(t.git_remote):""]}),(0,n.jsxs)("div",{className:"hstack",style:{marginTop:12,justifyContent:"space-between"},children:[(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["cred ",t.credential_ref||"server default"," \xb7 added ",m(t.created_at)]}),(0,n.jsxs)("div",{className:"hstack",children:[t.git_remote&&(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>h(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let M=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],L=[{value:"worktree",label:"git worktree on branch"},{value:"subdir",label:"subdir under root"}],D={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function U(){let e=o(),[t,s]=(0,r.useState)(D),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=(0,r.useMemo)(()=>e.agents.filter(t=>t.project_id===e.selectedProjectId),[e.agents,e.selectedProjectId]),i=async()=>{await e.spawnAgent(t)&&s(D)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Agents"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn agents into a repository and manage running sessions."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Spawn an agent"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:M}),(0,n.jsx)(w,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:L}),"worktree"===t.placement?(0,n.jsx)(b,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(b,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Initial task",value:t.task,onChange:e=>s({...t,task:e}),rows:2,placeholder:"initial task / prompt (optional)"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name.trim(),onClick:i,children:"Spawn"})})]}),0===l.length?(0,n.jsx)("div",{className:"empty",children:"No agents in this repository."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Role"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Working dir"}),(0,n.jsx)("th",{children:"Created"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:l.map(t=>{var s;return(0,n.jsxs)(r.Fragment,{children:[(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(v,{tone:"info",children:t.role}):"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:t.status})}),(0,n.jsx)("td",{className:"mono faint",children:t.working_dir}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.created_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]}),("working"===t.status||"crashed"===t.status)&&(null===(s=t.last_output)||void 0===s?void 0:s.trim())&&(0,n.jsx)("tr",{children:(0,n.jsxs)("td",{colSpan:6,style:{paddingTop:0},children:[(0,n.jsxs)("div",{className:"faint",style:{fontSize:"var(--text-xs)",marginBottom:4},children:["crashed"===t.status?"last output before crash":"live output",t.output_at?" \xb7 ".concat(x(t.output_at)):""]}),(0,n.jsx)("pre",{className:"mono",style:{margin:0,padding:"8px 10px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6,fontSize:"var(--text-xs)",lineHeight:1.4,maxHeight:220,overflow:"auto",whiteSpace:"pre"},children:t.last_output})]})})]},t.id)})})]})})]})})]})}let F=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],B=[{value:"900",label:"every 15 minutes"},{value:"1800",label:"every 30 minutes"},{value:"3600",label:"every hour"},{value:"21600",label:"every 6 hours"},{value:"86400",label:"every day"},{value:"604800",label:"every week"}],H={name_prefix:"",task:"",interval:"3600",role:""};function W(){let e=o(),[t,s]=(0,r.useState)(H),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.createSchedule(e.selectedProjectId,{name_prefix:t.name_prefix,task:t.task,interval_seconds:Number(t.interval),role:t.role})&&s(H)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Schedules"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a file the prompt reads and overwrites."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"New schedule"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(w,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:B}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:F})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Prompt (the task every run starts with)",value:t.task,onChange:e=>s({...t,task:e}),rows:3,placeholder:"Read @notes.md and continue from where it left off. Before finishing, overwrite @notes.md with the current state so the next run can pick up from there."})}),(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:["Runs are named ",(0,n.jsxs)("span",{className:"mono",children:[t.name_prefix.trim()||"prefix","-YYYYMMDD-HHMMSS"]}),". The repo is pulled before every run; the first run fires on the worker's next pass."]}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name_prefix.trim()||!t.task.trim(),onClick:l,children:"Create schedule"})})]}),0===e.schedules.length?(0,n.jsx)("div",{className:"empty",children:"No schedules yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"On"}),(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Interval"}),(0,n.jsx)("th",{children:"Prompt"}),(0,n.jsx)("th",{children:"Next run"}),(0,n.jsx)("th",{children:"Last run"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:e.schedules.map(t=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{children:(0,n.jsx)(S,{on:t.enabled,onClick:()=>e.updateSchedule(t.id,{enabled:!t.enabled})})}),(0,n.jsxs)("td",{className:"mono",children:[t.name_prefix,t.role?(0,n.jsxs)(n.Fragment,{children:[" ",(0,n.jsx)(v,{tone:"info",children:t.role})]}):null]}),(0,n.jsx)("td",{className:"mono faint",children:t.project_id}),(0,n.jsx)("td",{className:"nowrap",children:function(e){let t=B.find(t=>Number(t.value)===e);return t?t.label:e%3600==0?"every ".concat(e/3600,"h"):e%60==0?"every ".concat(e/60,"m"):"every ".concat(e,"s")}(t.interval_seconds)}),(0,n.jsx)("td",{className:"faint",style:{maxWidth:340},children:(0,n.jsx)("span",{className:"truncate",style:{display:"block"},title:t.task,children:t.task})}),(0,n.jsx)("td",{className:"faint nowrap",children:t.enabled?m(t.next_run_at):"paused"}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.last_run_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let q=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],G={branch:"",status:"approved",agent_name:"",sha:"",note:""};function J(){let e=o(),[t,s]=(0,r.useState)(G),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.submitApproval(t),s(G)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Approvals"}),(0,n.jsx)("div",{className:"section-desc",children:"A merge is denied unless a standing approval exists — made by a different agent, pinned to the reviewed commit."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Record a verdict"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(w,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:q}),(0,n.jsx)(b,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(b,{label:"SHA",value:t.sha,onChange:e=>s({...t,sha:e}),placeholder:"pins the approval (optional)"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(b,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.branch.trim(),onClick:l,children:"Enqueue verdict"})})]}),0===e.approvals.length?(0,n.jsx)("div",{className:"empty",children:"No approvals recorded."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Branch"}),(0,n.jsx)("th",{children:"Verdict"}),(0,n.jsx)("th",{children:"By"}),(0,n.jsx)("th",{children:"SHA"}),(0,n.jsx)("th",{children:"Note"})]})}),(0,n.jsx)("tbody",{children:e.approvals.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.branch}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.approved_by_agent_id?"agent ".concat(e.approved_by_agent_id):e.actor||"—"}),(0,n.jsx)("td",{className:"mono",children:p(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let K=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],Y={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function V(e){let{value:t}=e,[s,a]=(0,r.useState)(!1),l=async()=>{try{await navigator.clipboard.writeText(t),a(!0),setTimeout(()=>a(!1),1500)}catch(e){}};return(0,n.jsxs)("div",{style:{marginTop:10},children:[(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsx)("span",{className:"eyebrow",children:"SSH public key — add it to the forge (deploy key)"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:l,children:s?"Copied":"Copy"})]}),(0,n.jsx)("pre",{className:"mono",style:{fontSize:"var(--text-xs)",whiteSpace:"pre-wrap",wordBreak:"break-all",margin:"6px 0 0",padding:8,border:"1px solid var(--border-default)",borderRadius:6,userSelect:"all"},children:t})]})}function Q(){let e=o(),[t,s]=(0,r.useState)(Y),[a,l]=(0,r.useState)(!1),i=()=>{s(Y),l(!1)},c=async()=>{(a?await e.updateHost(t.hostname,t):await e.createHost(t))&&i()},d=e=>{var t,a;s({hostname:e.hostname,forge_type:e.forge_type,token_env_var:null!==(t=e.token_env_var)&&void 0!==t?t:"",base_url:null!==(a=e.base_url)&&void 0!==a?a:"",token:"",generate_ssh_key:!1}),l(!0)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Git Servers"}),(0,n.jsxs)("div",{className:"section-desc",children:["Each server carries its own credentials: a forge token (encrypted at rest, used by agents' ",(0,n.jsx)("span",{className:"mono",children:"forge"})," + git) and an SSH deploy key — paste the public key into the forge. New repositories are added by picking a server and typing owner/name."]})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit server \xb7 ".concat(t.hostname):"Add a git server"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(w,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:K}),(0,n.jsx)(b,{label:a?"Forge token (blank = keep current)":"Forge token",type:"password",value:t.token,onChange:e=>s({...t,token:e}),placeholder:"stored encrypted; used by forge + git"}),(0,n.jsx)(b,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(b,{label:"Token env var override (optional)",value:t.token_env_var,onChange:e=>s({...t,token_env_var:e}),placeholder:"GITEA_TOKEN"}),(0,n.jsxs)("label",{className:"field",children:[(0,n.jsx)("span",{className:"field-label",children:"SSH deploy key"}),(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer"},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.generate_ssh_key,onChange:e=>s({...t,generate_ssh_key:e.target.checked})}),(0,n.jsx)("span",{style:{fontSize:"var(--text-sm)"},children:a?"Regenerate keypair (replaces the current key)":"Generate a keypair"})]})]})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:i,children:"Cancel"})]})]}),0===e.hosts.length&&(0,n.jsx)("div",{className:"empty",children:"No git servers registered (built-in host map still applies)."}),e.hosts.map(t=>(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"mono",style:{fontWeight:"var(--fw-bold)",fontSize:"var(--text-lg)",color:"var(--text-heading)"},children:t.hostname}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(v,{tone:"info",children:t.forge_type}),(0,n.jsx)(v,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(v,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteHost(t.hostname),children:"Remove"})]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:8},children:["token env ",t.token_env_var||"—",t.base_url?" \xb7 ".concat(t.base_url):""]}),t.ssh_public_key&&(0,n.jsx)(V,{value:t.ssh_public_key})]},t.hostname))]})]})}function X(){let e=o();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"section-head",children:(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"section-title",children:"Activity"}),(0,n.jsx)("div",{className:"section-desc",children:"Control commands the worker drains from the queue."})]}),(0,n.jsx)(y,{variant:"secondary",disabled:e.cmd.busy,onClick:()=>e.pollCi(),children:"Sweep CI now"})]})}),(0,n.jsx)("div",{className:"section-body",children:0===e.commands.length?(0,n.jsx)("div",{className:"empty",children:"No commands yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Type"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Result / Error"})]})}),(0,n.jsx)("tbody",{children:e.commands.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.type}),(0,n.jsx)("td",{className:"mono",children:e.project_id||"—"}),(0,n.jsx)("td",{className:"mono",children:e.agent_name||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:e.error||(e.result?JSON.stringify(e.result):"—")})]},e.id))})]})})})]})}function $(){let e=o(),[t,s]=(0,r.useState)(""),[a,l]=(0,r.useState)(""),i=async()=>{t.trim()&&a.trim()&&await e.setSharedKey(t.trim(),a.trim())&&(s(""),l(""))};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Shared"}),(0,n.jsx)("div",{className:"section-desc",children:"The cross-project global feed and shared facts."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Global feed"}),0===e.shared.log.length?(0,n.jsx)("div",{className:"empty",children:"No global log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:e.shared.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.agent_id}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{status:e.ci_status})})]},e.id))})]})})]}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Set a shared key"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(b,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(b,{label:"Value",value:a,onChange:l,placeholder:"value"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Requires the shared-context write token (or admin/global if unset)."}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:!t.trim()||!a.trim(),onClick:i,children:"Set"})})]}),e.shared.context.length>0&&(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Key"}),(0,n.jsx)("th",{children:"Value"}),(0,n.jsx)("th",{children:"Updated"})]})}),(0,n.jsx)("tbody",{children:e.shared.context.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:e.key}),(0,n.jsx)("td",{children:e.value}),(0,n.jsx)("td",{className:"faint nowrap",children:m(e.updated_at)})]},e.key))})]})})]})]})}function Z(e){let t=window.screenX+Math.max(0,(window.outerWidth-520)/2),s=window.screenY+Math.max(0,(window.outerHeight-760)/2);return window.open(e,"claude-login","popup=yes,width=".concat(520,",height=").concat(760,",left=").concat(Math.round(t),",top=").concat(Math.round(s)))}function ee(){let e=o(),{status:t,url:s,message:a}=e.claudeLogin,[l,i]=(0,r.useState)(""),c=(0,r.useRef)(null),d="starting"===t||"submitting"===t,u="awaiting"===t||"submitting"===t;(0,r.useEffect)(()=>{if("awaiting"===t&&s&&c.current&&!c.current.closed)try{c.current.location.href=s}catch(e){}if("done"===t||"error"===t){var e;null===(e=c.current)||void 0===e||e.close(),c.current=null}},[t,s]);let h=()=>{c.current=Z("about:blank"),e.startClaudeLogin()},m=async()=>{await e.submitClaudeCode(l)&&i("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Claude Login"}),(0,n.jsxs)("div",{className:"section-desc",children:["Log Claude Code in on the host so agents can run. This drives"," ",(0,n.jsx)("span",{className:"mono",children:"claude /login"})," in the control container and picks the Claude account with a subscription."]})]}),(0,n.jsxs)("div",{className:"section-body",style:{display:"flex",flexDirection:"column",gap:16},children:[a&&(0,n.jsx)(C,{tone:"error"===t?"danger":"done"===t?"success":"info",children:a}),"done"===t?(0,n.jsx)("div",{children:(0,n.jsx)(y,{variant:"secondary",onClick:e.resetClaudeLogin,children:"Log in again"})}):u?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(C,{tone:"info",children:"A Claude sign-in window should have opened. Authorize there, copy the code Claude shows you, and paste it below. If the window didn't open (popups blocked), use the button."}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,flexWrap:"wrap"},children:[(0,n.jsx)(y,{variant:"secondary",disabled:!s,onClick:()=>{s&&(c.current=Z(s))},children:"Open Claude sign-in window ↗"}),s&&(0,n.jsx)("a",{className:"btn btn-ghost",href:s,target:"_blank",rel:"noopener noreferrer",children:"Open in a new tab"}),(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Restart"})]}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,alignItems:"flex-end",flexWrap:"wrap"},children:[(0,n.jsx)("div",{style:{flex:"1 1 320px"},children:(0,n.jsx)(b,{label:"Authorization code",value:l,onChange:i,placeholder:"Paste the code from claude.com",disabled:"submitting"===t})}),(0,n.jsx)(y,{variant:"primary",disabled:"submitting"===t||!l.trim(),onClick:m,children:"submitting"===t?"Submitting…":"Finish login"})]})]}):(0,n.jsxs)("div",{className:"hstack",style:{gap:10},children:[(0,n.jsx)(y,{variant:"primary",disabled:d,onClick:h,children:"starting"===t?"Starting…":"Log in to Claude"}),"error"===t&&(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Retry"})]})]})]})}let et=[{key:"runs",label:"Runs",count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},{key:"repositories",label:"Repositories",count:e=>e.projects.length},{key:"agents",label:"Agents",count:e=>e.agents.length},{key:"schedules",label:"Schedules",count:e=>e.schedules.length},{key:"approvals",label:"Approvals",count:e=>e.approvals.length},{key:"servers",label:"Git Servers",count:e=>e.hosts.length},{key:"activity",label:"Activity",count:e=>e.commands.length},{key:"shared",label:"Shared",count:e=>e.shared.context.length},{key:"login",label:"Claude Login",count:()=>0,accent:e=>"done"!==e.claudeLogin.status}];function es(e){let{onSignOut:t}=e,s=o();return(0,n.jsxs)("div",{className:"app",children:[(0,n.jsxs)("aside",{className:"sidebar",children:[(0,n.jsxs)("div",{className:"brand",children:[(0,n.jsx)("span",{className:"logo"}),"Claude Monitor"]}),et.map(e=>{var t,a;let r=e.count(s),l=null!==(a=null===(t=e.accent)||void 0===t?void 0:t.call(e,s))&&void 0!==a&&a;return(0,n.jsxs)("button",{className:"nav-item".concat(s.section===e.key?" active":""),onClick:()=>s.setSection(e.key),children:[(0,n.jsx)("span",{children:e.label}),(0,n.jsx)("span",{className:"count",style:l?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,n.jsx)("div",{className:"sidebar-spacer"}),(0,n.jsxs)("div",{className:"sidebar-foot",children:[(0,n.jsxs)("button",{className:"nav-item",onClick:s.refresh,title:"Refresh now",children:[(0,n.jsx)("span",{children:"Refresh"}),(0,n.jsx)("span",{className:"count",children:"↻"})]}),(0,n.jsx)("button",{className:"nav-item",onClick:t,title:"Sign out / change token",children:(0,n.jsx)("span",{children:"Sign out"})})]})]}),(0,n.jsxs)("main",{className:"main",children:[s.cmd.text&&(0,n.jsx)("p",{className:"banner ".concat(s.cmd.error?"err":"ok"),style:{marginTop:16},children:s.cmd.text}),s.lastError&&(0,n.jsx)("p",{className:"banner err",style:{marginTop:12},children:s.lastError}),"runs"===s.section?(0,n.jsx)(I,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(O,{}),"agents"===s.section&&(0,n.jsx)(U,{}),"schedules"===s.section&&(0,n.jsx)(W,{}),"approvals"===s.section&&(0,n.jsx)(J,{}),"servers"===s.section&&(0,n.jsx)(Q,{}),"activity"===s.section&&(0,n.jsx)(X,{}),"shared"===s.section&&(0,n.jsx)($,{}),"login"===s.section&&(0,n.jsx)(ee,{})]})]})]})}function ea(e){let{error:t,onSubmit:s}=e,[a,l]=(0,r.useState)("");return(0,n.jsx)("div",{className:"gate",children:(0,n.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let t=a.trim();t&&s(t)},children:[(0,n.jsxs)("div",{className:"gate-brand",children:[(0,n.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,n.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,n.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:a,onChange:e=>l(e.target.value),autoFocus:!0}),t&&(0,n.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:t}),(0,n.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let en="handler_token";function er(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(en);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(en,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(en),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(en),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(es,{onSignOut:i})}):(0,n.jsx)(ea,{error:s,onSubmit:l})}},257:function(e,t,s){"use strict";var a,n;e.exports=(null==(a=s.g.process)?void 0:a.env)&&"object"==typeof(null==(n=s.g.process)?void 0:n.env)?s.g.process:s(4227)},4227:function(e){!function(){var t={229:function(e){var t,s,a,n=e.exports={};function r(){throw Error("setTimeout has not been defined")}function l(){throw Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(s){try{return t.call(null,e,0)}catch(s){return t.call(this,e,0)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(e){t=r}try{s="function"==typeof clearTimeout?clearTimeout:l}catch(e){s=l}}();var c=[],o=!1,d=-1;function u(){o&&a&&(o=!1,a.length?c=a.concat(c):d=-1,c.length&&h())}function h(){if(!o){var e=i(u);o=!0;for(var t=c.length;t;){for(a=c,c=[];++d1)for(var s=1;s(function(e,t){async function s(s,a){var n;let r=(null==a?void 0:a.body)!==void 0&&(null==a?void 0:a.body)!==null,c=await fetch(l+s,{method:null!==(n=null==a?void 0:a.method)&&void 0!==n?n:r?"POST":"GET",headers:{Authorization:"Bearer ".concat(e),...r?{"Content-Type":"application/json"}:{}},body:r?JSON.stringify(a.body):void 0});if(401===c.status)throw t(),new i;if(!c.ok){let e=c.statusText;try{let t=await c.json();t&&void 0!==t.detail&&(e="string"==typeof t.detail?t.detail:JSON.stringify(t.detail))}catch(e){}let t=Error(e);throw t.status=c.status,t}if(204===c.status)return;let o=await c.text();return o?JSON.parse(o):void 0}async function a(e,t){var a,n;let r=null!==(a=null==t?void 0:t.attempts)&&void 0!==a?a:60,l=null!==(n=null==t?void 0:t.intervalMs)&&void 0!==n?n:500;for(let t=0;tsetTimeout(e,l))}return null}return{api:s,trackCommand:a}})(t,s),[t,s]),d=(0,r.useRef)(o);d.current=o;let[u,h]=(0,r.useState)("runs"),[m,x]=(0,r.useState)([]),[p,j]=(0,r.useState)([]),[v,g]=(0,r.useState)(""),[y,b]=(0,r.useState)(null),[f,k]=(0,r.useState)(null),[w,N]=(0,r.useState)(!1),[_,C]=(0,r.useState)([]),[S,R]=(0,r.useState)(0),[I,T]=(0,r.useState)([]),[A,z]=(0,r.useState)([]),[E,P]=(0,r.useState)([]),[M,O]=(0,r.useState)([]),[L,U]=(0,r.useState)({log:[],context:[]}),[D,F]=(0,r.useState)({text:"",error:!1,busy:!1}),[B,H]=(0,r.useState)(""),[q,W]=(0,r.useState)(!0),[G,J]=(0,r.useState)({status:"idle",url:"",message:""}),K=(0,r.useRef)(u);K.current=u;let Y=(0,r.useRef)(v);Y.current=v;let V=(0,r.useRef)(y);V.current=y;let Q=(0,r.useRef)(S);Q.current=S;let X=e=>{e instanceof i||H(e.message)},$=(0,r.useCallback)(async()=>{try{let e=await d.current.api("/projects");x(e),H(""),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")})}catch(e){X(e)}},[]),Z=(0,r.useCallback)(async e=>{try{let t=await Promise.all(e.map(e=>d.current.api("/projects/".concat(encodeURIComponent(e.id),"/agents")).catch(()=>[])));j(t.flat())}catch(e){X(e)}},[]),ee=(0,r.useCallback)(async(e,t)=>{let s="/projects/".concat(encodeURIComponent(e),"/agents/").concat(encodeURIComponent(t));try{let e=await d.current.api("".concat(s,"/checkmark"));k(e),N(!1)}catch(e){if(e instanceof i)return;404===e.status?(k(null),N(!0)):X(e)}try{let e=await d.current.api("".concat(s,"/log?limit=").concat(100,"&offset=").concat(Q.current));C(e)}catch(e){X(e)}},[]),et=(0,r.useCallback)(async e=>{if(!e){T([]);return}try{T(await d.current.api("/projects/".concat(encodeURIComponent(e),"/approvals")))}catch(e){X(e)}},[]),es=(0,r.useCallback)(async()=>{try{z(await d.current.api("/hosts"))}catch(e){X(e)}},[]),ea=(0,r.useCallback)(async()=>{try{P(await d.current.api("/commands?limit=50"))}catch(e){X(e)}},[]),en=(0,r.useCallback)(async()=>{try{O(await d.current.api("/schedules"))}catch(e){X(e)}},[]),er=(0,r.useCallback)(async()=>{try{let[e,t]=await Promise.all([d.current.api("/shared/log"),d.current.api("/shared/context")]);U({log:e,context:t})}catch(e){X(e)}},[]),el=(0,r.useCallback)(async()=>{let e=await d.current.api("/projects").catch(e=>(X(e),null));e&&(x(e),g(t=>{var s,a;return t||(null!==(a=null===(s=e[0])||void 0===s?void 0:s.id)&&void 0!==a?a:"")}),await Z(e));let t=K.current,s=V.current;s&&await ee(s.projectId,s.name),"approvals"===t&&await et(Y.current),"servers"===t&&await es(),"activity"===t&&await ea(),"schedules"===t&&await en(),"shared"===t&&await er()},[Z,ee,et,es,ea,en,er]);(0,r.useEffect)(()=>{let e=!0;(async()=>{W(!0),await el(),e&&W(!1)})();let t=setInterval(()=>{document.hidden||el()},5e3);return()=>{e=!1,clearInterval(t)}},[el]);let ei=(0,r.useCallback)(e=>{h(e),F({text:"",error:!1,busy:!1}),"approvals"===e&&et(Y.current),"servers"===e&&es(),"activity"===e&&ea(),"schedules"===e&&en(),"shared"===e&&er()},[et,es,ea,en,er]),ec=(0,r.useCallback)(e=>{g(e),"approvals"===K.current&&et(e)},[et]),eo=(0,r.useCallback)((e,t)=>{b({projectId:e,name:t}),R(0),Q.current=0,k(null),N(!1),C([]),ee(e,t)},[ee]),ed=(0,r.useCallback)(e=>{let t=Math.max(0,S+100*e);if(t===S)return;R(t),Q.current=t;let s=V.current;s&&ee(s.projectId,s.name)},[S,ee]),eu=(0,r.useCallback)(()=>{el()},[el]),eh=(0,r.useCallback)(async(e,t,s)=>{F({text:"".concat(s,": queued…"),error:!1,busy:!0});try{let a=await d.current.api(e,{method:"POST",body:t}),n=await d.current.trackCommand(a.id);if(!n)return F({text:"".concat(s,": still running (see Activity). Is the worker up?"),error:!1,busy:!1}),null;let r="done"===n.status,l=n.error||(n.result?JSON.stringify(n.result):"");return F({text:"".concat(s," ").concat(r?"done":"failed").concat(l?" — "+l:""),error:!r,busy:!1}),n}catch(e){if(e instanceof i)return null;return F({text:"".concat(s," failed: ").concat(e.message),error:!0,busy:!1}),null}},[]),em=(0,r.useCallback)(async e=>{let t={name:e.name.trim(),role:e.role||null,task:e.task.trim()||null};"worktree"===e.placement&&e.worktree.trim()&&(t.worktree=e.worktree.trim()),"subdir"===e.placement&&e.subdir.trim()&&(t.subdir=e.subdir.trim());let s=encodeURIComponent(Y.current),a=await eh("/projects/".concat(s,"/agents/spawn"),t,"spawn ".concat(t.name));return await Z(m),(null==a?void 0:a.status)==="done"},[eh,Z,m]),ex=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);await eh("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t),"/kill"),void 0,"kill ".concat(t)),await Z(m)},[eh,Z,m]),ep=(0,r.useCallback)(async(e,t)=>{let s=encodeURIComponent(e);try{var a;await d.current.api("/projects/".concat(s,"/agents/").concat(encodeURIComponent(t)),{method:"DELETE"}),F({text:"agent '".concat(t,"' row deleted"),error:!1,busy:!1}),(null===(a=V.current)||void 0===a?void 0:a.name)===t&&b(null),await Z(m)}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[Z,m]),ej=(0,r.useCallback)(async(e,t)=>{let s=V.current;if(!s)return!1;let a="/projects/".concat(encodeURIComponent(s.projectId),"/agents/").concat(encodeURIComponent(s.name));try{return await d.current.api("".concat(a,"/answer"),{method:"POST",body:{answer:e}}),t?await eh("".concat(a,"/resume"),{answer:e},"resume"):F({text:"Answer saved (agent still paused).",error:!1,busy:!1}),await Z(m),await ee(s.projectId,s.name),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[eh,Z,ee,m]),ev=(0,r.useCallback)(async e=>{try{var t,s;let a="server"===e.mode?{git_server:e.git_server,repo:e.repo.trim(),id:e.id.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise}:{id:e.id.trim(),root_dir:e.root_dir.trim(),git_remote:e.git_remote.trim()||null,credential_ref:e.credential_ref.trim()||null,init_mise:e.init_mise},n=await d.current.api("/projects",{method:"POST",body:a});if(await $(),null!=n.sync_command_id){F({text:"repository '".concat(n.id,"': cloning…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.sync_command_id);e?"done"===e.status?F({text:"repository '".concat(n.id,"' registered and cloned"),error:!1,busy:!1}):F({text:"repository '".concat(n.id,"' registered but the clone failed — ").concat(null!==(t=e.error)&&void 0!==t?t:""),error:!0,busy:!1}):F({text:"repository '".concat(n.id,"' registered; clone still running (see Activity). Is the worker up?"),error:!1,busy:!1})}else F({text:"repository '".concat(n.id,"' registered"),error:!1,busy:!1});if(null!=n.mise_init_command_id){F({text:"repository '".concat(n.id,"': launching a mise-init agent to create .mise.toml…"),error:!1,busy:!0});let e=await d.current.trackCommand(n.mise_init_command_id);e?"done"===e.status?F({text:"repository '".concat(n.id,"' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs)."),error:!1,busy:!1}):F({text:"repository '".concat(n.id,"' registered but the mise-init agent failed to launch — ").concat(null!==(s=e.error)&&void 0!==s?s:""),error:!0,busy:!1}):F({text:"repository '".concat(n.id,"' registered; mise-init still starting (see Activity). Is the worker up?"),error:!1,busy:!1})}return!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[$]),eg=(0,r.useCallback)(async e=>{await eh("/projects/".concat(encodeURIComponent(e),"/sync"),void 0,"pull ".concat(e))},[eh]),ey=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"PATCH",body:{root_dir:t.root_dir.trim(),git_remote:t.git_remote.trim()||null,credential_ref:t.credential_ref.trim()||null}}),F({text:"repository '".concat(e,"' updated"),error:!1,busy:!1}),await $(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[$]),eb=(0,r.useCallback)(async e=>{try{await d.current.api("/projects/".concat(encodeURIComponent(e)),{method:"DELETE"}),F({text:"repository '".concat(e,"' removed"),error:!1,busy:!1}),g(t=>t===e?"":t),await $()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[$]),ef=(0,r.useCallback)(async e=>{let t=encodeURIComponent(Y.current);await eh("/projects/".concat(t,"/approvals"),{branch:e.branch.trim(),status:e.status,agent_name:e.agent_name.trim()||null,sha:e.sha.trim()||null,note:e.note.trim()||null},"".concat(e.status," ").concat(e.branch)),await et(Y.current)},[eh,et]),ek=(0,r.useCallback)(async e=>{try{return await d.current.api("/hosts",{method:"POST",body:{hostname:e.hostname.trim(),forge_type:e.forge_type,token_env_var:e.token_env_var.trim()||null,base_url:e.base_url.trim()||null,token:e.token.trim()||null,generate_ssh_key:e.generate_ssh_key}}),F({text:"git server '".concat(e.hostname,"' added"),error:!1,busy:!1}),await es(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[es]),ew=(0,r.useCallback)(async(e,t)=>{try{let s={forge_type:t.forge_type,token_env_var:t.token_env_var.trim()||null,base_url:t.base_url.trim()||null};return t.token.trim()&&(s.token=t.token.trim()),t.generate_ssh_key&&(s.regenerate_ssh_key=!0),await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"PATCH",body:s}),F({text:"git server '".concat(e,"' updated"),error:!1,busy:!1}),await es(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[es]),eN=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/projects/".concat(encodeURIComponent(e),"/schedules"),{method:"POST",body:{name_prefix:t.name_prefix.trim(),task:t.task.trim(),interval_seconds:t.interval_seconds,role:t.role||null}}),F({text:"schedule '".concat(t.name_prefix,"' created — first run on the worker's next pass"),error:!1,busy:!1}),await en(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[en]),e_=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/schedules/".concat(e),{method:"PATCH",body:t}),await en(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[en]),eC=(0,r.useCallback)(async e=>{try{await d.current.api("/schedules/".concat(e),{method:"DELETE"}),F({text:"schedule ".concat(e," removed"),error:!1,busy:!1}),await en()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[en]),eS=(0,r.useCallback)(async e=>{try{await d.current.api("/hosts/".concat(encodeURIComponent(e)),{method:"DELETE"}),F({text:"git server '".concat(e,"' removed"),error:!1,busy:!1}),await es()}catch(e){if(e instanceof i)return;F({text:e.message,error:!0,busy:!1})}},[es]),eR=(0,r.useCallback)(async()=>{await eh("/poll-ci",void 0,"poll-ci (all projects)"),await ea()},[eh,ea]),eI=(0,r.useCallback)(async()=>{J({status:"starting",url:"",message:"Opening `claude /login` in the control container and selecting the subscription account…"});try{let e=await d.current.api("/login/start",{method:"POST"}),t=await d.current.trackCommand(e.id,{attempts:180});if(!t){J({status:"error",url:"",message:"Still starting (see Activity). Is the control worker running?"});return}if("done"!==t.status){J({status:"error",url:"",message:t.error||"Failed to start login."});return}let s=t.result&&"string"==typeof t.result.url?t.result.url:"";if(!s){J({status:"error",url:"",message:"No login URL was returned by claude."});return}J({status:"awaiting",url:s,message:"Authorize in the window below (or open it in a new tab), then paste the code claude gives you."})}catch(e){if(e instanceof i)return;J({status:"error",url:"",message:e.message})}},[]),eT=(0,r.useCallback)(async e=>{let t=e.trim();if(!t)return!1;J(e=>({...e,status:"submitting",message:"Submitting the authorization code…"}));try{let e=await d.current.api("/login/submit",{method:"POST",body:{code:t}}),s=await d.current.trackCommand(e.id,{attempts:60});if(!s)return J(e=>({...e,status:"awaiting",message:"Submit still running (see Activity). Is the control worker running?"})),!1;if("done"===s.status)return J({status:"done",url:"",message:"Claude Code is now logged in on the host — new agents will use this account."}),!0;return J(e=>({...e,status:"awaiting",message:s.error||"Login was not confirmed. Re-check the code, or restart the flow."})),!1}catch(e){if(e instanceof i)return!1;return J(t=>({...t,status:"awaiting",message:e.message})),!1}},[]),eA=(0,r.useCallback)(()=>{J({status:"idle",url:"",message:""})},[]),ez=(0,r.useCallback)(async(e,t)=>{try{return await d.current.api("/shared/context/".concat(encodeURIComponent(e)),{method:"PUT",body:{value:t}}),F({text:"shared context '".concat(e,"' set"),error:!1,busy:!1}),await er(),!0}catch(e){if(e instanceof i)return!1;return F({text:e.message,error:!0,busy:!1}),!1}},[er]);return(0,n.jsx)(c.Provider,{value:{section:u,setSection:ei,projects:m,agents:p,selectedProjectId:v,selectProject:ec,selectedRun:y,selectRun:eo,checkmark:f,checkmarkMissing:w,log:_,logOffset:S,pageLog:ed,approvals:I,hosts:A,commands:E,schedules:M,shared:L,cmd:D,lastError:B,loading:q,refresh:eu,spawnAgent:em,killAgent:ex,deleteAgent:ep,submitAnswer:ej,createProject:ev,updateProject:ey,deleteProject:eb,syncProject:eg,submitApproval:ef,createHost:ek,updateHost:ew,deleteHost:eS,createSchedule:eN,updateSchedule:e_,deleteSchedule:eC,pollCi:eR,setSharedKey:ez,claudeLogin:G,startClaudeLogin:eI,submitClaudeCode:eT,resetClaudeLogin:eA},children:a})}function u(e){switch((null!=e?e:"").toLowerCase()){case"pass":case"done":case"completed":case"approved":case"success":return"success";case"fail":case"failed":case"blocked":case"rejected":case"error":return"danger";case"pending":case"queued":case"running":case"working":case"paused_for_input":return"warning";case"not_applicable":case"unknown":case"":return"neutral";default:return"info"}}let h={paused_for_input:"Needs input",not_applicable:"N/A"};function m(e){if(!e)return"—";let t=new Date(e);return Number.isNaN(t.getTime())?String(e):t.toLocaleString(void 0,{year:"numeric",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"})}function x(e){return e?e.slice(0,7):"—"}function p(e){if(!e)return"—";let t=new Date(e).getTime();if(Number.isNaN(t))return"—";let s=Math.max(0,Math.floor((Date.now()-t)/1e3));if(s<60)return"".concat(s,"s");let a=Math.floor(s/60);if(a<60)return"".concat(a,"m");let n=Math.floor(a/60);if(n<24)return"".concat(n,"h");let r=Math.floor(n/24);if(r<30)return"".concat(r,"d");let l=Math.floor(r/30);return l<12?"".concat(l,"mo"):"".concat(Math.floor(l/12),"y")}function j(e){let{tone:t="neutral",pill:s=!1,dot:a=!1,children:r}=e;return(0,n.jsxs)("span",{className:"badge badge-".concat(t).concat(s?" pill":""),children:[a&&(0,n.jsx)("span",{className:"dot"}),r]})}function v(e){let{status:t}=e;return(0,n.jsx)(j,{tone:u(t),children:function(e){let t=(null!=e?e:"").trim();if(!t)return"—";let s=t.toLowerCase();return h[s]?h[s]:s.replace(/_/g," ").replace(/\b\w/g,e=>e.toUpperCase())}(t)})}function g(e){let{children:t,interactive:s=!1,onClick:a,className:r=""}=e;return(0,n.jsx)("div",{className:"card".concat(s?" interactive":""," ").concat(r).trim(),onClick:a,role:s?"button":void 0,tabIndex:s?0:void 0,children:t})}function y(e){let{variant:t="secondary",size:s="md",onClick:a,disabled:r,type:l="button",children:i}=e;return(0,n.jsx)("button",{type:l,className:"btn btn-".concat(t).concat("sm"===s?" btn-sm":""),onClick:a,disabled:r,children:i})}function b(e){let{label:t,children:s}=e;return(0,n.jsxs)("label",{className:"field",children:[t&&(0,n.jsx)("span",{className:"field-label",children:t}),s]})}function f(e){let{label:t,value:s,onChange:a,placeholder:r,type:l="text",disabled:i}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("input",{className:"input",type:l,value:s,placeholder:r,disabled:i,onChange:e=>a(e.target.value)})})}function k(e){let{label:t,value:s,onChange:a,placeholder:r,rows:l=3}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("textarea",{className:"textarea",value:s,rows:l,placeholder:r,onChange:e=>a(e.target.value)})})}function w(e){let{label:t,value:s,onChange:a,options:r}=e;return(0,n.jsx)(b,{label:t,children:(0,n.jsx)("select",{className:"select",value:s,onChange:e=>a(e.target.value),children:r.map(e=>(0,n.jsx)("option",{value:e.value,children:e.label},e.value))})})}function N(e){let{tabs:t,value:s,onChange:a}=e;return(0,n.jsx)("div",{className:"tabs",role:"tablist",children:t.map(e=>(0,n.jsx)("button",{role:"tab","aria-selected":s===e.value,className:"tab".concat(s===e.value?" active":""),onClick:()=>a(e.value),children:e.label},e.value))})}function _(e){let{value:t,label:s,sub:a,accent:r=!1}=e;return(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"stat-value".concat(r?" accent":""),children:t}),(0,n.jsx)("div",{className:"stat-label",children:s}),a&&(0,n.jsx)("div",{className:"stat-sub",children:a})]})}function C(e){let{tone:t="info",children:s}=e;return(0,n.jsx)("div",{className:"callout callout-".concat(t),children:s})}function S(e){let{on:t,onClick:s}=e;return(0,n.jsx)("button",{type:"button",className:"toggle".concat(t?" on":""),"aria-pressed":t,onClick:s,children:(0,n.jsx)("span",{className:"knob"})})}let R=[{value:"all",label:"All"},{value:"needs",label:"Needs Input"},{value:"working",label:"Working"},{value:"done",label:"Done"}];function I(){let e=o(),[t,s]=(0,r.useState)("all"),a=(0,r.useMemo)(()=>[...e.agents.filter(e=>{var s;return s=e.status,"all"===t||("needs"===t?"paused_for_input"===s:"working"===t?"working"===s||"running"===s:"done"!==t||"done"===s||"completed"===s)})].sort((e,t)=>e.created_at"paused_for_input"===e.status).length,c=e.agents.filter(e=>"working"===e.status||"running"===e.status).length;return(0,n.jsxs)("div",{className:"runs",children:[(0,n.jsx)("div",{className:"runs-stats",children:(0,n.jsxs)("div",{className:"stat-row",children:[(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.agents.length,label:"Runs tracked"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:i,label:"Needs input",accent:!0})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:c,label:"Working"})}),(0,n.jsx)("div",{className:"stat-cell",children:(0,n.jsx)(_,{value:e.projects.length,label:"Repositories"})})]})}),(0,n.jsxs)("div",{className:"split",children:[(0,n.jsxs)("div",{className:"split-list",children:[(0,n.jsxs)("div",{className:"split-list-head",children:[(0,n.jsx)("div",{className:"section-title",style:{fontSize:"var(--text-lg)"},children:"Runs"}),(0,n.jsx)(N,{tabs:R,value:t,onChange:s})]}),(0,n.jsxs)("div",{className:"split-list-scroll",children:[0===a.length&&(0,n.jsx)(C,{tone:"info",children:"No runs match this filter."}),a.map(t=>(0,n.jsx)(T,{agent:t,selected:(null==l?void 0:l.projectId)===t.project_id&&(null==l?void 0:l.name)===t.name,onSelect:()=>e.selectRun(t.project_id,t.name)},"".concat(t.project_id,"/").concat(t.name)))]})]}),(0,n.jsx)("div",{className:"split-detail",children:l?(0,n.jsx)(z,{}):(0,n.jsx)(A,{})})]})]})}function T(e){let{agent:t,selected:s,onSelect:a}=e;return(0,n.jsxs)("button",{className:"run-row".concat(s?" selected":""),onClick:a,children:[(0,n.jsxs)("div",{className:"run-row-top",children:[(0,n.jsx)("span",{className:"run-project",children:t.project_id}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:p(t.created_at)})]}),(0,n.jsxs)("div",{className:"truncate muted",style:{fontSize:"var(--text-sm)"},children:[t.name,t.role?" \xb7 ".concat(t.role):""]}),(0,n.jsx)("div",{className:"hstack",style:{gap:8},children:(0,n.jsx)(v,{status:t.status})})]})}function A(){return(0,n.jsx)("div",{style:{padding:"60px 32px",color:"var(--text-muted)"},children:"Select a run to see its checkmark, log, and any open question."})}function z(){var e;let t=o(),s=t.selectedRun,a=t.agents.find(e=>e.project_id===s.projectId&&e.name===s.name),l=t.checkmark,[i,c]=(0,r.useState)(""),[d,h]=(0,r.useState)(!1),p=(null==a?void 0:a.status)==="paused_for_input",g=async e=>{if(!i.trim())return;h(!0);let s=await t.submitAnswer(i.trim(),e);h(!1),s&&c("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{style:{padding:"24px 28px",borderBottom:"1px solid var(--border-default)",display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)("span",{style:{color:"var(--accent)",fontWeight:"var(--fw-bold)",fontSize:"var(--text-xl)"},children:s.projectId}),(0,n.jsx)("span",{className:"faint",children:"/"}),(0,n.jsx)("span",{style:{color:"var(--text-heading)",fontWeight:"var(--fw-semibold)",fontSize:"var(--text-lg)"},children:s.name}),(0,n.jsx)(v,{status:null==a?void 0:a.status}),(null==a?void 0:a.role)&&(0,n.jsx)(j,{tone:"info",children:a.role}),(0,n.jsx)("span",{className:"spacer"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>t.killAgent(s.projectId,s.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>t.deleteAgent(s.projectId,s.name),children:"Delete row"})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:[null!==(e=null==a?void 0:a.working_dir)&&void 0!==e?e:"—"," \xb7 created ",m(null==a?void 0:a.created_at)]})]}),(0,n.jsxs)("div",{style:{padding:"20px 28px",display:"flex",flexDirection:"column",gap:16},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Checkmark"}),t.checkmarkMissing&&(0,n.jsx)(C,{tone:"info",children:"No checkpoint recorded yet."}),l&&!t.checkmarkMissing&&(0,n.jsxs)("dl",{className:"kv",children:[(0,n.jsx)("dt",{children:"Status"}),(0,n.jsx)("dd",{children:(0,n.jsx)(v,{status:l.status})}),(0,n.jsx)("dt",{children:"Where it stopped"}),(0,n.jsx)("dd",{children:l.where_it_stopped||"—"}),(0,n.jsx)("dt",{children:"Open question"}),(0,n.jsx)("dd",{children:l.open_question||"—"}),(0,n.jsx)("dt",{children:"Next steps"}),(0,n.jsx)("dd",{children:l.next_steps&&l.next_steps.length>0?(0,n.jsx)("ul",{children:l.next_steps.map((e,t)=>(0,n.jsx)("li",{children:e},t))}):"—"}),(0,n.jsx)("dt",{children:"Tests"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(j,{tone:u(l.tests_status),children:l.tests_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.tested_at?m(l.tested_at):""})]}),(0,n.jsx)("dt",{children:"Build"}),(0,n.jsxs)("dd",{className:"hstack",children:[(0,n.jsx)(j,{tone:u(l.build_status),children:l.build_status}),(0,n.jsx)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:l.built_at?m(l.built_at):""})]}),(0,n.jsx)("dt",{children:"Checkpoint at"}),(0,n.jsx)("dd",{className:"faint",children:m(l.checkpoint_at)})]})]}),p&&(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Answer this question"}),(0,n.jsx)(C,{tone:"danger",children:(null==l?void 0:l.open_question)||"(no question text on the checkmark)"}),(0,n.jsx)(k,{value:i,onChange:c,rows:3,placeholder:"Your answer…"}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{variant:"secondary",disabled:d||!i.trim(),onClick:()=>g(!1),children:"Answer"}),(0,n.jsx)(y,{variant:"primary",disabled:d||!i.trim(),onClick:()=>g(!0),children:"Answer & Resume"})]})]}),(0,n.jsxs)("div",{style:{display:"flex",flexDirection:"column",gap:10},children:[(0,n.jsx)("div",{className:"eyebrow",children:"Log \xb7 newest first"}),0===t.log.length?(0,n.jsx)("div",{className:"empty",children:"No log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"Q / A"}),(0,n.jsx)("th",{children:"Push"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:t.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsxs)("td",{children:[e.question&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"Q:"})," ",e.question]}),e.answer&&(0,n.jsxs)("div",{children:[(0,n.jsx)("strong",{children:"A:"})," ",e.answer]}),!e.question&&!e.answer&&"—"]}),(0,n.jsx)("td",{className:"mono",children:x(e.push_sha)}),(0,n.jsx)("td",{children:(0,n.jsx)(j,{tone:u(e.ci_status),children:e.ci_status})})]},e.id))})]})}),(0,n.jsxs)("div",{className:"pager",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:0===t.logOffset,onClick:()=>t.pageLog(-1),children:"‹ Newer"}),(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["offset ",t.logOffset]}),(0,n.jsx)(y,{size:"sm",variant:"ghost",disabled:t.log.length<100,onClick:()=>t.pageLog(1),children:"Older ›"})]})]})]})]})}let E={mode:"server",git_server:"",repo:"",id:"",root_dir:"",git_remote:"",credential_ref:"",init_mise:!1};function P(){let e=o(),[t,s]=(0,r.useState)(E),[a,l]=(0,r.useState)(!1),i=(0,r.useMemo)(()=>{var t;let s=new Map;for(let a of e.agents)s.set(a.project_id,(null!==(t=s.get(a.project_id))&&void 0!==t?t:0)+1);return s},[e.agents]),c=(0,r.useMemo)(()=>[{value:"",label:e.hosts.length?"Pick a git server…":"No git servers configured"},...e.hosts.map(e=>({value:e.hostname,label:"".concat(e.hostname," (").concat(e.forge_type,")")}))],[e.hosts]),d=()=>{s(E),l(!1)},u=async()=>{(a?await e.updateProject(t.id,t):await e.createProject(t))&&d()},h=e=>{var t,a;s({...E,mode:"manual",id:e.id,root_dir:e.root_dir,git_remote:null!==(t=e.git_remote)&&void 0!==t?t:"",credential_ref:null!==(a=e.credential_ref)&&void 0!==a?a:""}),l(!0)},x=a?!!t.root_dir.trim():"server"===t.mode?!!t.git_server&&/^[\w.-]+\/[\w.-]+$/.test(t.repo.trim()):!!t.id.trim()&&!!t.root_dir.trim();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Repositories"}),(0,n.jsx)("div",{className:"section-desc",children:"Repos Handler manages. Each carries its own agents, history, and credentials."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit repository \xb7 ".concat(t.id):"Add a repository"})}),!a&&(0,n.jsx)("div",{style:{marginBottom:14},children:(0,n.jsx)(N,{tabs:[{value:"server",label:"From a git server"},{value:"manual",label:"Manual (existing checkout)"}],value:t.mode,onChange:e=>s({...t,mode:e})})}),a||"server"!==t.mode?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"ID / slug",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"leeworks-api",disabled:a}),(0,n.jsx)(f,{label:"Root dir",value:t.root_dir,onChange:e=>s({...t,root_dir:e}),placeholder:"/var/lib/handler/projects/leeworks"}),(0,n.jsx)(f,{label:"Git remote",value:t.git_remote,onChange:e=>s({...t,git_remote:e}),placeholder:"git@github.com:user/repo.git (optional)"}),(0,n.jsx)(f,{label:"Credential ref",value:t.credential_ref,onChange:e=>s({...t,credential_ref:e}),placeholder:"env:VAR / file:/path / db:host:github.com"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Optional override — projects on a configured git server use its stored token automatically. credential_ref is a pointer, never the token (env: / file: / db:host:)."})]}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(w,{label:"Git server",value:t.git_server,onChange:e=>s({...t,git_server:e}),options:c}),(0,n.jsx)(f,{label:"Repository (owner/name)",value:t.repo,onChange:e=>s({...t,repo:e}),placeholder:"me/coolproj"}),(0,n.jsx)(f,{label:"ID / slug (optional — defaults to the repo name)",value:t.id,onChange:e=>s({...t,id:e}),placeholder:"coolproj"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"The repo is always pulled: Handler derives the remote from the server (ssh when it has a deploy key, https via the stored token otherwise), clones it under PROJECTS_ROOT, and keeps it fresh before every run."})]}),!a&&(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer",marginTop:14},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.init_mise,onChange:e=>s({...t,init_mise:e.target.checked})}),(0,n.jsxs)("span",{style:{fontSize:"var(--text-sm)"},children:["Initialize mise — after the clone, run an agent that writes a"," ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," with a"," ",(0,n.jsx)("span",{className:"mono",children:"[tasks.test]"})," task for this repo’s stack, then commits and pushes it. Needed for repos that don’t define one yet."]})]}),!a&&t.init_mise&&"manual"===t.mode&&!t.git_remote.trim()&&(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"6px 0 0"},children:["A git remote is required to push the new ",(0,n.jsx)("span",{className:"mono",children:".mise.toml"})," — add one above, or mise won’t be initialized."]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!x,onClick:u,children:a?"Save changes":"server"===t.mode?"Add & pull":"Register"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:d,children:"Cancel"})]})]}),0===e.projects.length&&(0,n.jsx)("div",{className:"empty",children:"No repositories registered."}),e.projects.map(t=>{var s,a;return(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"card-title",children:t.id}),(0,n.jsxs)(j,{tone:"info",pill:!0,children:[null!==(s=i.get(t.id))&&void 0!==s?s:0," ",(null!==(a=i.get(t.id))&&void 0!==a?a:0)===1?"agent":"agents"]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:4},children:[t.root_dir,t.git_remote?" \xb7 ".concat(t.git_remote):""]}),(0,n.jsxs)("div",{className:"hstack",style:{marginTop:12,justifyContent:"space-between"},children:[(0,n.jsxs)("span",{className:"faint mono",style:{fontSize:"var(--text-xs)"},children:["cred ",t.credential_ref||"server default"," \xb7 added ",m(t.created_at)]}),(0,n.jsxs)("div",{className:"hstack",children:[t.git_remote&&(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.syncProject(t.id),children:"Pull now"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>h(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteProject(t.id),children:"Remove"})]})]})]},t.id)})]})]})}let M=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],O=[{value:"worktree",label:"git worktree on branch"},{value:"subdir",label:"subdir under root"}],L={name:"",role:"",placement:"worktree",worktree:"",subdir:"",task:""};function U(){let e=o(),[t,s]=(0,r.useState)(L),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=(0,r.useMemo)(()=>e.agents.filter(t=>t.project_id===e.selectedProjectId),[e.agents,e.selectedProjectId]),i=async()=>{await e.spawnAgent(t)&&s(L)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Agents"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn agents into a repository and manage running sessions."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Spawn an agent"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Name",value:t.name,onChange:e=>s({...t,name:e}),placeholder:"junior"}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:M}),(0,n.jsx)(w,{label:"Placement",value:t.placement,onChange:e=>s({...t,placement:e}),options:O}),"worktree"===t.placement?(0,n.jsx)(f,{label:"Branch",value:t.worktree,onChange:e=>s({...t,worktree:e}),placeholder:"feat/auth"}):(0,n.jsx)(f,{label:"Subdir",value:t.subdir,onChange:e=>s({...t,subdir:e}),placeholder:"api"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Initial task",value:t.task,onChange:e=>s({...t,task:e}),rows:2,placeholder:"initial task / prompt (optional)"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name.trim(),onClick:i,children:"Spawn"})})]}),0===l.length?(0,n.jsx)("div",{className:"empty",children:"No agents in this repository."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Role"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Working dir"}),(0,n.jsx)("th",{children:"Created"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:l.map(t=>{var s;return(0,n.jsxs)(r.Fragment,{children:[(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:t.name}),(0,n.jsx)("td",{children:t.role?(0,n.jsx)(j,{tone:"info",children:t.role}):"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:t.status})}),(0,n.jsx)("td",{className:"mono faint",children:t.working_dir}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.created_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(y,{size:"sm",variant:"ghost",onClick:()=>e.selectRun(t.project_id,t.name),children:"Open"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>e.killAgent(t.project_id,t.name),children:"Kill"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteAgent(t.project_id,t.name),children:"Delete"})]})})]}),"working"===t.status&&(null===(s=t.last_output)||void 0===s?void 0:s.trim())&&(0,n.jsx)("tr",{children:(0,n.jsxs)("td",{colSpan:6,style:{paddingTop:0},children:[(0,n.jsxs)("div",{className:"faint",style:{fontSize:"var(--text-xs)",marginBottom:4},children:["live output",t.output_at?" \xb7 ".concat(p(t.output_at)):""]}),(0,n.jsx)("pre",{className:"mono",style:{margin:0,padding:"8px 10px",background:"var(--surface-2, rgba(0,0,0,0.25))",borderRadius:6,fontSize:"var(--text-xs)",lineHeight:1.4,maxHeight:220,overflow:"auto",whiteSpace:"pre"},children:t.last_output})]})})]},t.id)})})]})})]})})]})}let D=[{value:"",label:"Role — none"},{value:"junior",label:"junior"},{value:"senior",label:"senior"},{value:"deploy",label:"deploy"}],F=[{value:"900",label:"every 15 minutes"},{value:"1800",label:"every 30 minutes"},{value:"3600",label:"every hour"},{value:"21600",label:"every 6 hours"},{value:"86400",label:"every day"},{value:"604800",label:"every week"}],B={name_prefix:"",task:"",interval:"3600",role:""};function H(){let e=o(),[t,s]=(0,r.useState)(B),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.createSchedule(e.selectedProjectId,{name_prefix:t.name_prefix,task:t.task,interval_seconds:Number(t.interval),role:t.role})&&s(B)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Schedules"}),(0,n.jsx)("div",{className:"section-desc",children:"Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a file the prompt reads and overwrites."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"New schedule"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Name prefix",value:t.name_prefix,onChange:e=>s({...t,name_prefix:e}),placeholder:"nightly"}),(0,n.jsx)(w,{label:"Interval",value:t.interval,onChange:e=>s({...t,interval:e}),options:F}),(0,n.jsx)(w,{label:"Role",value:t.role,onChange:e=>s({...t,role:e}),options:D})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(k,{label:"Prompt (the task every run starts with)",value:t.task,onChange:e=>s({...t,task:e}),rows:3,placeholder:"Read @notes.md and continue from where it left off. Before finishing, overwrite @notes.md with the current state so the next run can pick up from there."})}),(0,n.jsxs)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:["Runs are named ",(0,n.jsxs)("span",{className:"mono",children:[t.name_prefix.trim()||"prefix","-YYYYMMDD-HHMMSS"]}),". The repo is pulled before every run; the first run fires on the worker's next pass."]}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.name_prefix.trim()||!t.task.trim(),onClick:l,children:"Create schedule"})})]}),0===e.schedules.length?(0,n.jsx)("div",{className:"empty",children:"No schedules yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"On"}),(0,n.jsx)("th",{children:"Name"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Interval"}),(0,n.jsx)("th",{children:"Prompt"}),(0,n.jsx)("th",{children:"Next run"}),(0,n.jsx)("th",{children:"Last run"}),(0,n.jsx)("th",{})]})}),(0,n.jsx)("tbody",{children:e.schedules.map(t=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{children:(0,n.jsx)(S,{on:t.enabled,onClick:()=>e.updateSchedule(t.id,{enabled:!t.enabled})})}),(0,n.jsxs)("td",{className:"mono",children:[t.name_prefix,t.role?(0,n.jsxs)(n.Fragment,{children:[" ",(0,n.jsx)(j,{tone:"info",children:t.role})]}):null]}),(0,n.jsx)("td",{className:"mono faint",children:t.project_id}),(0,n.jsx)("td",{className:"nowrap",children:function(e){let t=F.find(t=>Number(t.value)===e);return t?t.label:e%3600==0?"every ".concat(e/3600,"h"):e%60==0?"every ".concat(e/60,"m"):"every ".concat(e,"s")}(t.interval_seconds)}),(0,n.jsx)("td",{className:"faint",style:{maxWidth:340},children:(0,n.jsx)("span",{className:"truncate",style:{display:"block"},title:t.task,children:t.task})}),(0,n.jsx)("td",{className:"faint nowrap",children:t.enabled?m(t.next_run_at):"paused"}),(0,n.jsx)("td",{className:"faint nowrap",children:m(t.last_run_at)}),(0,n.jsx)("td",{className:"nowrap",children:(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteSchedule(t.id),children:"Delete"})})]},t.id))})]})})]})})]})}let q=[{value:"approved",label:"approve"},{value:"rejected",label:"reject"}],W={branch:"",status:"approved",agent_name:"",sha:"",note:""};function G(){let e=o(),[t,s]=(0,r.useState)(W),a=(0,r.useMemo)(()=>e.projects.map(e=>({value:e.id,label:e.id})),[e.projects]),l=async()=>{await e.submitApproval(t),s(W)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Approvals"}),(0,n.jsx)("div",{className:"section-desc",children:"A merge is denied unless a standing approval exists — made by a different agent, pinned to the reviewed commit."})]}),(0,n.jsx)("div",{className:"section-body",children:0===e.projects.length?(0,n.jsx)("div",{className:"empty",children:"Register a repository first."}):(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"row",children:(0,n.jsx)("div",{style:{width:260},children:(0,n.jsx)(w,{label:"Repository",value:e.selectedProjectId,onChange:e.selectProject,options:a})})}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Record a verdict"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Branch",value:t.branch,onChange:e=>s({...t,branch:e}),placeholder:"feat/auth"}),(0,n.jsx)(w,{label:"Verdict",value:t.status,onChange:e=>s({...t,status:e}),options:q}),(0,n.jsx)(f,{label:"Agent",value:t.agent_name,onChange:e=>s({...t,agent_name:e}),placeholder:"reads its HEAD (optional)"}),(0,n.jsx)(f,{label:"SHA",value:t.sha,onChange:e=>s({...t,sha:e}),placeholder:"pins the approval (optional)"})]}),(0,n.jsx)("div",{className:"mt14",children:(0,n.jsx)(f,{label:"Note",value:t.note,onChange:e=>s({...t,note:e}),placeholder:"optional"})}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.branch.trim(),onClick:l,children:"Enqueue verdict"})})]}),0===e.approvals.length?(0,n.jsx)("div",{className:"empty",children:"No approvals recorded."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Branch"}),(0,n.jsx)("th",{children:"Verdict"}),(0,n.jsx)("th",{children:"By"}),(0,n.jsx)("th",{children:"SHA"}),(0,n.jsx)("th",{children:"Note"})]})}),(0,n.jsx)("tbody",{children:e.approvals.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.branch}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.approved_by_agent_id?"agent ".concat(e.approved_by_agent_id):e.actor||"—"}),(0,n.jsx)("td",{className:"mono",children:x(e.approved_sha)}),(0,n.jsx)("td",{children:e.note||"—"})]},e.id))})]})})]})})]})}let J=[{value:"github",label:"github"},{value:"gitlab",label:"gitlab"},{value:"gitea",label:"gitea"},{value:"forgejo",label:"forgejo"},{value:"bitbucket",label:"bitbucket"}],K={hostname:"",forge_type:"github",token_env_var:"",base_url:"",token:"",generate_ssh_key:!0};function Y(e){let{value:t}=e,[s,a]=(0,r.useState)(!1),l=async()=>{try{await navigator.clipboard.writeText(t),a(!0),setTimeout(()=>a(!1),1500)}catch(e){}};return(0,n.jsxs)("div",{style:{marginTop:10},children:[(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsx)("span",{className:"eyebrow",children:"SSH public key — add it to the forge (deploy key)"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:l,children:s?"Copied":"Copy"})]}),(0,n.jsx)("pre",{className:"mono",style:{fontSize:"var(--text-xs)",whiteSpace:"pre-wrap",wordBreak:"break-all",margin:"6px 0 0",padding:8,border:"1px solid var(--border-default)",borderRadius:6,userSelect:"all"},children:t})]})}function V(){let e=o(),[t,s]=(0,r.useState)(K),[a,l]=(0,r.useState)(!1),i=()=>{s(K),l(!1)},c=async()=>{(a?await e.updateHost(t.hostname,t):await e.createHost(t))&&i()},d=e=>{var t,a;s({hostname:e.hostname,forge_type:e.forge_type,token_env_var:null!==(t=e.token_env_var)&&void 0!==t?t:"",base_url:null!==(a=e.base_url)&&void 0!==a?a:"",token:"",generate_ssh_key:!1}),l(!0)};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Git Servers"}),(0,n.jsxs)("div",{className:"section-desc",children:["Each server carries its own credentials: a forge token (encrypted at rest, used by agents' ",(0,n.jsx)("span",{className:"mono",children:"forge"})," + git) and an SSH deploy key — paste the public key into the forge. New repositories are added by picking a server and typing owner/name."]})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:a?"Edit server \xb7 ".concat(t.hostname):"Add a git server"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Hostname",value:t.hostname,onChange:e=>s({...t,hostname:e}),placeholder:"github.com",disabled:a}),(0,n.jsx)(w,{label:"Type",value:t.forge_type,onChange:e=>s({...t,forge_type:e}),options:J}),(0,n.jsx)(f,{label:a?"Forge token (blank = keep current)":"Forge token",type:"password",value:t.token,onChange:e=>s({...t,token:e}),placeholder:"stored encrypted; used by forge + git"}),(0,n.jsx)(f,{label:"Base URL (optional)",value:t.base_url,onChange:e=>s({...t,base_url:e}),placeholder:"https://git.corp.internal:8443"}),(0,n.jsx)(f,{label:"Token env var override (optional)",value:t.token_env_var,onChange:e=>s({...t,token_env_var:e}),placeholder:"GITEA_TOKEN"}),(0,n.jsxs)("label",{className:"field",children:[(0,n.jsx)("span",{className:"field-label",children:"SSH deploy key"}),(0,n.jsxs)("label",{className:"hstack",style:{gap:8,cursor:"pointer"},children:[(0,n.jsx)("input",{type:"checkbox",checked:t.generate_ssh_key,onChange:e=>s({...t,generate_ssh_key:e.target.checked})}),(0,n.jsx)("span",{style:{fontSize:"var(--text-sm)"},children:a?"Regenerate keypair (replaces the current key)":"Generate a keypair"})]})]})]}),(0,n.jsxs)("div",{className:"hstack mt14",children:[(0,n.jsx)(y,{variant:"primary",disabled:e.cmd.busy||!t.hostname.trim(),onClick:c,children:a?"Save changes":"Add server"}),a&&(0,n.jsx)(y,{variant:"ghost",onClick:i,children:"Cancel"})]})]}),0===e.hosts.length&&(0,n.jsx)("div",{className:"empty",children:"No git servers registered (built-in host map still applies)."}),e.hosts.map(t=>(0,n.jsxs)(g,{children:[(0,n.jsxs)("div",{className:"card-head",children:[(0,n.jsx)("span",{className:"mono",style:{fontWeight:"var(--fw-bold)",fontSize:"var(--text-lg)",color:"var(--text-heading)"},children:t.hostname}),(0,n.jsxs)("div",{className:"hstack",children:[(0,n.jsx)(j,{tone:"info",children:t.forge_type}),(0,n.jsx)(j,{tone:t.has_token?"success":"neutral",children:t.has_token?"token stored":"no token"}),(0,n.jsx)(j,{tone:t.ssh_public_key?"success":"neutral",children:t.ssh_public_key?"ssh key":"no ssh key"}),(0,n.jsx)(y,{size:"sm",variant:"secondary",onClick:()=>d(t),children:"Edit"}),(0,n.jsx)(y,{size:"sm",variant:"danger",onClick:()=>e.deleteHost(t.hostname),children:"Remove"})]})]}),(0,n.jsxs)("div",{className:"mono faint",style:{fontSize:"var(--text-xs)",marginTop:8},children:["token env ",t.token_env_var||"—",t.base_url?" \xb7 ".concat(t.base_url):""]}),t.ssh_public_key&&(0,n.jsx)(Y,{value:t.ssh_public_key})]},t.hostname))]})]})}function Q(){let e=o();return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)("div",{className:"section-head",children:(0,n.jsxs)("div",{className:"hstack",style:{justifyContent:"space-between"},children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"section-title",children:"Activity"}),(0,n.jsx)("div",{className:"section-desc",children:"Control commands the worker drains from the queue."})]}),(0,n.jsx)(y,{variant:"secondary",disabled:e.cmd.busy,onClick:()=>e.pollCi(),children:"Sweep CI now"})]})}),(0,n.jsx)("div",{className:"section-body",children:0===e.commands.length?(0,n.jsx)("div",{className:"empty",children:"No commands yet."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Type"}),(0,n.jsx)("th",{children:"Repository"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Result / Error"})]})}),(0,n.jsx)("tbody",{children:e.commands.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.type}),(0,n.jsx)("td",{className:"mono",children:e.project_id||"—"}),(0,n.jsx)("td",{className:"mono",children:e.agent_name||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{className:"mono faint",style:{fontSize:"var(--text-xs)"},children:e.error||(e.result?JSON.stringify(e.result):"—")})]},e.id))})]})})})]})}function X(){let e=o(),[t,s]=(0,r.useState)(""),[a,l]=(0,r.useState)(""),i=async()=>{t.trim()&&a.trim()&&await e.setSharedKey(t.trim(),a.trim())&&(s(""),l(""))};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Shared"}),(0,n.jsx)("div",{className:"section-desc",children:"The cross-project global feed and shared facts."})]}),(0,n.jsxs)("div",{className:"section-body",children:[(0,n.jsxs)("div",{children:[(0,n.jsx)("div",{className:"eyebrow",style:{marginBottom:10},children:"Global feed"}),0===e.shared.log.length?(0,n.jsx)("div",{className:"empty",children:"No global log entries."}):(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"When"}),(0,n.jsx)("th",{children:"Agent"}),(0,n.jsx)("th",{children:"Status"}),(0,n.jsx)("th",{children:"Summary"}),(0,n.jsx)("th",{children:"CI"})]})}),(0,n.jsx)("tbody",{children:e.shared.log.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"faint nowrap",children:m(e.created_at)}),(0,n.jsx)("td",{className:"mono",children:e.agent_id}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.status})}),(0,n.jsx)("td",{children:e.summary||"—"}),(0,n.jsx)("td",{children:(0,n.jsx)(v,{status:e.ci_status})})]},e.id))})]})})]}),(0,n.jsxs)(g,{children:[(0,n.jsx)("div",{className:"card-head",style:{marginBottom:14},children:(0,n.jsx)("span",{className:"card-title",style:{fontSize:"var(--text-md)",color:"var(--text-heading)"},children:"Set a shared key"})}),(0,n.jsxs)("div",{className:"form-grid",children:[(0,n.jsx)(f,{label:"Key",value:t,onChange:s,placeholder:"key"}),(0,n.jsx)(f,{label:"Value",value:a,onChange:l,placeholder:"value"})]}),(0,n.jsx)("p",{className:"faint",style:{fontSize:"var(--text-xs)",margin:"10px 0 0"},children:"Requires the shared-context write token (or admin/global if unset)."}),(0,n.jsx)("div",{className:"hstack mt14",children:(0,n.jsx)(y,{variant:"primary",disabled:!t.trim()||!a.trim(),onClick:i,children:"Set"})})]}),e.shared.context.length>0&&(0,n.jsx)("div",{className:"table-wrap",children:(0,n.jsxs)("table",{className:"tbl",children:[(0,n.jsx)("thead",{children:(0,n.jsxs)("tr",{children:[(0,n.jsx)("th",{children:"Key"}),(0,n.jsx)("th",{children:"Value"}),(0,n.jsx)("th",{children:"Updated"})]})}),(0,n.jsx)("tbody",{children:e.shared.context.map(e=>(0,n.jsxs)("tr",{children:[(0,n.jsx)("td",{className:"mono",children:e.key}),(0,n.jsx)("td",{children:e.value}),(0,n.jsx)("td",{className:"faint nowrap",children:m(e.updated_at)})]},e.key))})]})})]})]})}function $(e){let t=window.screenX+Math.max(0,(window.outerWidth-520)/2),s=window.screenY+Math.max(0,(window.outerHeight-760)/2);return window.open(e,"claude-login","popup=yes,width=".concat(520,",height=").concat(760,",left=").concat(Math.round(t),",top=").concat(Math.round(s)))}function Z(){let e=o(),{status:t,url:s,message:a}=e.claudeLogin,[l,i]=(0,r.useState)(""),c=(0,r.useRef)(null),d="starting"===t||"submitting"===t,u="awaiting"===t||"submitting"===t;(0,r.useEffect)(()=>{if("awaiting"===t&&s&&c.current&&!c.current.closed)try{c.current.location.href=s}catch(e){}if("done"===t||"error"===t){var e;null===(e=c.current)||void 0===e||e.close(),c.current=null}},[t,s]);let h=()=>{c.current=$("about:blank"),e.startClaudeLogin()},m=async()=>{await e.submitClaudeCode(l)&&i("")};return(0,n.jsxs)(n.Fragment,{children:[(0,n.jsxs)("div",{className:"section-head",children:[(0,n.jsx)("div",{className:"section-title",children:"Claude Login"}),(0,n.jsxs)("div",{className:"section-desc",children:["Log Claude Code in on the host so agents can run. This drives"," ",(0,n.jsx)("span",{className:"mono",children:"claude /login"})," in the control container and picks the Claude account with a subscription."]})]}),(0,n.jsxs)("div",{className:"section-body",style:{display:"flex",flexDirection:"column",gap:16},children:[a&&(0,n.jsx)(C,{tone:"error"===t?"danger":"done"===t?"success":"info",children:a}),"done"===t?(0,n.jsx)("div",{children:(0,n.jsx)(y,{variant:"secondary",onClick:e.resetClaudeLogin,children:"Log in again"})}):u?(0,n.jsxs)(n.Fragment,{children:[(0,n.jsx)(C,{tone:"info",children:"A Claude sign-in window should have opened. Authorize there, copy the code Claude shows you, and paste it below. If the window didn't open (popups blocked), use the button."}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,flexWrap:"wrap"},children:[(0,n.jsx)(y,{variant:"secondary",disabled:!s,onClick:()=>{s&&(c.current=$(s))},children:"Open Claude sign-in window ↗"}),s&&(0,n.jsx)("a",{className:"btn btn-ghost",href:s,target:"_blank",rel:"noopener noreferrer",children:"Open in a new tab"}),(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Restart"})]}),(0,n.jsxs)("div",{className:"hstack",style:{gap:10,alignItems:"flex-end",flexWrap:"wrap"},children:[(0,n.jsx)("div",{style:{flex:"1 1 320px"},children:(0,n.jsx)(f,{label:"Authorization code",value:l,onChange:i,placeholder:"Paste the code from claude.com",disabled:"submitting"===t})}),(0,n.jsx)(y,{variant:"primary",disabled:"submitting"===t||!l.trim(),onClick:m,children:"submitting"===t?"Submitting…":"Finish login"})]})]}):(0,n.jsxs)("div",{className:"hstack",style:{gap:10},children:[(0,n.jsx)(y,{variant:"primary",disabled:d,onClick:h,children:"starting"===t?"Starting…":"Log in to Claude"}),"error"===t&&(0,n.jsx)(y,{variant:"ghost",disabled:d,onClick:h,children:"Retry"})]})]})]})}let ee=[{key:"runs",label:"Runs",count:e=>e.agents.length,accent:e=>e.agents.some(e=>"paused_for_input"===e.status)},{key:"repositories",label:"Repositories",count:e=>e.projects.length},{key:"agents",label:"Agents",count:e=>e.agents.length},{key:"schedules",label:"Schedules",count:e=>e.schedules.length},{key:"approvals",label:"Approvals",count:e=>e.approvals.length},{key:"servers",label:"Git Servers",count:e=>e.hosts.length},{key:"activity",label:"Activity",count:e=>e.commands.length},{key:"shared",label:"Shared",count:e=>e.shared.context.length},{key:"login",label:"Claude Login",count:()=>0,accent:e=>"done"!==e.claudeLogin.status}];function et(e){let{onSignOut:t}=e,s=o();return(0,n.jsxs)("div",{className:"app",children:[(0,n.jsxs)("aside",{className:"sidebar",children:[(0,n.jsxs)("div",{className:"brand",children:[(0,n.jsx)("span",{className:"logo"}),"Claude Monitor"]}),ee.map(e=>{var t,a;let r=e.count(s),l=null!==(a=null===(t=e.accent)||void 0===t?void 0:t.call(e,s))&&void 0!==a&&a;return(0,n.jsxs)("button",{className:"nav-item".concat(s.section===e.key?" active":""),onClick:()=>s.setSection(e.key),children:[(0,n.jsx)("span",{children:e.label}),(0,n.jsx)("span",{className:"count",style:l?{color:"var(--lw-warning-fg)"}:void 0,children:r||""})]},e.key)}),(0,n.jsx)("div",{className:"sidebar-spacer"}),(0,n.jsxs)("div",{className:"sidebar-foot",children:[(0,n.jsxs)("button",{className:"nav-item",onClick:s.refresh,title:"Refresh now",children:[(0,n.jsx)("span",{children:"Refresh"}),(0,n.jsx)("span",{className:"count",children:"↻"})]}),(0,n.jsx)("button",{className:"nav-item",onClick:t,title:"Sign out / change token",children:(0,n.jsx)("span",{children:"Sign out"})})]})]}),(0,n.jsxs)("main",{className:"main",children:[s.cmd.text&&(0,n.jsx)("p",{className:"banner ".concat(s.cmd.error?"err":"ok"),style:{marginTop:16},children:s.cmd.text}),s.lastError&&(0,n.jsx)("p",{className:"banner err",style:{marginTop:12},children:s.lastError}),"runs"===s.section?(0,n.jsx)(I,{}):(0,n.jsxs)("div",{className:"main-scroll",children:["repositories"===s.section&&(0,n.jsx)(P,{}),"agents"===s.section&&(0,n.jsx)(U,{}),"schedules"===s.section&&(0,n.jsx)(H,{}),"approvals"===s.section&&(0,n.jsx)(G,{}),"servers"===s.section&&(0,n.jsx)(V,{}),"activity"===s.section&&(0,n.jsx)(Q,{}),"shared"===s.section&&(0,n.jsx)(X,{}),"login"===s.section&&(0,n.jsx)(Z,{})]})]})]})}function es(e){let{error:t,onSubmit:s}=e,[a,l]=(0,r.useState)("");return(0,n.jsx)("div",{className:"gate",children:(0,n.jsxs)("form",{className:"gate-card",onSubmit:e=>{e.preventDefault();let t=a.trim();t&&s(t)},children:[(0,n.jsxs)("div",{className:"gate-brand",children:[(0,n.jsx)("span",{className:"logo",style:{width:26,height:26,borderRadius:7}}),"Claude Monitor"]}),(0,n.jsx)("p",{className:"muted",style:{fontSize:"var(--text-sm)",margin:0},children:"Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token."}),(0,n.jsx)("input",{className:"input",type:"password",autoComplete:"current-password",placeholder:"API token",value:a,onChange:e=>l(e.target.value),autoFocus:!0}),t&&(0,n.jsx)("p",{className:"callout callout-danger",style:{margin:0},children:t}),(0,n.jsx)("button",{className:"btn btn-primary",type:"submit",children:"Continue"})]})})}let ea="handler_token";function en(){let[e,t]=(0,r.useState)(null),[s,a]=(0,r.useState)("");(0,r.useEffect)(()=>{let e=window.localStorage.getItem(ea);e&&t(e)},[]);let l=(0,r.useCallback)(e=>{window.localStorage.setItem(ea,e),a(""),t(e)},[]),i=(0,r.useCallback)(()=>{window.localStorage.removeItem(ea),t(null)},[]),c=(0,r.useCallback)(()=>{window.localStorage.removeItem(ea),t(null),a("Invalid token — please try again.")},[]);return e?(0,n.jsx)(d,{token:e,onUnauthorized:c,children:(0,n.jsx)(et,{onSignOut:i})}):(0,n.jsx)(es,{error:s,onSubmit:l})}},257:function(e,t,s){"use strict";var a,n;e.exports=(null==(a=s.g.process)?void 0:a.env)&&"object"==typeof(null==(n=s.g.process)?void 0:n.env)?s.g.process:s(4227)},4227:function(e){!function(){var t={229:function(e){var t,s,a,n=e.exports={};function r(){throw Error("setTimeout has not been defined")}function l(){throw Error("clearTimeout has not been defined")}function i(e){if(t===setTimeout)return setTimeout(e,0);if((t===r||!t)&&setTimeout)return t=setTimeout,setTimeout(e,0);try{return t(e,0)}catch(s){try{return t.call(null,e,0)}catch(s){return t.call(this,e,0)}}}!function(){try{t="function"==typeof setTimeout?setTimeout:r}catch(e){t=r}try{s="function"==typeof clearTimeout?clearTimeout:l}catch(e){s=l}}();var c=[],o=!1,d=-1;function u(){o&&a&&(o=!1,a.length?c=a.concat(c):d=-1,c.length&&h())}function h(){if(!o){var e=i(u);o=!0;for(var t=c.length;t;){for(a=c,c=[];++d1)for(var s=1;sHandler · Claude Activity
Claude Monitor

Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.

\ No newline at end of file +Handler · Claude Activity
Claude Monitor

Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.

\ No newline at end of file diff --git a/src/handler/api/static/index.txt b/src/handler/api/static/index.txt index 22a12d7..847ed74 100644 --- a/src/handler/api/static/index.txt +++ b/src/handler/api/static/index.txt @@ -1,7 +1,7 @@ 2:I[9107,[],"ClientPageRoot"] -3:I[9859,["931","static/chunks/app/page-a511d608db773c67.js"],"default",1] +3:I[9859,["931","static/chunks/app/page-0637b8cf149a88ec.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["tQnVLKX-Dr8H3frHzPrCa",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] +0:["ga21jhjKhYsutf8F3vo_-",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] 6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]] 1:null diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index aeff4e2..36c2b36 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -298,6 +298,63 @@ def _run_one(command: dict) -> None: repo.finish_command(conn, command["id"], "failed", error=str(exc)) +def heartbeat(worker_id: str) -> None: + """Refresh this worker's registry row — the reaper's proof-of-life.""" + with connection() as conn: + active = len(repo.list_running_runs(conn, worker_id=worker_id)) + repo.upsert_worker_heartbeat( + conn, + worker_id, + hostname=socket.gethostname(), + pid=os.getpid(), + max_runs=get_settings().max_concurrent_runs, + active_runs=active, + ) + + +def reap_dead_workers(now: datetime | None = None) -> int: + """Settle the record for workers that stopped heartbeating: their ``running`` runs + become ``crashed``, and agents those runs left in ``working`` become ``crashed`` too. + + Any surviving worker may reap any dead one — ``finish_run``'s still-running guard + makes concurrent reapers idempotent. Deliberately NO auto-requeue: a half-done run + may have already pushed commits, so re-running it isn't safe to assume; the operator + sees the crashed badge (with the frozen last_output as evidence) and resumes + explicitly. Agents in ``paused_for_input``/``blocked`` keep their status — those are + still accurate descriptions of what the agent needs. Returns runs reaped. + """ + now = now or datetime.now(UTC) + cutoff = now - timedelta(seconds=get_settings().worker_stale_after) + reaped = 0 + with connection() as conn: + stale = repo.list_stale_workers(conn, cutoff) + for dead in stale: + with connection() as conn: + for run in repo.list_running_runs(conn, worker_id=dead["id"]): + if not repo.finish_run(conn, run["id"], "crashed"): + continue # another reaper won the race + agent = repo.get_agent_by_id(conn, run["agent_id"]) + if agent is not None and agent["status"] == "working": + repo.set_agent_status(conn, agent["id"], "crashed") + repo.insert_agent_event( + conn, + run["agent_id"], + run["id"], + seq=0, # runner-generated, outside the supervisor's line counter + type="worker", + payload={ + "notice": "worker died mid-run; run marked crashed", + "worker_id": dead["id"], + }, + session_id=run["session_id"], + ) + reaped += 1 + # Row served its purpose; dropping it keeps the registry to live workers + # (and stops every future pass re-scanning long-dead ids). + repo.delete_worker(conn, dead["id"]) + return reaped + + def fire_due_schedules(now: datetime | None = None) -> int: """Enqueue a spawn command for every schedule whose ``next_run_at`` has passed. @@ -419,10 +476,11 @@ def run( ci_interval: float = 30.0, capture_interval: float = 2.0, credsync_interval: float = 30.0, + reap_interval: float = 15.0, iterations: int | None = None, ) -> None: """The control-container main loop: drain the command queue, snapshot live agent - output, sync claude credentials, and sweep CI periodically. + output, sync claude credentials, heartbeat + reap dead workers, and sweep CI. ``iterations`` bounds the loop for tests; production runs unbounded. Sleeps ``poll_interval`` only when a pass found no commands, so bursts drain promptly. @@ -431,8 +489,19 @@ def run( last_ci = 0.0 last_capture = 0.0 last_credsync = 0.0 + last_reap = 0.0 count = 0 while iterations is None or count < iterations: + try: + heartbeat(worker_id) + except Exception: # noqa: BLE001 - a heartbeat hiccup must not kill the worker + pass + if reap_interval > 0 and time.monotonic() - last_reap >= reap_interval: + try: + reap_dead_workers() + except Exception: # noqa: BLE001 - a reap hiccup must not kill the worker + pass + last_reap = time.monotonic() try: fire_due_schedules() except Exception: # noqa: BLE001 - a schedule hiccup must not kill the worker diff --git a/src/handler/db/repository.py b/src/handler/db/repository.py index 3684c27..0f8e0aa 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -727,6 +727,12 @@ def list_stale_workers(conn: Connection, cutoff: datetime) -> list[dict]: return [dict(r._mapping) for r in rows] +def delete_worker(conn: Connection, worker_id: str) -> bool: + """Drop a (dead) worker's registry row after its runs have been settled.""" + result = conn.execute(workers.delete().where(workers.c.id == worker_id)) + return result.rowcount > 0 + + def create_run( conn: Connection, agent_id: int, session_id: str, worker_id: str, kind: str ) -> dict: diff --git a/tests/test_reaper.py b/tests/test_reaper.py new file mode 100644 index 0000000..db594f8 --- /dev/null +++ b/tests/test_reaper.py @@ -0,0 +1,116 @@ +"""Worker heartbeat + reaper: a worker that stops heartbeating gets its running runs +(and their still-'working' agents) marked crashed by any surviving worker — the positive +liveness that replaces tmux scraping. No auto-requeue: half-done runs may have pushed.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from handler.control import worker +from handler.db import repository as repo +from handler.db.engine import get_engine + + +def _seed_run(worker_id, agent_name="a", agent_status="working"): + with get_engine().begin() as conn: + if repo.get_project(conn, "p") is None: + repo.create_project(conn, "p", "/tmp/p") + agent = repo.create_agent(conn, "p", agent_name, f"/tmp/p/{agent_name}", + status=agent_status) + run = repo.create_run(conn, agent["id"], f"sid-{agent_name}", worker_id, "spawn") + return agent, run + + +def _hb(worker_id, age_seconds=0.0): + with get_engine().begin() as conn: + repo.upsert_worker_heartbeat(conn, worker_id, hostname="h", pid=1) + if age_seconds: + from handler.db.tables import workers as workers_table + + conn.execute( + workers_table.update() + .where(workers_table.c.id == worker_id) + .values(heartbeat_at=datetime.now(UTC) - timedelta(seconds=age_seconds)) + ) + + +def test_reaper_marks_stale_workers_runs_crashed(env): + agent, run = _seed_run("w-dead") + _hb("w-dead", age_seconds=120) # stale: default worker_stale_after is 60s + + assert worker.reap_dead_workers() == 1 + + with get_engine().begin() as conn: + assert repo.get_run(conn, run["id"])["status"] == "crashed" + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "crashed" + events = repo.list_agent_events(conn, agent["id"]) + # The dead worker's registry row is dropped once settled. + assert repo.list_stale_workers(conn, datetime.now(UTC) + timedelta(days=1)) == [] + assert events[0]["type"] == "worker" + assert events[0]["payload"]["worker_id"] == "w-dead" + + +def test_reaper_leaves_fresh_workers_alone(env): + agent, run = _seed_run("w-live") + _hb("w-live") # fresh heartbeat + + assert worker.reap_dead_workers() == 0 + with get_engine().begin() as conn: + assert repo.get_run(conn, run["id"])["status"] == "running" + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "working" + + +def test_reaper_preserves_paused_agent_status(env): + """paused_for_input still accurately describes what the agent needs — only agents + stuck in 'working' get flipped to crashed.""" + agent, run = _seed_run("w-dead2", agent_name="paused", agent_status="paused_for_input") + _hb("w-dead2", age_seconds=120) + + assert worker.reap_dead_workers() == 1 + with get_engine().begin() as conn: + assert repo.get_run(conn, run["id"])["status"] == "crashed" + assert repo.get_agent_by_id(conn, agent["id"])["status"] == "paused_for_input" + + +def test_reaper_idempotent_against_races(env): + agent, run = _seed_run("w-dead3", agent_name="raced") + _hb("w-dead3", age_seconds=120) + assert worker.reap_dead_workers() == 1 + # A second reaper (or the same one next pass) finds nothing left to settle. + assert worker.reap_dead_workers() == 0 + with get_engine().begin() as conn: + assert len(repo.list_agent_events(conn, agent["id"])) == 1 + + +def test_heartbeat_registers_worker(env): + worker.heartbeat("w-me") + with get_engine().begin() as conn: + rows = repo.list_stale_workers(conn, datetime.now(UTC) + timedelta(seconds=1)) + assert [w["id"] for w in rows] == ["w-me"] + assert rows[0]["hostname"] + assert rows[0]["active_runs"] == 0 + + +def test_events_route_serves_stream(env, client, auth): + agent, run = _seed_run("w-api", agent_name="api-agent") + with get_engine().begin() as conn: + for seq in range(1, 4): + repo.insert_agent_event( + conn, agent["id"], run["id"], seq=seq, type="assistant", + payload={"n": seq}, session_id=run["session_id"], + ) + + resp = client.get("/projects/p/agents/api-agent/events", headers=auth) + assert resp.status_code == 200 + events = resp.json() + assert [e["seq"] for e in events] == [1, 2, 3] + assert events[0]["type"] == "assistant" + + # Cursor paging: only events after the given id come back. + resp = client.get( + f"/projects/p/agents/api-agent/events?after_id={events[1]['id']}", headers=auth + ) + assert [e["seq"] for e in resp.json()] == [3] + + resp = client.get("/projects/p/agents/missing/events", headers=auth) + assert resp.status_code == 404 From 1517e4dca81d84486ca8915d2f00c4987f6207ad Mon Sep 17 00:00:00 2001 From: Wyatt Date: Tue, 21 Jul 2026 23:20:39 -0400 Subject: [PATCH 4/4] feat!: headless is the only runner - delete the tmux run path (phase 4) Agent runs are now always worker-owned 'claude -p' subprocesses; tmux survives only for the interactive /login flow. - deleted: worker.capture_agent_output/_pane_tail + the capture loop arm (the empty-/log bug's home), spawn's tmux launch/_claude_command, the tmux resume/kill branches (the silent-send-keys bug's home), tmux.session_name/list_sessions, the CLI attach subcommand, the 'runner' setting - spawn: task is now a hard requirement (headless has no idle REPL) - enforced in spawn (SpawnError) and the API (400); onboarding seeding dropped (-p skips the trust dialog) - resume: single headless path; pre-headless agent rows (no session_id) degrade to the context-re-injection fresh run - settings_gen: permissions allowlist is always emitted - credsync: change-triggered uploads key on .claude/.credentials.json only (claude touches ~/.claude.json every run - keying on it would ping-pong uploads between workers); logins still publish explicitly - cli list: liveness from agent_runs in the DB, not tmux - tests: spawn/kill/resume re-pointed at the fake_launch seam (conftest); integration test now drives API -> worker -> real fake claude subprocess -> events endpoint; README documents the headless model + multi-worker deployment invariants Suite 295 green; frontend unchanged since phase 3. --- README.md | 51 ++++++++++--- src/handler/api/routes/agents.py | 9 +-- src/handler/config.py | 7 +- src/handler/control/cli.py | 37 +++------ src/handler/control/credsync.py | 29 ++++--- src/handler/control/settings_gen.py | 25 +++--- src/handler/control/spawn.py | 113 +++++++++------------------- src/handler/control/tmux.py | 27 +------ src/handler/control/worker.py | 74 ++++-------------- tests/conftest.py | 37 +++++++-- tests/test_control_spawn.py | 102 ++++++++++++++++++------- tests/test_control_spawn_forge.py | 28 +++---- tests/test_credsync.py | 7 -- tests/test_headless_run.py | 8 +- tests/test_integration_web_spawn.py | 93 +++++++++++++++++++---- tests/test_worker.py | 39 ---------- tests/test_worker_concurrency.py | 5 -- 17 files changed, 337 insertions(+), 354 deletions(-) diff --git a/README.md b/README.md index 51d3321..6ef589d 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ git remote, and your own network exposure. > control layer, HTTP API, database, migrations, and verification/approval hooks are > implemented and tested (106 tests, SQLite). Phase 2 adds credential resolution + > injection, role-based forge-workflow skills, a hard approval gate, and a CI-status -> poller. Live end-to-end agent spawning against a real `claude` binary + tmux is stubbed -> behind mockable seams (`tmux`, `verify`, `forge`, `gitops`, `spawn.resume`) and wired -> but not yet exercised against production binaries. See [`docs/PLAN.md`](docs/PLAN.md) -> for the full design and roadmap. +> poller. Agent runs are headless (`claude -p --output-format stream-json`, supervised +> by the worker, events persisted to the DB); the run/kill/resume paths are exercised +> end-to-end against a scripted fake claude binary, with a manual validation script +> (`scripts/validate_claude_headless.sh`) for the real one. See +> [`docs/PLAN.md`](docs/PLAN.md) for the full design and roadmap. --- @@ -45,19 +46,23 @@ the control layer and API are disposable compute that can restart or scale out f ``` writes reads (+ answer backfill) ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ - │ control layer │───────▶│ database │◀───────│ HTTP API │ + │ worker(s) │───────▶│ database │◀───────│ HTTP API │ │ (CLI + hooks) │ │ PG / SQLite │ │ (FastAPI) │ └──────────────────┘ └──────────────┘ └──────────────────┘ │ ▲ ▲ │ spawns │ Stop / PreToolUse / Notification hooks │ curl, UI, any client - ▼ │ write checkmark + log rows │ (bearer token) - tmux + claude binary (one working dir / worktree per agent) + ▼ │ + streamed run events, checkmark, log │ (bearer token) + claude -p --output-format stream-json (one working dir / worktree per agent) ``` -- **Control layer** (`handler.control`) — the only writer. Spawns/lists/attaches/kills - agents as `tmux` sessions running the `claude` binary, one working directory or git - worktree per agent, namespaced `project__agent`. Stateless; every write goes straight - to the database. +- **Control layer / workers** (`handler.control`) — the only writer. Runs each agent as + a **headless** `claude -p --output-format stream-json` subprocess (one working + directory or git worktree per agent), streams every stdout event into the database as + it happens, and reconciles agent status from the process itself (exit code + EOF — + positive liveness, no screen scraping). Stateless: repo state is pulled from git when + a task is claimed, claude session transcripts are archived to / materialized from the + DB for cross-worker `--resume`, and the claude login credential bundle is distributed + encrypted through the DB. tmux survives only to drive the interactive `/login` flow. - **Hooks** (`handler.hooks`) — run inside each agent via a generated `settings.json`. They write the checkpoint/log rows and enforce the test and push gates. - **API** (`handler.api`) — a thin, read-mostly HTTP layer over the same database (the @@ -77,10 +82,32 @@ The data model is defined once (SQLAlchemy Core) and renders correctly on both: Portable column types bridge the two, and the checkmark upsert uses native `INSERT … ON CONFLICT DO UPDATE` on both dialects. Migrations are Alembic, dual-dialect. +### Scaling workers horizontally + +Multiple worker containers can drain the same command queue concurrently (Postgres +`FOR UPDATE SKIP LOCKED`); each supervises up to `MAX_CONCURRENT_RUNS` claude processes +and skips claiming run-starting commands while full, leaving them for a less-loaded +worker. Workers heartbeat into the DB; if one dies mid-run, any surviving worker's +reaper marks its runs (and their agents) `crashed` — visible in the UI with the last +output preserved — and the operator resumes explicitly on whichever worker picks it up. + +Deployment invariants for multi-worker: + +- **No shared filesystems.** Git carries repo state (workers clone/pull on claim); + claude session transcripts live in `session_archives`; login credentials are + Fernet-encrypted into `runtime_secrets` and materialized by every worker. +- **Identical `PROJECTS_ROOT` on every worker** — claude keys its session storage to + the absolute working-dir path, so cross-worker `--resume` needs the same layout. +- **The same `HANDLER_SECRET_KEY` on every worker** (and the API) — without it, the + credential bundle can't be distributed and only the worker that ran `/login` can run + agents. +- The two-step web login is automatically pinned to one worker + (`commands.target_worker`), so it works unchanged with a fleet. + ## Requirements - Python 3.11+ -- `git` and `tmux` (for live spawning) +- `git` (for live spawning) and `tmux` (only for the web `/login` flow) - A `claude` binary, authenticated (for live spawning) - `mise` in each managed project, with a `.mise.toml` defining at least a `test` task - Postgres (default) — or nothing but a file path for the SQLite fallback diff --git a/src/handler/api/routes/agents.py b/src/handler/api/routes/agents.py index 80b9683..0a0a46e 100644 --- a/src/handler/api/routes/agents.py +++ b/src/handler/api/routes/agents.py @@ -12,7 +12,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import Connection from sqlalchemy.exc import IntegrityError -from ...config import get_settings from ...db import repository as repo from ..deps import db_conn, require_admin, require_auth from ..schemas import ( @@ -79,10 +78,10 @@ def enqueue_spawn(project: str, body: SpawnIn, conn: Connection = Depends(db_con status.HTTP_409_CONFLICT, detail=f"agent '{body.name}' already exists in project '{project}'", ) - if get_settings().runner == "headless" and not body.task: - # A tmux agent can idle at the REPL awaiting input; a headless `claude -p` run - # with no prompt exits immediately. Reject here (400) instead of letting the - # command fail asynchronously in the worker. + if not body.task: + # A headless `claude -p` run with no prompt exits immediately having done + # nothing. Reject here (400) instead of letting the command fail asynchronously + # in the worker. raise HTTPException( status.HTTP_400_BAD_REQUEST, detail="a task is required: the headless runner has no idle-REPL mode", diff --git a/src/handler/config.py b/src/handler/config.py index bb618a7..1684dc4 100644 --- a/src/handler/config.py +++ b/src/handler/config.py @@ -51,10 +51,9 @@ class Settings(BaseSettings): forge_bin: str = "forge" git_bin: str = "git" - # ---- Headless runner (claude -p --output-format stream-json). ``runner`` selects the - # launch path: "tmux" (legacy interactive session, the default until the headless path - # is validated) or "headless" (worker-owned subprocess streaming events to the DB). - runner: str = "tmux" + # ---- Headless runner (claude -p --output-format stream-json): worker-owned + # subprocesses streaming events to the DB. Agent runs are always headless; tmux + # remains only for the interactive /login flow. # How many concurrent claude runs one worker container supervises; commands that would # start a run are left queued (for another worker) while all slots are busy. max_concurrent_runs: int = 4 diff --git a/src/handler/control/cli.py b/src/handler/control/cli.py index be9d20a..2c7285c 100644 --- a/src/handler/control/cli.py +++ b/src/handler/control/cli.py @@ -1,10 +1,11 @@ """``handler`` CLI — the control layer's write side. -Spawn/list/attach/kill manage agent processes. Phase 2 adds the forge-workflow control -commands: ``approve``/``reject`` (the senior agent records its verdict, which the deploy -gate checks), ``poll-ci`` (backfill CI verdicts), and ``forge-init`` (write the role -skills into a managed repo). The DB is the source of truth for what agents exist; tmux is -cross-checked for liveness. All commands are project-namespaced. +Spawn/list/kill manage agent runs (headless ``claude -p`` processes supervised by the +worker). Phase 2 adds the forge-workflow control commands: ``approve``/``reject`` (the +senior agent records its verdict, which the deploy gate checks), ``poll-ci`` (backfill +CI verdicts), and ``forge-init`` (write the role skills into a managed repo). The DB is +the single source of truth: what agents exist AND whether their runs are live both come +from it. All commands are project-namespaced. """ from __future__ import annotations @@ -15,7 +16,7 @@ import sys from ..db import repository as repo from ..db.engine import connection -from . import poller, reposync, skills_gen, spawn, tmux, worker +from . import poller, reposync, skills_gen, spawn, worker def _cmd_spawn(args: argparse.Namespace) -> int: @@ -37,38 +38,27 @@ def _cmd_spawn(args: argparse.Namespace) -> int: print(f" role: {args.role}") if agent.get("forge_note"): print(f" warning: {agent['forge_note']}", file=sys.stderr) - print(f" tmux session: {tmux.session_name(args.project, args.name)}") return 0 def _cmd_list(args: argparse.Namespace) -> int: - live = set(tmux.list_sessions()) with connection() as conn: + live = {run["agent_id"] for run in repo.list_running_runs(conn)} if args.project: projects = [args.project] if repo.get_project(conn, args.project) else [] else: projects = [p["id"] for p in repo.list_projects(conn)] for project_id in projects: for agent in repo.list_agents(conn, project_id): - session = tmux.session_name(project_id, agent["name"]) - alive = "live" if session in live else "-" + alive = "live" if agent["id"] in live else "-" role = agent.get("role") or "-" + worker_id = agent.get("worker_id") or "-" print( - f"{project_id}/{agent['name']}\t{role}\t{agent['status']}\t{alive}\t{session}" + f"{project_id}/{agent['name']}\t{role}\t{agent['status']}\t{alive}\t{worker_id}" ) return 0 -def _cmd_attach(args: argparse.Namespace) -> int: - session = tmux.session_name(args.project, args.name) - if not tmux.has_session(session): - print(f"error: no live session '{session}'", file=sys.stderr) - return 1 - # Replace this process with an interactive tmux attach. - os.execvp("tmux", ["tmux", "attach", "-t", session]) - return 0 # pragma: no cover - execvp does not return - - def _cmd_kill(args: argparse.Namespace) -> int: try: spawn.kill(args.project, args.name) @@ -233,11 +223,6 @@ def build_parser() -> argparse.ArgumentParser: p_list.add_argument("--project", help="limit to one project") p_list.set_defaults(func=_cmd_list) - p_attach = sub.add_parser("attach", help="attach to an agent's tmux session") - p_attach.add_argument("--project", required=True) - p_attach.add_argument("--name", required=True) - p_attach.set_defaults(func=_cmd_attach) - p_kill = sub.add_parser("kill", help="kill an agent's session") p_kill.add_argument("--project", required=True) p_kill.add_argument("--name", required=True) diff --git a/src/handler/control/credsync.py b/src/handler/control/credsync.py index f253b41..f76363f 100644 --- a/src/handler/control/credsync.py +++ b/src/handler/control/credsync.py @@ -43,15 +43,19 @@ def _credential_files() -> dict[str, str]: def fingerprint() -> tuple: - """(path, mtime_ns, size) of the on-disk credential files — cheap change detection.""" - fp = [] - for path in sorted(_credential_files().values()): - try: - st = os.stat(path) - fp.append((path, st.st_mtime_ns, st.st_size)) - except OSError: - continue - return tuple(fp) + """(path, mtime_ns, size) of the OAuth token file — cheap change detection. + + Deliberately only ``.claude/.credentials.json``: claude touches ``~/.claude.json`` + on every run (project entries, UI state), and treating those as "new credentials" + would ping-pong uploads between workers forever. A login that only rewrites + ``.claude.json`` is still published — the login flow calls :func:`upload` directly. + """ + path = _credential_files()[".claude/.credentials.json"] + try: + st = os.stat(path) + except OSError: + return () + return ((path, st.st_mtime_ns, st.st_size),) def upload() -> bool: @@ -165,10 +169,3 @@ def refresh() -> str | None: _state.seen_updated_at = stored["updated_at"] if stored else None return "uploaded" return None - - -def note_local_write() -> None: - """Record that this process just changed local credentials deliberately (e.g. the - claude_config onboarding merge at spawn), so refresh() doesn't misread the mtime - bump as a new login and ping-pong uploads between workers.""" - _state.last_fingerprint = fingerprint() diff --git a/src/handler/control/settings_gen.py b/src/handler/control/settings_gen.py index f8c1ea1..6e89d02 100644 --- a/src/handler/control/settings_gen.py +++ b/src/handler/control/settings_gen.py @@ -21,7 +21,7 @@ def _hook_command(event: str) -> str: return f"{sys.executable} -m handler.hooks {event}" -def build_settings(headless: bool = False) -> dict: +def build_settings() -> dict: settings = { "hooks": { "Stop": [{"hooks": [{"type": "command", "command": _hook_command("stop")}]}], @@ -39,24 +39,23 @@ def build_settings(headless: bool = False) -> dict: ], } } - if headless: - # ``claude -p`` never prompts — anything that would ask for permission is - # auto-denied. The allowlist is therefore what lets normal work (git, mise, the - # project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard - # gate either way, since a hook deny overrides any allow. - s = get_settings() - settings["permissions"] = { - "defaultMode": s.headless_permission_mode, - "allow": s.headless_allowed_tools_list, - } + # ``claude -p`` never prompts — anything that would ask for permission is + # auto-denied. The allowlist is therefore what lets normal work (git, mise, the + # project's own tooling) proceed; the PreToolUse/Stop hooks above remain the hard + # gate either way, since a hook deny overrides any allow. + s = get_settings() + settings["permissions"] = { + "defaultMode": s.headless_permission_mode, + "allow": s.headless_allowed_tools_list, + } return settings -def write_settings(working_dir: str, headless: bool = False) -> str: +def write_settings(working_dir: str) -> str: """Write ``.claude/settings.json`` under the agent's working dir; return its path.""" claude_dir = os.path.join(working_dir, ".claude") os.makedirs(claude_dir, exist_ok=True) path = os.path.join(claude_dir, "settings.json") with open(path, "w") as fh: - json.dump(build_settings(headless=headless), fh, indent=2) + json.dump(build_settings(), fh, indent=2) return path diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 008fd7c..561c11c 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -15,16 +15,13 @@ from ..config import get_settings from ..db import repository as repo from ..db.engine import connection from . import ( - claude_config, credentials, - credsync, forge, gitops, headless, mise, reposync, settings_gen, - tmux, worktree, ) @@ -47,18 +44,6 @@ def require_test_task(working_dir: str) -> None: ) -def _claude_command(task: str | None, settings_path: str) -> str: - claude = get_settings().claude_bin - argv = [claude, "--settings", settings_path] - if task: - argv.append(_shell_quote(task)) - return " ".join(argv) - - -def _shell_quote(value: str) -> str: - return "'" + value.replace("'", "'\\''") + "'" - - def _install_git_credentials( working_dir: str, git_remote: str | None, conn=None ) -> None: @@ -97,10 +82,10 @@ def spawn( test gate. ``worker_id`` identifies the calling worker container (headless runs record it on the run row; the CLI defaults to a pid-scoped id). """ - if get_settings().runner == "headless" and not task: - # A tmux agent without a task idles at the REPL waiting for input; ``claude -p`` - # has no such mode — an empty prompt would exit immediately having done nothing. - raise SpawnError("a headless agent requires a task (the runner is 'headless')") + if not task: + # ``claude -p`` has no idle-REPL mode — an empty prompt would exit immediately + # having done nothing, so a task is a hard requirement. + raise SpawnError("an agent requires a task (headless claude has no idle mode)") sync_note = None with connection() as conn: project = repo.get_project(conn, project_id) @@ -154,8 +139,7 @@ def spawn( role=role, ) - headless_run = get_settings().runner == "headless" - settings_path = settings_gen.write_settings(working_dir, headless=headless_run) + settings_path = settings_gen.write_settings(working_dir) env = _agent_env(project, agent, token, role=role, mise_init=mise_init) # Verify the pinned forge version, if one is configured. Non-fatal: a version drift @@ -163,25 +147,14 @@ def spawn( # touches forge and the base image is the real pin (README 3.6, Phase 2). forge_note = _check_forge_version(working_dir) - # Mark Claude Code onboarding complete + trust the working dir before launching. The - # tmux path needs both (no human at the TTY to answer the theme/trust screens); - # ``-p`` skips the trust dialog but still reads onboarding state, so keep it for both. - claude_config.ensure_onboarded(working_dir) - credsync.note_local_write() - - if headless_run: - headless.launch( - agent, - kind="spawn", - prompt=task, - settings_path=settings_path, - env=env, - worker_id=worker_id or f"cli-{os.getpid()}", - ) - else: - session = tmux.session_name(project_id, name) - command = _claude_command(task, settings_path) - tmux.new_session(session, cwd=working_dir, command=command, env=env) + headless.launch( + agent, + kind="spawn", + prompt=task, + settings_path=settings_path, + env=env, + worker_id=worker_id or f"cli-{os.getpid()}", + ) agent = {**agent, "forge_note": forge_note, "sync_note": sync_note} return agent @@ -234,51 +207,35 @@ def _check_forge_version(working_dir: str) -> str | None: def kill(project_id: str, name: str) -> None: + """Stop an agent: flag its running run for cancel and mark the row done. + + The owning worker's supervisor polls the cancel flag and SIGTERMs its own child + (cross-worker safe — nobody signals a process they don't own). No running run means + the process is already gone; the status update is all that's left to do. + """ with connection() as conn: agent = repo.get_agent_by_name(conn, project_id, name) if agent is None: raise SpawnError(f"agent '{name}' not found in project '{project_id}'") - if agent.get("session_id"): - # Headless agent: flag the running run for cancel; the owning worker's - # supervisor polls the flag and SIGTERMs its own child (cross-worker safe — - # nobody signals a process they don't own). No running run = already dead. - run = repo.get_latest_run(conn, agent["id"]) - if run is not None and run["status"] == "running": - repo.request_run_cancel(conn, run["id"]) - repo.set_agent_status(conn, agent["id"], "done") - return - session = tmux.session_name(project_id, name) - if tmux.has_session(session): - tmux.kill_session(session) + run = repo.get_latest_run(conn, agent["id"]) + if run is not None and run["status"] == "running": + repo.request_run_cancel(conn, run["id"]) repo.set_agent_status(conn, agent["id"], "done") def resume(agent: dict, answer: str, worker_id: str | None = None) -> tuple[bool, str]: - """Feed an operator's answer back to an agent. + """Feed an operator's answer back to an agent as a new ``claude -p --resume`` run. - The seam the API's ``/resume`` route calls (and the one tests mock). Legacy tmux - agents (``session_id`` null) get the answer typed into their live session; headless - agents get a brand-new ``claude -p --resume`` run on this worker, with the session - transcript materialized from the DB archive first so any worker can serve the resume. - """ - if agent.get("session_id"): - return _resume_headless(agent, answer, worker_id or f"cli-{os.getpid()}") - session = tmux.session_name(agent["project_id"], agent["name"]) - if not tmux.has_session(session): - return False, f"no live session '{session}' to resume" - tmux.send_keys(session, answer) - return True, f"answer delivered to session '{session}'" - - -def _resume_headless(agent: dict, answer: str, worker_id: str) -> tuple[bool, str]: - """Launch a ``--resume`` run for a headless agent, materializing its session first. - - Refuses while a run is still live (two concurrent processes on one session would - corrupt it). When neither this worker nor the DB has the transcript — the owning - worker died before its first archive — falls back to a *fresh* session whose prompt - re-injects context from the DB (checkmark + open question + answer), recorded as a - ``worker`` event so the UI shows the degraded continuity. + The seam the API's ``/resume`` route calls (and the one tests mock). The session + transcript is materialized from the DB archive first, so ANY worker can serve the + resume. Refuses while a run is still live (two concurrent processes on one session + would corrupt it). When no transcript survives anywhere — the owning worker died + before its first archive, or the row predates the headless runner — falls back to a + *fresh* session whose prompt re-injects context from the DB (checkmark + open + question + answer), recorded as a ``worker`` event so the UI shows the degraded + continuity. """ + worker_id = worker_id or f"cli-{os.getpid()}" with connection() as conn: run = repo.get_latest_run(conn, agent["id"]) if run is not None and run["status"] == "running": @@ -289,7 +246,7 @@ def _resume_headless(agent: dict, answer: str, worker_id: str) -> tuple[bool, st return False, f"project '{agent['project_id']}' not registered" working_dir = agent["working_dir"] - settings_path = settings_gen.write_settings(working_dir, headless=True) + settings_path = settings_gen.write_settings(working_dir) try: token = None with connection() as conn: @@ -298,6 +255,10 @@ def _resume_headless(agent: dict, answer: str, worker_id: str) -> tuple[bool, st return False, str(exc) env = _agent_env(project, agent, token) + if not agent.get("session_id"): + # Pre-headless agent row (or a spawn that never launched): nothing to --resume. + return _resume_reinjected(agent, answer, settings_path, env, worker_id) + transcript = headless.session_dir(working_dir) / f"{agent['session_id']}.jsonl" if archive is not None: try: diff --git a/src/handler/control/tmux.py b/src/handler/control/tmux.py index a29fb9d..94bd49f 100644 --- a/src/handler/control/tmux.py +++ b/src/handler/control/tmux.py @@ -1,7 +1,8 @@ -"""Thin tmux wrapper — the single mock seam for spawning. +"""Thin tmux wrapper — now used ONLY by the interactive ``/login`` flow. -Every tmux/claude invocation goes through these functions so tests can substitute a -fake and never touch a real tmux server or ``claude`` binary. +Agent runs are headless (``control.headless``); the one thing that still genuinely +needs a TTY is driving claude's ``/login`` OAuth screens. Everything here goes through +subprocess so the login tests can substitute a fake and never touch a real tmux server. """ from __future__ import annotations @@ -11,14 +12,6 @@ import subprocess from ..config import get_settings -def session_name(project_id: str, agent_name: str) -> str: - """``project__agent`` with tmux-illegal characters sanitized (README 3.4).""" - safe = f"{project_id}__{agent_name}" - for ch in (".", ":", " "): - safe = safe.replace(ch, "-") - return safe - - def new_session( name: str, cwd: str, @@ -57,18 +50,6 @@ def has_session(name: str) -> bool: return result.returncode == 0 -def list_sessions() -> list[str]: - tmux = get_settings().tmux_bin - result = subprocess.run( - [tmux, "list-sessions", "-F", "#{session_name}"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return [] - return [line for line in result.stdout.splitlines() if line] - - def kill_session(name: str) -> None: tmux = get_settings().tmux_bin subprocess.run([tmux, "kill-session", "-t", name], check=True) diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index 36c2b36..7368b99 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -1,11 +1,13 @@ -"""The control-container worker: executes commands the API enqueues. +"""The control/worker container: executes commands the API enqueues and supervises +headless claude runs. -The API (in its own container) has no ``git``/``tmux``/``claude`` and does not own the -tmux sessions, so it cannot run control actions directly. Instead it writes a ``queued`` -row to the ``commands`` table; this worker — running in the control container — claims each -row, dispatches it to the *same* control functions the CLI uses (``spawn``/``poller``/ -``skills_gen``/``repo.record_approval``), and writes the result or error back. It also runs -the periodic CI sweep, subsuming the old ``poll-ci --watch`` loop. +The API (in its own container) has no ``git``/``claude``, so it cannot run control +actions directly. Instead it writes a ``queued`` row to the ``commands`` table; any +worker claims each row (multi-worker safe — ``FOR UPDATE SKIP LOCKED`` + slot-aware +claim filters), dispatches it to the *same* control functions the CLI uses (``spawn``/ +``poller``/``skills_gen``/``repo.record_approval``), and writes the result or error +back. It also heartbeats + reaps dead workers' runs, syncs claude credentials, and runs +the periodic CI sweep. Every command runs in isolation: one bad command is recorded as ``failed`` and never stops the loop. ``execute_command`` is the pure dispatch seam (given a claimed command dict, @@ -23,7 +25,7 @@ from datetime import UTC, datetime, timedelta from ..config import get_settings from ..db import repository as repo from ..db.engine import connection -from . import credsync, gitops, login, poller, reposync, skills_gen, spawn, tmux +from . import credsync, gitops, login, poller, reposync, skills_gen, spawn # Command types that launch a claude run and therefore need a free slot on this worker. # A worker with all slots busy leaves these queued for a less-loaded worker to claim. @@ -394,43 +396,6 @@ def fire_due_schedules(now: datetime | None = None) -> int: return fired -# How many trailing pane lines to snapshot — enough to show the current screen (a menu, a -# prompt, the tail of the last command) without bloating the row. -_PANE_TAIL_LINES = 40 - - -def _pane_tail(pane: str, lines: int = _PANE_TAIL_LINES) -> str: - """The last ``lines`` of a captured pane, trailing blank lines trimmed so an idle - screen doesn't store as a wall of whitespace.""" - rows = (pane or "").splitlines() - while rows and not rows[-1].strip(): - rows.pop() - return "\n".join(rows[-lines:]) - - -def capture_agent_output() -> int: - """Snapshot each working agent's live tmux pane tail into the DB. - - The tmux socket lives only in the control container, so this is the one channel the - API/UI have onto what a running — or wedged — agent is actually doing: an agent stuck - on claude's first-run theme picker surfaces as that screen instead of a misleading - green 'working'. A missing session is skipped (its process is gone). Returns the count - updated. - """ - with connection() as conn: - working = repo.list_agents_by_status(conn, "working") - updated = 0 - for agent in working: - session = tmux.session_name(agent["project_id"], agent["name"]) - if not tmux.has_session(session): - continue - tail = _pane_tail(tmux.capture_pane(session)) - with connection() as conn: - repo.update_agent_output(conn, agent["id"], tail) - updated += 1 - return updated - - def drain(worker_id: str, limit: int | None = None) -> int: """Claim and run queued commands until the queue is empty (or ``limit`` reached). @@ -454,15 +419,12 @@ def drain(worker_id: str, limit: int | None = None) -> int: def _full_slot_exclusions(worker_id: str) -> tuple[str, ...]: """Command types this worker must not claim right now. - Headless runs are supervised in-process, so a worker at ``max_concurrent_runs`` skips + Runs are supervised in-process, so a worker at ``max_concurrent_runs`` skips claiming run-starting commands — they stay queued for a worker with a free slot. Slot accounting is DB-driven (this worker's ``running`` runs), so it needs no in-memory registry and survives restarts (a fresh process gets a fresh worker id; stale rows - belong to the old id and are the reaper's problem). Tmux runs are fire-and-forget and - never consume a slot. + belong to the old id and are the reaper's problem). """ - if get_settings().runner != "headless": - return () with connection() as conn: active = len(repo.list_running_runs(conn, worker_id=worker_id)) if active >= get_settings().max_concurrent_runs: @@ -474,20 +436,18 @@ def run( worker_id: str | None = None, poll_interval: float = 2.0, ci_interval: float = 30.0, - capture_interval: float = 2.0, credsync_interval: float = 30.0, reap_interval: float = 15.0, iterations: int | None = None, ) -> None: - """The control-container main loop: drain the command queue, snapshot live agent - output, sync claude credentials, heartbeat + reap dead workers, and sweep CI. + """The control-container main loop: drain the command queue, sync claude + credentials, heartbeat + reap dead workers, and sweep CI. ``iterations`` bounds the loop for tests; production runs unbounded. Sleeps ``poll_interval`` only when a pass found no commands, so bursts drain promptly. """ worker_id = worker_id or make_worker_id() last_ci = 0.0 - last_capture = 0.0 last_credsync = 0.0 last_reap = 0.0 count = 0 @@ -508,12 +468,6 @@ def run( pass did_work = drain(worker_id) > 0 now = time.monotonic() - if capture_interval > 0 and now - last_capture >= capture_interval: - try: - capture_agent_output() - except Exception: # noqa: BLE001 - a capture hiccup must not kill the worker - pass - last_capture = now if credsync_interval > 0 and (last_credsync == 0.0 or now - last_credsync >= credsync_interval): # First pass runs immediately: a fresh worker container must materialize the # claude credentials before it claims its first spawn. diff --git a/tests/conftest.py b/tests/conftest.py index 5adb9d7..d931253 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,8 @@ """Shared fixtures. Everything runs on a fresh SQLite file per test, materialized via a *real* ``alembic upgrade head`` — so the migration path itself is under test, not -just ``create_all``. No live claude/tmux/mise is ever touched: the three seams -(``control.tmux``, ``hooks.verify``, ``control.spawn.resume``) are faked. +just ``create_all``. No live claude/tmux/mise is ever touched: the seams +(``control.headless.launch`` for runs, ``control.tmux`` for the login flow, +``hooks.verify``, ``control.spawn.resume``) are faked. """ from __future__ import annotations @@ -111,20 +112,44 @@ def fake_tmux(monkeypatch): def send_enter(name): calls["send_enter"].append({"name": name}) - def list_sessions(): - return list(live) - monkeypatch.setattr(tmux, "new_session", new_session) monkeypatch.setattr(tmux, "has_session", has_session) monkeypatch.setattr(tmux, "kill_session", kill_session) monkeypatch.setattr(tmux, "send_keys", send_keys) monkeypatch.setattr(tmux, "send_text", send_text) monkeypatch.setattr(tmux, "send_enter", send_enter) - monkeypatch.setattr(tmux, "list_sessions", list_sessions) return {"calls": calls, "live": live} +@pytest.fixture +def fake_launch(monkeypatch): + """Record ``headless.launch`` calls instead of spawning a claude subprocess. + + Mirrors the real launch's DB side effects (run row + agent session/worker) so kill/ + resume logic downstream of a fake spawn behaves like production, minus the process. + """ + from handler.control import headless + from handler.db import repository as repo + from handler.db.engine import connection + + calls: list[dict] = [] + + def launch(agent, *, kind, prompt, settings_path, env, worker_id, on_exit=None): + session_id = agent.get("session_id") if kind == "resume" else f"fake-sid-{len(calls) + 1}" + with connection() as conn: + run = repo.create_run(conn, agent["id"], session_id, worker_id, kind) + repo.set_agent_session(conn, agent["id"], session_id, worker_id) + calls.append( + {"agent": agent, "kind": kind, "prompt": prompt, "settings_path": settings_path, + "env": env, "worker_id": worker_id, "run": run} + ) + return run + + monkeypatch.setattr(headless, "launch", launch) + return calls + + @pytest.fixture def fake_gitops(monkeypatch): """Fake the git seam: record config/add/commit, return a controllable branch/sha.""" diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index fb34d92..5fb48ef 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -1,4 +1,7 @@ -"""Control-layer spawn: the hard test-task gate, settings generation, identity env.""" +"""Control-layer spawn: the hard test-task gate, settings generation, identity env. + +Spawns go through the ``fake_launch`` seam (conftest) — the headless analogue of the old +fake tmux: it records the launch and mirrors its DB side effects, no subprocess.""" from __future__ import annotations @@ -24,36 +27,48 @@ def _write_mise(root, with_test=True): (root / ".mise.toml").write_text(body) -def test_spawn_refuses_without_test_task(env, fake_tmux): +def test_spawn_refuses_without_test_task(env, fake_launch): root = env["tmp"] / "proj" _write_mise(root, with_test=False) _register_project(root) with pytest.raises(spawn.SpawnError, match="no \\[tasks.test\\]"): - spawn.spawn("proj", "api") - assert fake_tmux["calls"]["new_session"] == [] + spawn.spawn("proj", "api", task="do it") + assert fake_launch == [] -def test_spawn_refuses_without_mise_file(env, fake_tmux): +def test_spawn_refuses_without_mise_file(env, fake_launch): root = env["tmp"] / "proj" root.mkdir(parents=True, exist_ok=True) _register_project(root) with pytest.raises(spawn.SpawnError, match="no mise config"): + spawn.spawn("proj", "api", task="do it") + + +def test_spawn_refuses_without_task(env, fake_launch): + root = env["tmp"] / "proj" + _write_mise(root) + _register_project(root) + with pytest.raises(spawn.SpawnError, match="requires a task"): spawn.spawn("proj", "api") + # Fail-fast: no orphaned agent row behind the refused spawn. + with get_engine().begin() as conn: + assert repo.get_agent_by_name(conn, "proj", "api") is None + assert fake_launch == [] -def test_spawn_accepts_dotless_mise_toml(env, fake_tmux): +def test_spawn_accepts_dotless_mise_toml(env, fake_launch): # mise also reads `mise.toml` (no leading dot); the gate must honor it too. root = env["tmp"] / "proj" root.mkdir(parents=True, exist_ok=True) (root / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n") _register_project(root) - agent = spawn.spawn("proj", "api") + agent = spawn.spawn("proj", "api", task="do it") with get_engine().begin() as conn: assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"] -def test_spawn_creates_agent_settings_and_session(env, fake_tmux): +def test_spawn_creates_agent_settings_and_run(env, fake_launch): root = env["tmp"] / "proj" _write_mise(root, with_test=True) _register_project(root) @@ -64,66 +79,99 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux): with get_engine().begin() as conn: assert repo.get_agent_by_name(conn, "proj", "api")["id"] == agent["id"] - # settings.json wires all four hook events. + # settings.json wires all four hook events AND the headless permission allowlist + # (claude -p auto-denies anything that would prompt; the allowlist is what lets + # normal work proceed — the hooks stay the hard gate). settings = json.loads((root / ".claude" / "settings.json").read_text()) assert set(settings["hooks"]) == {"Stop", "SessionEnd", "PreToolUse", "Notification"} pre = settings["hooks"]["PreToolUse"][0] assert pre["matcher"] == "AskUserQuestion|Bash" assert "handler.hooks pre_tool_use" in pre["hooks"][0]["command"] + assert settings["permissions"]["defaultMode"] == "acceptEdits" + assert "Bash(git *)" in settings["permissions"]["allow"] - # tmux session named project__agent, with identity + DATABASE_URL in env. - call = fake_tmux["calls"]["new_session"][0] - assert call["name"] == "proj__api" + # A headless run launched with identity + DATABASE_URL in env and the task as prompt. + call = fake_launch[0] + assert call["kind"] == "spawn" + assert call["prompt"] == "build the thing" assert call["env"]["HANDLER_PROJECT_ID"] == "proj" assert call["env"]["HANDLER_AGENT_NAME"] == "api" assert call["env"]["HANDLER_AGENT_ID"] == str(agent["id"]) assert call["env"]["DATABASE_URL"] == env["url"] + # The run row + session id landed on the agent. + with get_engine().begin() as conn: + row = repo.get_agent_by_name(conn, "proj", "api") + assert row["session_id"] == call["run"]["session_id"] + assert repo.get_latest_run(conn, row["id"])["kind"] == "spawn" -def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_tmux): +def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_launch): # A repo with no .mise.toml at all: the normal gate would refuse, but the mise-init # bootstrap agent must launch anyway (creating that file is its whole job). root = env["tmp"] / "proj" root.mkdir(parents=True, exist_ok=True) _register_project(root) - agent = spawn.spawn("proj", "mise-init", require_tests=False, mise_init=True) + agent = spawn.spawn( + "proj", "mise-init", task="write the mise config", require_tests=False, mise_init=True + ) with get_engine().begin() as conn: assert repo.get_agent_by_name(conn, "proj", "mise-init")["id"] == agent["id"] - # The launched session carries HANDLER_MISE_INIT so its hooks enforce commit + push. - call = fake_tmux["calls"]["new_session"][0] - assert call["env"]["HANDLER_MISE_INIT"] == "1" + # The launched run carries HANDLER_MISE_INIT so its hooks enforce commit + push. + assert fake_launch[0]["env"]["HANDLER_MISE_INIT"] == "1" -def test_spawn_still_gates_without_mise_init_flag(env, fake_tmux): +def test_spawn_still_gates_without_mise_init_flag(env, fake_launch): root = env["tmp"] / "proj" root.mkdir(parents=True, exist_ok=True) _register_project(root) # require_tests defaults on, so a normal spawn against a mise-less repo still refuses. with pytest.raises(spawn.SpawnError, match="no mise config"): - spawn.spawn("proj", "api") - assert fake_tmux["calls"]["new_session"] == [] + spawn.spawn("proj", "api", task="do it") + assert fake_launch == [] -def test_kill_sets_done_and_kills_session(env, fake_tmux): +def test_kill_cancels_run_and_sets_done(env, fake_launch): root = env["tmp"] / "proj" _write_mise(root, with_test=True) _register_project(root) - spawn.spawn("proj", "api") + spawn.spawn("proj", "api", task="do it") spawn.kill("proj", "api") - assert "proj__api" in fake_tmux["calls"]["kill_session"] with get_engine().begin() as conn: - assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done" + agent = repo.get_agent_by_name(conn, "proj", "api") + assert agent["status"] == "done" + # The running run was flagged; the owning supervisor terminates its own child. + assert repo.get_latest_run(conn, agent["id"])["cancel_requested"] is True -def test_resume_sends_answer_to_live_session(env, fake_tmux): +def test_resume_reinjects_when_no_transcript(env, fake_launch): + """A resume with no archive and no local transcript degrades to a fresh run whose + prompt carries the operator's answer (context re-injection).""" root = env["tmp"] / "proj" _write_mise(root, with_test=True) _register_project(root) - agent = spawn.spawn("proj", "api") + spawn.spawn("proj", "api", task="do it") + with get_engine().begin() as conn: + agent = repo.get_agent_by_name(conn, "proj", "api") + repo.finish_run(conn, repo.get_latest_run(conn, agent["id"])["id"], "completed") ok, detail = spawn.resume(agent, "use Postgres") assert ok is True - assert fake_tmux["calls"]["send_keys"][0] == {"name": "proj__api", "keys": "use Postgres"} + assert "re-injected" in detail + assert fake_launch[-1]["kind"] == "spawn" + assert "use Postgres" in fake_launch[-1]["prompt"] + + +def test_resume_refused_while_run_live(env, fake_launch): + root = env["tmp"] / "proj" + _write_mise(root, with_test=True) + _register_project(root) + spawn.spawn("proj", "api", task="do it") # fake run stays 'running' + with get_engine().begin() as conn: + agent = repo.get_agent_by_name(conn, "proj", "api") + + ok, detail = spawn.resume(agent, "answer") + assert ok is False + assert "live run" in detail diff --git a/tests/test_control_spawn_forge.py b/tests/test_control_spawn_forge.py index b50404c..8d29ff3 100644 --- a/tests/test_control_spawn_forge.py +++ b/tests/test_control_spawn_forge.py @@ -19,15 +19,15 @@ def _register(root, **kw): repo.create_project(conn, "proj", str(root), **kw) -def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gitops, monkeypatch): +def test_spawn_injects_credentials_and_installs_helper(env, fake_launch, fake_gitops, monkeypatch): monkeypatch.setenv("PROJ_TOKEN", "s3cret") root = env["tmp"] / "proj" _write_mise(root) _register(root, git_remote="https://github.com/me/proj.git", credential_ref="env:PROJ_TOKEN") - spawn.spawn("proj", "junior", role="junior") + spawn.spawn("proj", "junior", role="junior", task="do it") - call = fake_tmux["calls"]["new_session"][0] + call = fake_launch[0] # Token injected under the generic + host-specific names, never the raw ref stored. assert call["env"]["FORGE_TOKEN"] == "s3cret" assert call["env"]["GITHUB_TOKEN"] == "s3cret" @@ -38,43 +38,43 @@ def test_spawn_injects_credentials_and_installs_helper(env, fake_tmux, fake_gito assert "$FORGE_TOKEN" in helper[0]["value"] -def test_spawn_ssh_remote_installs_no_https_helper(env, fake_tmux, fake_gitops, monkeypatch): +def test_spawn_ssh_remote_installs_no_https_helper(env, fake_launch, fake_gitops, monkeypatch): monkeypatch.setenv("PROJ_TOKEN", "s3cret") root = env["tmp"] / "proj" _write_mise(root) _register(root, git_remote="git@github.com:me/proj.git", credential_ref="env:PROJ_TOKEN") - spawn.spawn("proj", "junior", role="junior") + spawn.spawn("proj", "junior", role="junior", task="do it") # ssh remote -> token still injected, but no HTTPS credential helper installed. - assert fake_tmux["calls"]["new_session"][0]["env"]["GITHUB_TOKEN"] == "s3cret" + assert fake_launch[0]["env"]["GITHUB_TOKEN"] == "s3cret" assert fake_gitops["config"] == [] -def test_spawn_fails_fast_on_broken_credential_ref(env, fake_tmux, fake_gitops, monkeypatch): +def test_spawn_fails_fast_on_broken_credential_ref(env, fake_launch, fake_gitops, monkeypatch): monkeypatch.delenv("ABSENT_TOKEN", raising=False) root = env["tmp"] / "proj" _write_mise(root) _register(root, credential_ref="env:ABSENT_TOKEN") with pytest.raises(spawn.SpawnError, match="not set"): - spawn.spawn("proj", "junior", role="junior") + spawn.spawn("proj", "junior", role="junior", task="do it") # No agent row and no session left behind by the failed spawn. with get_engine().begin() as conn: assert repo.get_agent_by_name(conn, "proj", "junior") is None - assert fake_tmux["calls"]["new_session"] == [] + assert fake_launch == [] -def test_spawn_without_credential_ref_injects_no_token(env, fake_tmux, fake_gitops): +def test_spawn_without_credential_ref_injects_no_token(env, fake_launch, fake_gitops): root = env["tmp"] / "proj" _write_mise(root) _register(root) - spawn.spawn("proj", "api") - call = fake_tmux["calls"]["new_session"][0] + spawn.spawn("proj", "api", task="do it") + call = fake_launch[0] assert "FORGE_TOKEN" not in call["env"] # No token -> no credential helper installed. assert fake_gitops["config"] == [] -def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_forge, monkeypatch): +def test_spawn_reports_forge_version_mismatch(env, fake_launch, fake_gitops, fake_forge, monkeypatch): monkeypatch.setenv("FORGE_VERSION", "9.9.9") from handler import config from handler.db import engine @@ -88,5 +88,5 @@ def test_spawn_reports_forge_version_mismatch(env, fake_tmux, fake_gitops, fake_ fake_forge["version_ok"] = False fake_forge["version_out"] = "forge 1.2.3" - agent = spawn.spawn("proj", "api") + agent = spawn.spawn("proj", "api", task="do it") assert "9.9.9" in agent["forge_note"] diff --git a/tests/test_credsync.py b/tests/test_credsync.py index cb748c4..f7bb8bb 100644 --- a/tests/test_credsync.py +++ b/tests/test_credsync.py @@ -101,10 +101,3 @@ def test_disabled_without_secret_key(env, tmp_path): _write_local_credentials(tmp_path) assert credsync.upload() is False assert credsync.refresh() is None - - -def test_note_local_write_suppresses_upload(secret_env, tmp_path): - _write_local_credentials(tmp_path) - credsync.note_local_write() - # The deliberate local write (e.g. ensure_onboarded at spawn) is not re-published. - assert credsync.refresh() is None diff --git a/tests/test_headless_run.py b/tests/test_headless_run.py index ad9600c..e413ccf 100644 --- a/tests/test_headless_run.py +++ b/tests/test_headless_run.py @@ -26,7 +26,6 @@ def headless_env(env, monkeypatch): from handler import config monkeypatch.setenv("CLAUDE_BIN", FAKE_CLAUDE) - monkeypatch.setenv("RUNNER", "headless") config.get_settings.cache_clear() yield env config.get_settings.cache_clear() @@ -250,16 +249,13 @@ def test_resume_refused_while_run_live(headless_env, tmp_path, monkeypatch): _wait_for(_finished_run(run["id"]), timeout=30.0) -def test_headless_settings_include_permissions(headless_env, tmp_path): - path = settings_gen.write_settings(str(tmp_path / "wd"), headless=True) +def test_settings_include_permissions_and_hooks(headless_env, tmp_path): + path = settings_gen.write_settings(str(tmp_path / "wd")) data = json.loads(Path(path).read_text()) assert data["permissions"]["defaultMode"] == "acceptEdits" assert "Bash(git *)" in data["permissions"]["allow"] assert "hooks" in data # the hard gate is untouched - tmux_path = settings_gen.write_settings(str(tmp_path / "wd2")) - assert "permissions" not in json.loads(Path(tmux_path).read_text()) - def test_api_rejects_empty_task_headless_spawn(headless_env, client, auth): client.post( diff --git a/tests/test_integration_web_spawn.py b/tests/test_integration_web_spawn.py index 06ff58b..01b7dae 100644 --- a/tests/test_integration_web_spawn.py +++ b/tests/test_integration_web_spawn.py @@ -1,13 +1,32 @@ """End-to-end web management: the dashboard's HTTP calls -> command queue -> worker -> -real ``spawn.spawn`` -> tmux seam. Proves the full container-split flow works with only the -tmux/claude boundary faked, not the control layer itself.""" +real ``spawn.spawn`` -> a real headless subprocess (the fake claude binary). Proves the +full container-split flow works with only the claude binary faked, not the control +layer: events stream into the DB, the run reconciles, kill cancels.""" from __future__ import annotations +import time +from pathlib import Path + +import pytest + from handler.control import worker from handler.db import repository as repo from handler.db.engine import get_engine +REPO_ROOT = Path(__file__).resolve().parents[1] +FAKE_CLAUDE = str(REPO_ROOT / "tests" / "fixtures" / "fake_claude.py") + + +@pytest.fixture +def headless_env(env, monkeypatch): + from handler import config + + monkeypatch.setenv("CLAUDE_BIN", FAKE_CLAUDE) + config.get_settings.cache_clear() + yield env + config.get_settings.cache_clear() + def _spawnable_project(root): root.mkdir(parents=True, exist_ok=True) @@ -16,10 +35,21 @@ def _spawnable_project(root): repo.create_project(conn, "proj", str(root)) -def test_spawn_via_api_then_worker_creates_agent_and_session(client, auth, env, fake_tmux): - _spawnable_project(env["tmp"] / "proj") +def _wait(predicate, timeout=20.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(0.1) + return None - # 1. The dashboard enqueues a spawn (202 + a queued command). + +def test_spawn_via_api_then_worker_runs_headless_claude(client, auth, headless_env): + _spawnable_project(headless_env["tmp"] / "proj") + + # 1. The dashboard enqueues a spawn (202 + a queued command). A task is mandatory — + # headless claude has no idle-REPL mode. r = client.post( "/projects/proj/agents/spawn", json={"name": "api", "task": "build the thing"}, @@ -32,29 +62,62 @@ def test_spawn_via_api_then_worker_creates_agent_and_session(client, auth, env, # No agent yet — the worker hasn't run. assert client.get("/projects/proj/agents", headers=auth).json() == [] - # 2. The control worker drains the queue (runs the real spawn.spawn). + # 2. The control worker drains the queue (real spawn.spawn -> real subprocess). assert worker.drain("test-worker") == 1 - # 3. The command is done and the agent + tmux session now exist. + # 3. The command finished at launch (fire-and-forget)... got = client.get(f"/commands/{command_id}", headers=auth).json() assert got["status"] == "done" assert got["result"]["name"] == "api" - agents = client.get("/projects/proj/agents", headers=auth).json() - assert [a["name"] for a in agents] == ["api"] - assert fake_tmux["calls"]["new_session"][0]["name"] == "proj__api" + # ...and the run's whole life shows up via the API: events stream in, the agent + # reconciles to done, last_output is the assistant's text. + def finished(): + agents = client.get("/projects/proj/agents", headers=auth).json() + return agents if agents and agents[0]["status"] == "done" else None + + agents = _wait(finished) + assert agents is not None, "run never reconciled to done" + agent = agents[0] + assert agent["name"] == "api" + assert agent["session_id"] + assert agent["worker_id"] == "test-worker" + assert agent["last_output"] == "working on: build the thing" + + events = client.get("/projects/proj/agents/api/events", headers=auth).json() + assert [e["type"] for e in events] == ["system", "assistant", "result"] -def test_kill_via_api_then_worker(client, auth, env, fake_tmux): - _spawnable_project(env["tmp"] / "proj") - client.post("/projects/proj/agents/spawn", json={"name": "api"}, headers=auth) +def test_spawn_without_task_is_rejected(client, auth, headless_env): + _spawnable_project(headless_env["tmp"] / "proj") + r = client.post("/projects/proj/agents/spawn", json={"name": "api"}, headers=auth) + assert r.status_code == 400 + assert "task is required" in r.json()["detail"] + + +def test_kill_via_api_then_worker(client, auth, headless_env, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_MODE", "hang") + _spawnable_project(headless_env["tmp"] / "proj") + client.post( + "/projects/proj/agents/spawn", json={"name": "api", "task": "hang"}, headers=auth + ) worker.drain("w") + # The hanging run is live; kill flags it and the supervisor SIGTERMs its child. r = client.post("/projects/proj/agents/api/kill", headers=auth) assert r.status_code == 202 worker.drain("w") assert client.get(f"/commands/{r.json()['id']}", headers=auth).json()["status"] == "done" - assert "proj__api" in fake_tmux["calls"]["kill_session"] with get_engine().begin() as conn: - assert repo.get_agent_by_name(conn, "proj", "api")["status"] == "done" + agent = repo.get_agent_by_name(conn, "proj", "api") + assert agent["status"] == "done" + + def canceled(): + with get_engine().begin() as conn: + run = repo.get_latest_run(conn, agent["id"]) + return run if run["status"] != "running" else None + + run = _wait(canceled, timeout=30.0) + assert run is not None, "kill never terminated the hanging run" + assert run["status"] == "canceled" diff --git a/tests/test_worker.py b/tests/test_worker.py index 6ac847f..135570d 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -229,45 +229,6 @@ def test_bad_command_is_recorded_failed_not_raised(env): assert "agent name" in failed["error"] -def test_capture_agent_output_snapshots_working_agents(env, monkeypatch): - with get_engine().begin() as conn: - repo.create_project(conn, "p", "/tmp/p") - agent = repo.create_agent(conn, "p", "api", "/tmp/p/api", status="working") - - monkeypatch.setattr(worker.tmux, "has_session", lambda name: True) - monkeypatch.setattr( - worker.tmux, "capture_pane", lambda name, escapes=False: "boot\nTheme picker\n\n\n" - ) - - assert worker.capture_agent_output() == 1 - with get_engine().begin() as conn: - row = repo.get_agent_by_id(conn, agent["id"]) - # The tail is stored with trailing blank lines trimmed. - assert row["last_output"] == "boot\nTheme picker" - assert row["output_at"] is not None - - -def test_capture_agent_output_skips_dead_sessions_and_nonworking(env, monkeypatch): - with get_engine().begin() as conn: - repo.create_project(conn, "p", "/tmp/p") - repo.create_agent(conn, "p", "gone", "/tmp/p/gone", status="working") - done = repo.create_agent(conn, "p", "done", "/tmp/p/done", status="done") - - captured = [] - monkeypatch.setattr(worker.tmux, "has_session", lambda name: False) - monkeypatch.setattr( - worker.tmux, - "capture_pane", - lambda name, escapes=False: captured.append(name) or "x", - ) - - # The working agent's session is dead (skipped); the done agent isn't queried at all. - assert worker.capture_agent_output() == 0 - assert captured == [] - with get_engine().begin() as conn: - assert repo.get_agent_by_id(conn, done["id"])["last_output"] is None - - def test_drain_processes_multiple_then_stops(env, monkeypatch): _seed_project() monkeypatch.setattr(poller, "sweep", lambda project_id=None: {"checked": 0}) diff --git a/tests/test_worker_concurrency.py b/tests/test_worker_concurrency.py index b96fe9d..9e9d0e2 100644 --- a/tests/test_worker_concurrency.py +++ b/tests/test_worker_concurrency.py @@ -16,7 +16,6 @@ from handler.db.engine import get_engine def headless_env(env, monkeypatch): from handler import config - monkeypatch.setenv("RUNNER", "headless") monkeypatch.setenv("MAX_CONCURRENT_RUNS", "2") config.get_settings.cache_clear() yield env @@ -75,7 +74,3 @@ def test_slot_frees_when_run_finishes(headless_env, monkeypatch): with get_engine().begin() as conn: repo.finish_run(conn, run1["id"], "completed", exit_code=0) assert worker._full_slot_exclusions("w1") == () - - -def test_tmux_runner_never_excludes(env): - assert worker._full_slot_exclusions("w") == ()