diff --git a/frontend/components/sections/AgentsSection.tsx b/frontend/components/sections/AgentsSection.tsx index d8661da..be18a88 100644 --- a/frontend/components/sections/AgentsSection.tsx +++ b/frontend/components/sections/AgentsSection.tsx @@ -2,10 +2,10 @@ * Spawning enqueues a control command that the worker turns into a tmux + claude process. */ "use client"; -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useDashboard } from "@/components/store"; import { Badge, Button, Card, Input, Select, StatusBadge, Textarea } from "@/components/ui"; -import { fmtFull } from "@/lib/format"; +import { fmtFull, timeAgo } from "@/lib/format"; const ROLE_OPTS = [ { value: "", label: "Role — none" }, @@ -121,28 +121,55 @@ export function AgentsSection() { {agents.map((a) => ( - - {a.name} - {a.role ? {a.role} : "—"} - - - - {a.working_dir} - {fmtFull(a.created_at)} - -
- - - -
- - + + + {a.name} + {a.role ? {a.role} : "—"} + + + + {a.working_dir} + {fmtFull(a.created_at)} + +
+ + + +
+ + + {a.status === "working" && a.last_output?.trim() && ( + + +
+ live output{a.output_at ? ` · ${timeAgo(a.output_at)}` : ""} +
+
+                                {a.last_output}
+                              
+ + + )} +
))} diff --git a/frontend/components/sections/RepositoriesSection.tsx b/frontend/components/sections/RepositoriesSection.tsx index 8479e06..c847fdf 100644 --- a/frontend/components/sections/RepositoriesSection.tsx +++ b/frontend/components/sections/RepositoriesSection.tsx @@ -22,6 +22,7 @@ const empty: NewProjectBody = { root_dir: "", git_remote: "", credential_ref: "", + init_mise: false, }; export function RepositoriesSection() { @@ -163,6 +164,28 @@ export function RepositoriesSection() { )} + {!editing && ( + + )} + {!editing && form.init_mise && form.mode === "manual" && !form.git_remote.trim() && ( +

+ A git remote is required to push the new .mise.toml — + add one above, or mise won’t be initialized. +

+ )} +
\ 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 1594751..22a12d7 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-5b19e394a5d5460f.js"],"default",1] +3:I[9859,["931","static/chunks/app/page-a511d608db773c67.js"],"default",1] 4:I[4707,[],""] 5:I[6423,[],""] -0:["UFaQ4h6X_9jNfr8PwK2IS",[[["",{"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:["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]]]] 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/claude_config.py b/src/handler/control/claude_config.py new file mode 100644 index 0000000..eaa13e2 --- /dev/null +++ b/src/handler/control/claude_config.py @@ -0,0 +1,78 @@ +"""Seed the user-level Claude Code config so a spawned agent never blocks on first-run +onboarding. + +A freshly-installed ``claude`` opens an interactive setup — the theme picker, then a +"do you trust the files in this folder?" prompt — before it reaches the REPL. A spawned +agent runs detached in tmux with no human at the TTY, so it would sit on the theme picker +forever (the observed wedge: ``agents.status='working'`` while nothing happens). The +``/login`` flow drives those screens by hand; a spawned agent can't, so we mark onboarding +complete on disk *before* launching claude. + +The write is a merge, never a clobber: ``~/.claude.json`` also holds the ``oauthAccount`` +the login flow wrote, and losing that would log the agent out. We only fill in the keys +that gate first-run and leave everything else — including a theme the operator already +chose — untouched. +""" + +from __future__ import annotations + +import json +import os + + +def _home() -> str: + return os.path.expanduser("~") or "/tmp" + + +def config_path(home: str | None = None) -> str: + return os.path.join(home or _home(), ".claude.json") + + +def _load(path: str) -> dict: + if not os.path.exists(path): + return {} + try: + with open(path) as fh: + data = json.load(fh) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _atomic_write(path: str, data: dict) -> None: + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = f"{path}.handler.tmp" + with open(tmp, "w") as fh: + json.dump(data, fh, indent=2) + os.replace(tmp, path) + + +def ensure_onboarded(working_dir: str | None = None, home: str | None = None) -> str: + """Mark Claude Code onboarding complete in ``~/.claude.json`` and (when given) trust + ``working_dir``, so a detached agent boots straight to the REPL. Returns the path. + + Idempotent and merge-only: ``hasCompletedOnboarding`` is forced true (a stale ``false`` + from a half-finished run would otherwise re-trigger setup), while ``theme`` is only + filled in when absent so an operator's chosen theme survives. + """ + path = config_path(home) + data = _load(path) + + data["hasCompletedOnboarding"] = True + data.setdefault("theme", "dark") + + if working_dir: + projects = data.get("projects") + if not isinstance(projects, dict): + projects = {} + entry = projects.get(working_dir) + if not isinstance(entry, dict): + entry = {} + # The per-directory trust prompt claude shows on first entry into a new folder. + entry["hasTrustDialogAccepted"] = True + entry.setdefault("hasCompletedProjectOnboarding", True) + projects[working_dir] = entry + data["projects"] = projects + + _atomic_write(path, data) + return path diff --git a/src/handler/control/gitops.py b/src/handler/control/gitops.py index 0faf8f2..6e6c12b 100644 --- a/src/handler/control/gitops.py +++ b/src/handler/control/gitops.py @@ -61,6 +61,27 @@ def config_local(cwd: str, key: str, value: str) -> tuple[bool, str]: return _run(["config", "--local", key, value], cwd) +def is_clean(cwd: str) -> bool: + """True when the working tree has no staged or unstaged changes.""" + ok, out = _run(["status", "--porcelain"], cwd) + return ok and out == "" + + +def ahead_count(cwd: str) -> int | None: + """Commits HEAD is ahead of its upstream, or ``None`` when no upstream is set. + + A ``None`` distinguishes "never pushed / no tracking branch" (the mise-init gate + treats it as unpushed, prompting ``git push -u``) from "0 commits ahead" (pushed). + """ + ok, out = _run(["rev-list", "--count", "@{upstream}..HEAD"], cwd) + if not ok: + return None + try: + return int(out.strip()) + except ValueError: + return None + + def add(cwd: str, paths: list[str]) -> tuple[bool, str]: return _run(["add", *paths], cwd) diff --git a/src/handler/control/mise.py b/src/handler/control/mise.py new file mode 100644 index 0000000..937a1b8 --- /dev/null +++ b/src/handler/control/mise.py @@ -0,0 +1,58 @@ +"""The mise-config gate — one source of truth for "does this project define the canonical +``[tasks.test]`` task". + +Shared by the spawn gate (``control.spawn.require_test_task``, which refuses to launch an +agent against a project with no test task) and the mise-init Stop hook (which refuses to +let the bootstrap agent finish until the task exists and is committed + pushed). + +mise reads several config filenames, not just ``.mise.toml`` — a repo may ship ``mise.toml`` +(no leading dot) or keep config under ``.config/mise/`` — so the gate accepts any of them +and treats a ``[tasks.test]`` in *any* present config as satisfying the requirement. +""" + +from __future__ import annotations + +import os +import tomllib + +# The config filenames mise itself looks for, in the rough precedence order it uses. A +# project only needs one; we scan all present ones for the test task. +CONFIG_NAMES = ( + "mise.toml", + ".mise.toml", + "mise.local.toml", + ".mise.local.toml", + os.path.join(".config", "mise.toml"), + os.path.join(".config", "mise", "config.toml"), +) + + +def config_paths(working_dir: str) -> list[str]: + return [os.path.join(working_dir, name) for name in CONFIG_NAMES] + + +def existing_config(working_dir: str) -> str | None: + """Path to the first present mise config file under ``working_dir``, else ``None``.""" + for path in config_paths(working_dir): + if os.path.exists(path): + return path + return None + + +def has_config(working_dir: str) -> bool: + return existing_config(working_dir) is not None + + +def has_test_task(working_dir: str) -> bool: + """True when any present mise config defines a ``[tasks.test]`` task.""" + for path in config_paths(working_dir): + if not os.path.exists(path): + continue + try: + with open(path, "rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError): + continue + if "test" in (data.get("tasks") or {}): + return True + return False diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 69c8c91..61a056a 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -10,12 +10,21 @@ process launched, so a project without a canonical test task never gets an agent from __future__ import annotations import os -import tomllib from ..config import get_settings from ..db import repository as repo from ..db.engine import connection -from . import credentials, forge, gitops, reposync, settings_gen, tmux, worktree +from . import ( + claude_config, + credentials, + forge, + gitops, + mise, + reposync, + settings_gen, + tmux, + worktree, +) class SpawnError(Exception): @@ -23,19 +32,15 @@ class SpawnError(Exception): def require_test_task(working_dir: str) -> None: - """Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``.""" - mise_path = os.path.join(working_dir, ".mise.toml") - if not os.path.exists(mise_path): + """Hard gate: refuse to spawn unless a mise config defines ``[tasks.test]``.""" + if not mise.has_config(working_dir): raise SpawnError( - f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task " - "before an agent can run against it" + f"no mise config (mise.toml / .mise.toml) in {working_dir}: a project must " + "define a [tasks.test] task before an agent can run against it" ) - with open(mise_path, "rb") as fh: - data = tomllib.load(fh) - tasks = data.get("tasks", {}) - if "test" not in tasks: + if not mise.has_test_task(working_dir): raise SpawnError( - f".mise.toml in {working_dir} has no [tasks.test]: the verification gate " + f"mise config in {working_dir} has no [tasks.test]: the verification gate " "requires a canonical test task" ) @@ -77,8 +82,17 @@ def spawn( worktree_branch: str | None = None, task: str | None = None, role: str | None = None, + require_tests: bool = True, + mise_init: bool = False, ) -> dict: - """Create and launch an agent. Returns the agent row.""" + """Create and launch an agent. Returns the agent row. + + ``require_tests`` is the ``[tasks.test]`` gate; the mise-init bootstrap agent runs with + 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. + """ sync_note = None with connection() as conn: project = repo.get_project(conn, project_id) @@ -111,8 +125,10 @@ def spawn( # Hard gates before any state is written or process launched: the test task must # exist, and configured credentials must actually resolve — a broken pointer - # should fail fast, not leave an orphaned agent row behind. - require_test_task(working_dir) + # should fail fast, not leave an orphaned agent row behind. The mise-init agent + # skips the test-task gate (it's here to create that very task). + if require_tests: + require_test_task(working_dir) try: token = credentials.resolve_for_project(project, conn) except credentials.CredentialError as exc: @@ -137,6 +153,9 @@ def spawn( } if role: env["HANDLER_AGENT_ROLE"] = role + if mise_init: + # Read by the Stop / git-push hooks to enforce the bootstrap contract. + env["HANDLER_MISE_INIT"] = "1" # A short read connection lets credential/host resolution consult the forge_hosts # registry (falling back to the built-in host map when a host has no row). with connection() as conn: @@ -154,6 +173,11 @@ 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, 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) diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index 550bbf6..2adc1bd 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -20,7 +20,7 @@ from datetime import UTC, datetime, timedelta from ..db import repository as repo from ..db.engine import connection -from . import gitops, login, poller, reposync, skills_gen, spawn +from . import gitops, login, poller, reposync, skills_gen, spawn, tmux class CommandError(Exception): @@ -150,6 +150,50 @@ def _cmd_forge_init(command: dict) -> dict: return result +# The prompt the bootstrap agent starts with when an operator ticks "Initialize mise" on +# the add-repo step. It launches with the [tasks.test] gate off (there's no .mise.toml yet) +# and the HANDLER_MISE_INIT marker on, so its hooks enforce the "commit + push" contract. +_MISE_INIT_TASK = ( + "This repository has no mise tooling yet. Detect the project's stack by inspecting the " + "repo (e.g. package.json -> npm/pnpm/yarn, pyproject.toml or setup.py -> pytest, " + "Cargo.toml -> cargo, go.mod -> go, a Makefile -> make, a Gemfile -> bundler), then " + "write a `.mise.toml` at the repository root that pins the runtime under `[tools]` and " + "defines a canonical `[tasks.test]` task running that stack's test command (add `lint` " + "and `verify` tasks too when the stack has an obvious linter). Then commit the " + "`.mise.toml` and push it to the remote. Do not finish until the change is committed AND " + "pushed — the checkpoint gate will keep blocking otherwise." +) + + +def _cmd_mise_init(command: dict) -> dict: + """Bootstrap mise tooling: launch an agent that writes, commits, and pushes a + ``.mise.toml`` with a ``[tasks.test]`` task for the project's stack. + + Runs with ``require_tests=False`` (the project has no test task yet — creating one is + the point) and ``mise_init=True`` (its hooks enforce the commit + push contract). + """ + project_id = command.get("project_id") + if not project_id: + raise CommandError("mise_init requires project_id") + p = _payload(command) + name = command.get("agent_name") or p.get("name") or "mise-init" + try: + agent = spawn.spawn( + project_id, + name, + task=p.get("task") or _MISE_INIT_TASK, + require_tests=False, + mise_init=True, + ) + except spawn.SpawnError as exc: + raise CommandError(str(exc)) from exc + return { + "agent_id": agent["id"], + "name": agent["name"], + "working_dir": agent["working_dir"], + } + + def _cmd_poll_ci(command: dict) -> dict: return poller.sweep(project_id=command.get("project_id")) @@ -199,6 +243,7 @@ _DISPATCH = { "approve": _cmd_approve, "reject": _cmd_reject, "forge_init": _cmd_forge_init, + "mise_init": _cmd_mise_init, "poll_ci": _cmd_poll_ci, "sync": _cmd_sync, "login_start": _cmd_login_start, @@ -268,6 +313,43 @@ 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). @@ -290,15 +372,18 @@ def run( worker_id: str | None = None, poll_interval: float = 2.0, ci_interval: float = 30.0, + capture_interval: float = 2.0, iterations: int | None = None, ) -> None: - """The control-container main loop: drain the command queue + sweep CI periodically. + """The control-container main loop: drain the command queue, snapshot live agent + output, 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()}" last_ci = 0.0 + last_capture = 0.0 count = 0 while iterations is None or count < iterations: try: @@ -307,6 +392,12 @@ 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 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 450c9d5..c737be0 100644 --- a/src/handler/db/repository.py +++ b/src/handler/db/repository.py @@ -219,6 +219,23 @@ def set_agent_status(conn: Connection, agent_id: int, status: str) -> None: conn.execute(agents.update().where(agents.c.id == agent_id).values(status=status)) +def list_agents_by_status(conn: Connection, status: str) -> list[dict]: + """Every agent in a given status, across all projects (the worker's capture input).""" + rows = conn.execute( + select(agents).where(agents.c.status == status).order_by(agents.c.id) + ).all() + return [dict(r._mapping) for r in rows] + + +def update_agent_output(conn: Connection, agent_id: int, output: str) -> None: + """Store the latest tmux pane-tail snapshot for an agent (worker poll loop).""" + conn.execute( + agents.update() + .where(agents.c.id == agent_id) + .values(last_output=output, output_at=_now()) + ) + + def insert_log_entry(conn: Connection, agent_id: int, status: str, **fields: Any) -> int: values = {"agent_id": agent_id, "status": status, "created_at": _now(), **fields} result = conn.execute(log_entries.insert().values(**values)) @@ -337,12 +354,59 @@ def update_project(conn: Connection, project_id: str, **fields: Any) -> dict | N return get_project(conn, project_id) +def _purge_agent_dependents(conn: Connection, agent_ids: list[int]) -> None: + """Delete (or detach) everything that references the given agents, so the agent rows + themselves can be removed without tripping a foreign key. + + ``shared_context`` is a *global* table keyed by ``key`` — its rows outlive any one + agent, so we only null the ``set_by_agent_id`` attribution rather than delete them. + ``checkmarks`` must go before ``log_entries`` (it carries an FK to ``log_entries`` via + the ``use_alter`` cycle). Approvals authored by these agents go with them. + """ + if not agent_ids: + return + conn.execute( + shared_context.update() + .where(shared_context.c.set_by_agent_id.in_(agent_ids)) + .values(set_by_agent_id=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))) + + def delete_project(conn: Connection, project_id: str) -> bool: + """Remove a project and everything scoped to it. + + Every project accumulates FK-referencing rows (at minimum the ``sync`` command queued + at registration, plus each agent's log/checkmark history), so the bare project delete + would violate ``commands``/``agents``/``approvals``/``schedules`` foreign keys. Clear + the dependents in FK-safe order first: agent-owned rows, then ``schedules`` (which + reference ``commands`` via ``last_command_id``), then the remaining project-scoped + rows, then the agents, then the project itself. + """ + agent_ids = [ + row[0] + for row in conn.execute( + select(agents.c.id).where(agents.c.project_id == project_id) + ).all() + ] + _purge_agent_dependents(conn, agent_ids) + conn.execute(schedules.delete().where(schedules.c.project_id == project_id)) + conn.execute(approvals.delete().where(approvals.c.project_id == project_id)) + conn.execute(commands.delete().where(commands.c.project_id == project_id)) + conn.execute(agents.delete().where(agents.c.project_id == project_id)) result = conn.execute(projects.delete().where(projects.c.id == project_id)) return result.rowcount > 0 def delete_agent(conn: Connection, project_id: str, name: str) -> bool: + """Remove a single agent row, first clearing its log/checkmark/approval history so the + ``log_entries``/``checkmarks``/``approvals`` foreign keys don't block the delete.""" + agent = get_agent_by_name(conn, project_id, name) + if agent is None: + return False + _purge_agent_dependents(conn, [agent["id"]]) result = conn.execute( agents.delete().where(agents.c.project_id == project_id, agents.c.name == name) ) diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 68acdf6..3e62f04 100644 --- a/src/handler/db/tables.py +++ b/src/handler/db/tables.py @@ -46,6 +46,7 @@ COMMAND_TYPES = ( "approve", "reject", "forge_init", + "mise_init", "poll_ci", "sync", "login_start", @@ -83,6 +84,11 @@ agents = Table( # Optional workflow role (junior | senior | deploy) — informational, drives which # forge skill an agent follows; the approval gate keys on identity, not role. Column("role", String), + # 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. + Column("last_output", String), + Column("output_at", PortableTimestamp), 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"), diff --git a/src/handler/hooks/checkpoint.py b/src/handler/hooks/checkpoint.py index de2dd92..d36b7d4 100644 --- a/src/handler/hooks/checkpoint.py +++ b/src/handler/hooks/checkpoint.py @@ -13,12 +13,80 @@ from datetime import UTC, datetime from sqlalchemy import Connection +from ..control import gitops, mise from ..db import repository as repo from . import verify from .context import HookInput, Identity, emit +def _mise_init_blocker(working_dir: str) -> str | None: + """Why a mise-init agent may not finish yet — ``None`` once the contract is met. + + The bootstrap is complete only when ``.mise.toml`` defines ``[tasks.test]`` and that + file is committed (clean tree) and pushed (no commits ahead of, and an, upstream). + """ + if not mise.has_test_task(working_dir): + return "no mise config (mise.toml / .mise.toml) defines a [tasks.test] task yet" + if not gitops.is_clean(working_dir): + return "there are uncommitted changes — commit the .mise.toml" + ahead = gitops.ahead_count(working_dir) + if ahead is None: + return "the branch has no upstream yet — push it with `git push -u`" + if ahead > 0: + return f"{ahead} commit(s) have not been pushed to the remote" + return None + + +def handle_mise_init_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + """Stop gate for the mise-init bootstrap agent: block until the ``.mise.toml`` test + task exists and is committed + pushed (not the normal ``mise run test`` gate — there + may be no working suite yet, which is exactly what this agent is bootstrapping).""" + working_dir = ident.working_dir or hook_input.cwd or "." + now = datetime.now(UTC) + blocker = _mise_init_blocker(working_dir) + + status = "done" if blocker is None else "blocked" + summary = ( + "checkpoint: .mise.toml committed and pushed" + if blocker is None + else f"mise-init blocked: {blocker}" + ) + log_id = repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status=status, + session_id=hook_input.session_id, + summary=summary, + ) + repo.upsert_checkmark_row( + conn, + agent_id=ident.agent_id, + checkpoint_at=now, + status=status, + where_it_stopped=summary, + log_entry_id=log_id, + ) + repo.set_agent_status(conn, ident.agent_id, status) + + if blocker is not None: + # Same infinite-block guard as the test gate: if we already re-invoked once, + # record the state but let the turn end rather than looping forever. + if hook_input.stop_hook_active: + return {} + return { + "decision": "block", + "reason": ( + f"mise initialization is not complete: {blocker}. Write a `.mise.toml` with " + "a [tasks.test] task for this project's stack, commit it, and push it before " + "finishing." + ), + } + return {} + + def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dict: + if ident.mise_init: + return handle_mise_init_stop(conn, ident, hook_input) working_dir = ident.working_dir or hook_input.cwd or "." ok, output = verify.run_test(working_dir) now = datetime.now(UTC) diff --git a/src/handler/hooks/context.py b/src/handler/hooks/context.py index b805ab5..d0348c6 100644 --- a/src/handler/hooks/context.py +++ b/src/handler/hooks/context.py @@ -59,6 +59,10 @@ class Identity: project_id: str agent_name: str working_dir: str | None = None + # True for the mise-init bootstrap agent (env ``HANDLER_MISE_INIT``): its Stop and + # git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the + # normal test gate. + mise_init: bool = False extra: dict = field(default_factory=dict) @@ -74,10 +78,11 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None project_id = os.environ.get("HANDLER_PROJECT_ID") agent_name = os.environ.get("HANDLER_AGENT_NAME") + mise_init = bool(os.environ.get("HANDLER_MISE_INIT")) if agent_id and project_id and agent_name: row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first() working_dir = row._mapping["working_dir"] if row else None - return Identity(int(agent_id), project_id, agent_name, working_dir) + return Identity(int(agent_id), project_id, agent_name, working_dir, mise_init=mise_init) # Fallback: match by working_dir == cwd. if hook_input.cwd: diff --git a/src/handler/hooks/gate.py b/src/handler/hooks/gate.py index e7df86e..8c290de 100644 --- a/src/handler/hooks/gate.py +++ b/src/handler/hooks/gate.py @@ -96,6 +96,23 @@ def handle_git_push(conn: Connection, ident: Identity, hook_input: HookInput) -> working_dir = ident.working_dir or hook_input.cwd or "." now = datetime.now(UTC) + # The mise-init bootstrap agent is pushing the .mise.toml it just wrote; there is no + # working test task to gate on yet (creating it is the point), and it must reach the + # remote so the [tasks.test] gate is satisfied for the agents that follow. Record the + # push and let it through, skipping the test/build/approval gates. + if ident.mise_init: + sha = gitops.head_sha(working_dir) + if sha: + repo.insert_log_entry( + conn, + agent_id=ident.agent_id, + status="working", + session_id=hook_input.session_id, + summary=f"mise-init push: {sha[:12]}", + push_sha=sha, + ) + return _allow("mise initialization push (test gate bypassed)") + # Cheap check first: tests. Only on success do we pay for the image build. tests_ok, tests_out = verify.run_test(working_dir) if not tests_ok: diff --git a/src/handler/migrations/versions/0006_mise_init_command.py b/src/handler/migrations/versions/0006_mise_init_command.py new file mode 100644 index 0000000..dbf279d --- /dev/null +++ b/src/handler/migrations/versions/0006_mise_init_command.py @@ -0,0 +1,44 @@ +"""mise-init command type + +Revision ID: 0006_mise_init_command +Revises: 0005_claude_login_commands +Create Date: 2026-07-16 + +Adds the ``mise_init`` command type so the dashboard's "Initialize mise" option on the +add-repo step can enqueue a bootstrap agent that writes a ``.mise.toml`` with a canonical +``[tasks.test]`` task for the project's stack, then commits and pushes it. The commands +CHECK constraint change goes through ``batch_alter_table`` so SQLite recreates the table +while Postgres alters in place (same pattern as 0004's ``sync`` and 0005's login types). +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0006_mise_init_command" +down_revision: str | None = "0005_claude_login_commands" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +OLD_COMMAND_TYPES = ( + "'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'poll_ci', 'sync', " + "'login_start', 'login_submit'" +) +NEW_COMMAND_TYPES = ( + "'spawn', 'kill', 'resume', 'approve', 'reject', 'forge_init', 'mise_init', 'poll_ci', " + "'sync', 'login_start', 'login_submit'" +) + + +def upgrade() -> None: + with op.batch_alter_table("commands", schema=None) as batch_op: + batch_op.drop_constraint("ck_commands_type", type_="check") + batch_op.create_check_constraint("ck_commands_type", f"type IN ({NEW_COMMAND_TYPES})") + + +def downgrade() -> None: + with op.batch_alter_table("commands", schema=None) as batch_op: + batch_op.drop_constraint("ck_commands_type", type_="check") + batch_op.create_check_constraint("ck_commands_type", f"type IN ({OLD_COMMAND_TYPES})") diff --git a/src/handler/migrations/versions/0007_agent_pane_output.py b/src/handler/migrations/versions/0007_agent_pane_output.py new file mode 100644 index 0000000..9fbc5bb --- /dev/null +++ b/src/handler/migrations/versions/0007_agent_pane_output.py @@ -0,0 +1,36 @@ +"""agent live-output snapshot + +Revision ID: 0007_agent_pane_output +Revises: 0006_mise_init_command +Create Date: 2026-07-16 + +Adds ``last_output`` (text) and ``output_at`` (timestamp) to ``agents``. The control +worker snapshots each working agent's tmux pane tail into these columns on its poll loop, +so the API/UI can show what a live — or wedged — agent is actually doing (the tmux socket +lives only in the control container, so the DB is the one channel the API can read). Both +are nullable and back-fill lazily on the next poll, so the migration needs no data step. +""" + +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 = "0007_agent_pane_output" +down_revision: str | None = "0006_mise_init_command" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("agents", sa.Column("last_output", sa.String())) + op.add_column("agents", sa.Column("output_at", PortableTimestamp)) + + +def downgrade() -> None: + op.drop_column("agents", "output_at") + op.drop_column("agents", "last_output") diff --git a/tests/conftest.py b/tests/conftest.py index bf3fdb8..5adb9d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,9 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("SHARED_CONTEXT_WRITE_TOKEN", "shared-token") monkeypatch.setenv("PROJECTS_ROOT", str(tmp_path / "projects")) monkeypatch.delenv("WEBHOOK_URL", raising=False) + # Isolate any home-dir writes (e.g. control.claude_config seeding ~/.claude.json at + # spawn) to the per-test tmp dir, so tests never touch the real user's config. + monkeypatch.setenv("HOME", str(tmp_path)) _reset_caches() cfg = Config(str(REPO_ROOT / "alembic.ini")) diff --git a/tests/test_api_projects_agents.py b/tests/test_api_projects_agents.py index 8abf6ef..e73965d 100644 --- a/tests/test_api_projects_agents.py +++ b/tests/test_api_projects_agents.py @@ -32,6 +32,24 @@ def test_create_and_list_agent(client, auth): assert [a["name"] for a in agents] == ["api"] +def test_agent_serializes_live_output_snapshot(client, auth, engine): + from handler.db import repository as repo + + _mk_project(client, auth) + client.post( + "/projects/proj/agents", + json={"name": "api", "working_dir": "/tmp/proj/api", "status": "working"}, + headers=auth, + ) + with engine.begin() as conn: + agent = repo.get_agent_by_name(conn, "proj", "api") + repo.update_agent_output(conn, agent["id"], "boot\nTheme picker") + + listed = client.get("/projects/proj/agents", headers=auth).json()[0] + assert listed["last_output"] == "boot\nTheme picker" + assert listed["output_at"] is not None + + def test_agent_under_missing_project_is_404(client, auth): r = client.get("/projects/ghost/agents", headers=auth) assert r.status_code == 404 diff --git a/tests/test_claude_config.py b/tests/test_claude_config.py new file mode 100644 index 0000000..84efa8d --- /dev/null +++ b/tests/test_claude_config.py @@ -0,0 +1,47 @@ +"""Seeding Claude Code onboarding so a detached agent boots straight to the REPL.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from handler.control import claude_config + + +def _read(home: Path) -> dict: + return json.loads((home / ".claude.json").read_text()) + + +def test_ensure_onboarded_seeds_defaults_on_fresh_home(tmp_path): + claude_config.ensure_onboarded(home=str(tmp_path)) + data = _read(tmp_path) + assert data["hasCompletedOnboarding"] is True + assert data["theme"] == "dark" + + +def test_ensure_onboarded_merges_without_clobbering(tmp_path): + # The login flow's oauthAccount (and a chosen theme) must survive the seeding. + (tmp_path / ".claude.json").write_text( + json.dumps({"oauthAccount": {"emailAddress": "a@b.c"}, "theme": "light"}) + ) + claude_config.ensure_onboarded(home=str(tmp_path)) + data = _read(tmp_path) + assert data["oauthAccount"] == {"emailAddress": "a@b.c"} # preserved + assert data["theme"] == "light" # operator's chosen theme untouched + assert data["hasCompletedOnboarding"] is True + + +def test_ensure_onboarded_forces_flag_and_trusts_working_dir(tmp_path): + # A stale false must not leave onboarding armed; the working dir gets trusted. + (tmp_path / ".claude.json").write_text(json.dumps({"hasCompletedOnboarding": False})) + wd = "/var/lib/handler/projects/x" + claude_config.ensure_onboarded(working_dir=wd, home=str(tmp_path)) + data = _read(tmp_path) + assert data["hasCompletedOnboarding"] is True + assert data["projects"][wd]["hasTrustDialogAccepted"] is True + + +def test_ensure_onboarded_survives_corrupt_file(tmp_path): + (tmp_path / ".claude.json").write_text("{ not valid json") + claude_config.ensure_onboarded(home=str(tmp_path)) + assert _read(tmp_path)["hasCompletedOnboarding"] is True diff --git a/tests/test_control_spawn.py b/tests/test_control_spawn.py index 4a01739..fb34d92 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -37,10 +37,22 @@ def test_spawn_refuses_without_mise_file(env, fake_tmux): root = env["tmp"] / "proj" root.mkdir(parents=True, exist_ok=True) _register_project(root) - with pytest.raises(spawn.SpawnError, match="no .mise.toml"): + with pytest.raises(spawn.SpawnError, match="no mise config"): spawn.spawn("proj", "api") +def test_spawn_accepts_dotless_mise_toml(env, fake_tmux): + # 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") + 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): root = env["tmp"] / "proj" _write_mise(root, with_test=True) @@ -68,6 +80,32 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux): assert call["env"]["DATABASE_URL"] == env["url"] +def test_spawn_mise_init_skips_test_gate_and_marks_env(env, fake_tmux): + # 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) + + 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" + + +def test_spawn_still_gates_without_mise_init_flag(env, fake_tmux): + 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"] == [] + + def test_kill_sets_done_and_kills_session(env, fake_tmux): root = env["tmp"] / "proj" _write_mise(root, with_test=True) diff --git a/tests/test_hook_checkpoint.py b/tests/test_hook_checkpoint.py index 2cc13b2..762147a 100644 --- a/tests/test_hook_checkpoint.py +++ b/tests/test_hook_checkpoint.py @@ -2,15 +2,22 @@ from __future__ import annotations +from handler.control import gitops, mise from handler.db import repository as repo from handler.hooks import checkpoint, verify from handler.hooks.context import HookInput, Identity -def _seed(conn): +def _seed(conn, mise_init=False): repo.create_project(conn, "p", "/tmp/p") a = repo.create_agent(conn, "p", "a", "/tmp/p/a") - return Identity(a["id"], "p", "a", "/tmp/p/a") + return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init) + + +def _fake_mise_state(monkeypatch, *, has_test, clean, ahead): + monkeypatch.setattr(mise, "has_test_task", lambda cwd: has_test) + monkeypatch.setattr(gitops, "is_clean", lambda cwd: clean) + monkeypatch.setattr(gitops, "ahead_count", lambda cwd: ahead) def test_stop_blocks_on_failing_tests(conn, monkeypatch): @@ -50,6 +57,62 @@ def test_stop_does_not_reblock_when_already_active(conn, monkeypatch): assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail" +def test_mise_init_stop_blocks_when_no_test_task(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + # The normal test gate must NOT run for a mise-init agent. + monkeypatch.setattr( + verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("test gate ran")) + ) + _fake_mise_state(monkeypatch, has_test=False, clean=True, ahead=0) + + result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) + assert result["decision"] == "block" + assert "[tasks.test]" in result["reason"] + assert repo.get_agent_by_name(conn, "p", "a")["status"] == "blocked" + + +def test_mise_init_stop_blocks_on_uncommitted_changes(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + _fake_mise_state(monkeypatch, has_test=True, clean=False, ahead=0) + result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) + assert result["decision"] == "block" + assert "uncommitted" in result["reason"] + + +def test_mise_init_stop_blocks_when_no_upstream(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + _fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=None) + result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) + assert result["decision"] == "block" + assert "upstream" in result["reason"] + + +def test_mise_init_stop_blocks_on_unpushed_commits(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + _fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=2) + result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) + assert result["decision"] == "block" + assert "not been pushed" in result["reason"] + + +def test_mise_init_stop_allows_when_committed_and_pushed(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + _fake_mise_state(monkeypatch, has_test=True, clean=True, ahead=0) + result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) + assert result == {} # contract met — the turn may end + cm = repo.get_checkmark(conn, ident.agent_id) + assert cm["status"] == "done" + assert repo.get_agent_by_name(conn, "p", "a")["status"] == "done" + + +def test_mise_init_stop_does_not_reblock_when_already_active(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + _fake_mise_state(monkeypatch, has_test=False, clean=True, ahead=0) + hi = HookInput({"session_id": "s1", "stop_hook_active": True}, "stop") + result = checkpoint.handle_stop(conn, ident, hi) + assert result == {} # recorded, but not an infinite block + + def test_session_end_records_without_gate(conn, monkeypatch): ident = _seed(conn) # Even if tests would fail, SessionEnd must not run the gate or block. diff --git a/tests/test_hook_gate.py b/tests/test_hook_gate.py index a0d76bc..9912dd5 100644 --- a/tests/test_hook_gate.py +++ b/tests/test_hook_gate.py @@ -2,15 +2,16 @@ from __future__ import annotations +from handler.control import gitops from handler.db import repository as repo from handler.hooks import gate, verify from handler.hooks.context import HookInput, Identity -def _seed(conn): +def _seed(conn, mise_init=False): repo.create_project(conn, "p", "/tmp/p") a = repo.create_agent(conn, "p", "a", "/tmp/p/a") - return Identity(a["id"], "p", "a", "/tmp/p/a") + return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init) def _decision(result): @@ -73,6 +74,26 @@ def test_git_push_allowed_when_both_pass(conn, monkeypatch): assert _decision(result) == "allow" +def test_mise_init_push_bypasses_test_gate(conn, monkeypatch): + ident = _seed(conn, mise_init=True) + # The mise-init agent pushes the .mise.toml it just wrote; the test/build gate must + # not run (there may be no working suite yet), and the push is recorded + allowed. + monkeypatch.setattr( + verify, "run_test", lambda cwd: (_ for _ in ()).throw(AssertionError("test gate ran")) + ) + monkeypatch.setattr(gitops, "head_sha", lambda cwd: "sha123456789") + hi = HookInput( + {"tool_name": "Bash", "tool_input": {"command": "git push -u origin main"}, + "session_id": "s1"}, + "pre_tool_use", + ) + result = gate.handle_git_push(conn, ident, hi) + assert _decision(result) == "allow" + # The push is recorded in the log so the run shows it landed. + entries = repo.get_log(conn, ident.agent_id, limit=10, offset=0) + assert any(e["push_sha"] == "sha123456789" for e in entries) + + def test_non_push_bash_is_ignored(conn): ident = _seed(conn) hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use") diff --git a/tests/test_mise.py b/tests/test_mise.py new file mode 100644 index 0000000..385f72e --- /dev/null +++ b/tests/test_mise.py @@ -0,0 +1,40 @@ +"""The mise-config gate: which filenames count, and the [tasks.test] check.""" + +from __future__ import annotations + +from handler.control import mise + + +def test_no_config_is_no_config(tmp_path): + assert mise.has_config(str(tmp_path)) is False + assert mise.has_test_task(str(tmp_path)) is False + + +def test_dotless_mise_toml_is_accepted(tmp_path): + (tmp_path / "mise.toml").write_text("[tasks.test]\nrun = 'pytest'\n") + assert mise.has_config(str(tmp_path)) is True + assert mise.has_test_task(str(tmp_path)) is True + + +def test_dotted_mise_toml_is_accepted(tmp_path): + (tmp_path / ".mise.toml").write_text("[tasks.test]\nrun = 'go test ./...'\n") + assert mise.has_test_task(str(tmp_path)) is True + + +def test_config_under_dot_config_dir(tmp_path): + cfg = tmp_path / ".config" / "mise" + cfg.mkdir(parents=True) + (cfg / "config.toml").write_text("[tasks.test]\nrun = 'pytest'\n") + assert mise.has_test_task(str(tmp_path)) is True + + +def test_config_without_test_task_fails_the_gate(tmp_path): + (tmp_path / "mise.toml").write_text("[tasks.lint]\nrun = 'ruff check .'\n") + assert mise.has_config(str(tmp_path)) is True + assert mise.has_test_task(str(tmp_path)) is False + + +def test_corrupt_config_is_not_a_test_task(tmp_path): + (tmp_path / "mise.toml").write_text("this = = not valid toml") + assert mise.has_config(str(tmp_path)) is True + assert mise.has_test_task(str(tmp_path)) is False diff --git a/tests/test_project_from_server.py b/tests/test_project_from_server.py index b00ade7..312d11b 100644 --- a/tests/test_project_from_server.py +++ b/tests/test_project_from_server.py @@ -50,6 +50,46 @@ def test_create_project_from_server_with_ssh_key(client, auth, env, secret_key): assert cmd["project_id"] == "coolproj" +def test_create_project_with_init_mise_enqueues_bootstrap(client, auth, env, secret_key): + _add_server(client, auth, generate_ssh_key=True) + r = client.post( + "/projects", + json={"git_server": "github.com", "repo": "me/coolproj", "init_mise": True}, + headers=auth, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["sync_command_id"] is not None + assert body["mise_init_command_id"] is not None + # The clone is queued before the bootstrap so it runs first (FIFO by id). + assert body["mise_init_command_id"] > body["sync_command_id"] + cmd = client.get(f"/commands/{body['mise_init_command_id']}", headers=auth).json() + assert cmd["type"] == "mise_init" + assert cmd["project_id"] == "coolproj" + + +def test_create_project_without_init_mise_skips_bootstrap(client, auth, env, secret_key): + _add_server(client, auth, generate_ssh_key=True) + r = client.post( + "/projects", json={"git_server": "github.com", "repo": "me/plain"}, headers=auth + ) + assert r.status_code == 201, r.text + assert r.json()["mise_init_command_id"] is None + + +def test_init_mise_without_remote_does_not_enqueue(client, auth, env, tmp_path): + # Manual mode with no git_remote: nothing to push to, so no bootstrap is queued. + root = tmp_path / "local" + root.mkdir() + r = client.post( + "/projects", + json={"id": "local", "root_dir": str(root), "init_mise": True}, + headers=auth, + ) + assert r.status_code == 201, r.text + assert r.json()["mise_init_command_id"] is None + + def test_create_project_from_server_https_without_key(client, auth, env): _add_server(client, auth, hostname="git.corp", forge_type="gitea", base_url="https://git.corp:8443") diff --git a/tests/test_repository_web.py b/tests/test_repository_web.py index 0d6255b..19661dc 100644 --- a/tests/test_repository_web.py +++ b/tests/test_repository_web.py @@ -24,6 +24,51 @@ def test_delete_agent_row(conn): assert repo.get_agent_by_name(conn, "p", "api") is None +def test_delete_project_cascades_all_dependents(conn): + """A real project always has FK-referencing rows (the sync command, agent history, + approvals, schedules). Deleting it must clear them, not raise a ForeignKeyViolation.""" + from datetime import UTC, datetime + + repo.create_project(conn, "p", "/tmp/p", git_remote="https://github.com/me/p.git") + # The sync command queued at registration — the exact row that blocked the delete. + repo.enqueue_command(conn, "sync", project_id="p", requested_by="operator:web") + agent = repo.create_agent(conn, "p", "api", "/tmp/p/api") + repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="did work") + repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working") + repo.set_shared_context(conn, "db", "postgres", agent["id"]) + repo.record_approval(conn, "p", "feat/x", "approved", approved_by_agent_id=agent["id"]) + repo.create_schedule( + conn, "p", "nightly", "run the thing", 3600, datetime.now(UTC) + ) + + assert repo.delete_project(conn, "p") is True + + # Project and everything scoped to it are gone. + assert repo.get_project(conn, "p") is None + assert repo.get_agent_by_name(conn, "p", "api") is None + assert repo.list_commands(conn, project_id="p") == [] + assert repo.list_approvals(conn, "p") == [] + assert repo.list_schedules(conn) == [] + # The global shared-context row survives, with its agent attribution cleared. + ctx = repo.get_shared_context_key(conn, "db") + assert ctx is not None and ctx["value"] == "postgres" + assert ctx["set_by_agent_id"] is None + + +def test_delete_agent_cascades_log_and_checkmark(conn): + """Every spawned agent accrues a checkmark + log entries via the hooks; removing the + agent must clear them rather than trip the log_entries/checkmarks foreign keys.""" + repo.create_project(conn, "p", "/tmp/p") + agent = repo.create_agent(conn, "p", "api", "/tmp/p/api") + repo.insert_log_entry(conn, agent_id=agent["id"], status="working", summary="x") + repo.upsert_checkmark_row(conn, agent_id=agent["id"], status="working") + + assert repo.delete_agent(conn, "p", "api") is True + assert repo.get_agent_by_name(conn, "p", "api") is None + assert repo.get_checkmark(conn, agent["id"]) is None + assert repo.get_log(conn, agent["id"]) == [] + + def test_enqueue_get_and_list_command(conn): repo.create_project(conn, "p", "/tmp/p") cmd = repo.enqueue_command( diff --git a/tests/test_worker.py b/tests/test_worker.py index 8323335..bdbd7e6 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -95,6 +95,53 @@ def test_approve_command_records_operator_verdict_with_head_sha(env, fake_gitops assert ap["approved_sha"] == fake_gitops["sha"] # read from the agent's working dir +def test_mise_init_command_spawns_bootstrap_agent(env, monkeypatch): + _seed_project() + calls = {} + + def fake_spawn(project_id, name, **kw): + calls.update(project_id=project_id, name=name, **kw) + return {"id": 7, "name": name, "working_dir": "/tmp/p/mise-init"} + + monkeypatch.setattr(spawn, "spawn", fake_spawn) + cmd = _enqueue(type="mise_init", project_id="p") + + assert worker.drain("w") == 1 + done = _get(cmd["id"]) + assert done["status"] == "done" + assert done["result"]["agent_id"] == 7 + assert done["result"]["name"] == "mise-init" + # Launched with the test gate off and the bootstrap marker on, with the default task. + assert calls["require_tests"] is False + assert calls["mise_init"] is True + assert ".mise.toml" in calls["task"] + + +def test_mise_init_command_honors_payload_overrides(env, monkeypatch): + _seed_project() + calls = {} + + def fake_spawn(project_id, name, **kw): + calls.update(name=name, **kw) + return {"id": 8, "name": name, "working_dir": "/tmp/p/x"} + + monkeypatch.setattr(spawn, "spawn", fake_spawn) + cmd = _enqueue( + type="mise_init", project_id="p", payload={"name": "boot", "task": "custom task"} + ) + + worker.drain("w") + assert _get(cmd["id"])["status"] == "done" + assert calls["name"] == "boot" + assert calls["task"] == "custom task" + + +def test_mise_init_without_project_is_failed(env): + cmd = _enqueue(type="mise_init") + assert worker.drain("w") == 1 + assert _get(cmd["id"])["status"] == "failed" + + def test_poll_ci_command_returns_summary(env, monkeypatch): _seed_project() summary = {"checked": 0, "resolved": 0, "pending": 0} @@ -164,6 +211,45 @@ 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})