From 072f63bf2d5f68662cd2f4dfb7d257b7ced4abbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:51:24 +0000 Subject: [PATCH] feat(repos): add an "Initialize mise" option to the add-repo step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some repos an operator wants to manage don't yet define the `.mise.toml` `[tasks.test]` task the spawn gate hard-requires — a chicken-and-egg, since you can't run an agent to author that file without it. This adds a one-click bootstrap. Ticking "Initialize mise" on the add step enqueues a `mise_init` command after the clone. The worker launches a dedicated agent that detects the repo's stack, writes a `.mise.toml` with a canonical `[tasks.test]` task, and commits + pushes it. That agent runs with the test-task gate off (creating the task is the point) and a `HANDLER_MISE_INIT` marker on, so its hooks enforce a bootstrap contract instead of the normal test gate: - Stop hook blocks the turn until `.mise.toml` defines `[tasks.test]` and the change is committed (clean tree) and pushed (no commits ahead of an upstream) — so claude cannot end before the work has actually landed. - git-push hook lets the bootstrap push through, skipping the test/build gate (there may be no working suite yet) so the file reaches the remote. Backend: `mise_init` command type (+ migration 0006), a shared `control.mise` helper for the test-task check, `spawn(require_tests=, mise_init=)`, gitops `is_clean`/`ahead_count`, and `init_mise` on the project-create API (only acts when a git remote exists to push to). Frontend: the checkbox, plumbed through the store, following the launch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ --- .../sections/RepositoriesSection.tsx | 23 +++++++ frontend/components/store.tsx | 34 ++++++++++ frontend/lib/api.ts | 3 + src/handler/api/routes/projects.py | 17 ++++- src/handler/api/schemas.py | 8 ++- src/handler/api/static/404.html | 2 +- .../chunks/app/page-4aea2dcf515b45ea.js | 1 + .../chunks/app/page-5b19e394a5d5460f.js | 1 - .../_buildManifest.js | 0 .../_ssgManifest.js | 0 src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 4 +- src/handler/control/gitops.py | 21 ++++++ src/handler/control/mise.py | 29 ++++++++ src/handler/control/spawn.py | 31 ++++++--- src/handler/control/worker.py | 45 ++++++++++++ src/handler/db/tables.py | 1 + src/handler/hooks/checkpoint.py | 68 +++++++++++++++++++ src/handler/hooks/context.py | 7 +- src/handler/hooks/gate.py | 17 +++++ .../versions/0006_mise_init_command.py | 44 ++++++++++++ tests/test_control_spawn.py | 26 +++++++ tests/test_hook_checkpoint.py | 67 +++++++++++++++++- tests/test_hook_gate.py | 25 ++++++- tests/test_project_from_server.py | 40 +++++++++++ tests/test_worker.py | 47 +++++++++++++ 26 files changed, 540 insertions(+), 23 deletions(-) create mode 100644 src/handler/api/static/_next/static/chunks/app/page-4aea2dcf515b45ea.js delete mode 100644 src/handler/api/static/_next/static/chunks/app/page-5b19e394a5d5460f.js rename src/handler/api/static/_next/static/{UFaQ4h6X_9jNfr8PwK2IS => j4wMy4OdPdBURLtWQmeHO}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{UFaQ4h6X_9jNfr8PwK2IS => j4wMy4OdPdBURLtWQmeHO}/_ssgManifest.js (100%) create mode 100644 src/handler/control/mise.py create mode 100644 src/handler/migrations/versions/0006_mise_init_command.py 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..586a822 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-4aea2dcf515b45ea.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:["j4wMy4OdPdBURLtWQmeHO",[[["",{"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/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..6172e51 --- /dev/null +++ b/src/handler/control/mise.py @@ -0,0 +1,29 @@ +"""The ``.mise.toml`` 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). +""" + +from __future__ import annotations + +import os +import tomllib + + +def mise_path(working_dir: str) -> str: + return os.path.join(working_dir, ".mise.toml") + + +def has_test_task(working_dir: str) -> bool: + """True when ``.mise.toml`` exists and defines a ``[tasks.test]`` task.""" + path = mise_path(working_dir) + if not os.path.exists(path): + return False + try: + with open(path, "rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError): + return False + return "test" in (data.get("tasks") or {}) diff --git a/src/handler/control/spawn.py b/src/handler/control/spawn.py index 69c8c91..2bfe584 100644 --- a/src/handler/control/spawn.py +++ b/src/handler/control/spawn.py @@ -10,12 +10,11 @@ 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 credentials, forge, gitops, mise, reposync, settings_gen, tmux, worktree class SpawnError(Exception): @@ -24,16 +23,12 @@ 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): + if not os.path.exists(mise.mise_path(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" ) - 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 " "requires a canonical test task" @@ -77,8 +72,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 +115,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 +143,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: diff --git a/src/handler/control/worker.py b/src/handler/control/worker.py index 550bbf6..9551357 100644 --- a/src/handler/control/worker.py +++ b/src/handler/control/worker.py @@ -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, diff --git a/src/handler/db/tables.py b/src/handler/db/tables.py index 68acdf6..193ffeb 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", diff --git a/src/handler/hooks/checkpoint.py b/src/handler/hooks/checkpoint.py index de2dd92..ee9986e 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 "`.mise.toml` does not yet define a [tasks.test] task" + 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/tests/test_control_spawn.py b/tests/test_control_spawn.py index 4a01739..faef3c1 100644 --- a/tests/test_control_spawn.py +++ b/tests/test_control_spawn.py @@ -68,6 +68,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.toml"): + 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_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_worker.py b/tests/test_worker.py index 8323335..c26a436 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}