mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 06:06:24 +00:00
feat(repos): add an "Initialize mise" option to the add-repo step
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BxKY28XKCM6o4ag3nmaVsZ
This commit is contained in:
@@ -22,6 +22,7 @@ const empty: NewProjectBody = {
|
|||||||
root_dir: "",
|
root_dir: "",
|
||||||
git_remote: "",
|
git_remote: "",
|
||||||
credential_ref: "",
|
credential_ref: "",
|
||||||
|
init_mise: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RepositoriesSection() {
|
export function RepositoriesSection() {
|
||||||
@@ -163,6 +164,28 @@ export function RepositoriesSection() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!editing && (
|
||||||
|
<label className="hstack" style={{ gap: 8, cursor: "pointer", marginTop: 14 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.init_mise}
|
||||||
|
onChange={(e) => setForm({ ...form, init_mise: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: "var(--text-sm)" }}>
|
||||||
|
Initialize mise — after the clone, run an agent that writes a{" "}
|
||||||
|
<span className="mono">.mise.toml</span> with a{" "}
|
||||||
|
<span className="mono">[tasks.test]</span> task for this repo’s stack, then
|
||||||
|
commits and pushes it. Needed for repos that don’t define one yet.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!editing && form.init_mise && form.mode === "manual" && !form.git_remote.trim() && (
|
||||||
|
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "6px 0 0" }}>
|
||||||
|
A git remote is required to push the new <span className="mono">.mise.toml</span> —
|
||||||
|
add one above, or mise won’t be initialized.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="hstack mt14">
|
<div className="hstack mt14">
|
||||||
<Button variant="primary" disabled={s.cmd.busy || !canSave} onClick={save}>
|
<Button variant="primary" disabled={s.cmd.busy || !canSave} onClick={save}>
|
||||||
{editing ? "Save changes" : form.mode === "server" ? "Add & pull" : "Register"}
|
{editing ? "Save changes" : form.mode === "server" ? "Add & pull" : "Register"}
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ export interface NewProjectBody {
|
|||||||
root_dir: string;
|
root_dir: string;
|
||||||
git_remote: string;
|
git_remote: string;
|
||||||
credential_ref: string;
|
credential_ref: string;
|
||||||
|
/* Bootstrap tooling: after the clone, run an agent that writes + commits + pushes a
|
||||||
|
* .mise.toml with a [tasks.test] task for the repo's stack. Needs a git remote. */
|
||||||
|
init_mise: boolean;
|
||||||
}
|
}
|
||||||
export interface ScheduleBody {
|
export interface ScheduleBody {
|
||||||
name_prefix: string;
|
name_prefix: string;
|
||||||
@@ -535,12 +538,14 @@ export function DashboardProvider({
|
|||||||
repo: b.repo.trim(),
|
repo: b.repo.trim(),
|
||||||
id: b.id.trim() || null,
|
id: b.id.trim() || null,
|
||||||
credential_ref: b.credential_ref.trim() || null,
|
credential_ref: b.credential_ref.trim() || null,
|
||||||
|
init_mise: b.init_mise,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: b.id.trim(),
|
id: b.id.trim(),
|
||||||
root_dir: b.root_dir.trim(),
|
root_dir: b.root_dir.trim(),
|
||||||
git_remote: b.git_remote.trim() || null,
|
git_remote: b.git_remote.trim() || null,
|
||||||
credential_ref: b.credential_ref.trim() || null,
|
credential_ref: b.credential_ref.trim() || null,
|
||||||
|
init_mise: b.init_mise,
|
||||||
};
|
};
|
||||||
const created = await clientRef.current.api<Project>("/projects", {
|
const created = await clientRef.current.api<Project>("/projects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -573,6 +578,35 @@ export function DashboardProvider({
|
|||||||
} else {
|
} else {
|
||||||
setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false });
|
setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false });
|
||||||
}
|
}
|
||||||
|
// "Initialize mise": follow the bootstrap agent's launch so the operator knows a
|
||||||
|
// .mise.toml is being written, committed, and pushed for them.
|
||||||
|
if (created.mise_init_command_id != null) {
|
||||||
|
setCmd({
|
||||||
|
text: `repository '${created.id}': launching a mise-init agent to create .mise.toml…`,
|
||||||
|
error: false,
|
||||||
|
busy: true,
|
||||||
|
});
|
||||||
|
const mise = await clientRef.current.trackCommand(created.mise_init_command_id);
|
||||||
|
if (!mise) {
|
||||||
|
setCmd({
|
||||||
|
text: `repository '${created.id}' registered; mise-init still starting (see Activity). Is the worker up?`,
|
||||||
|
error: false,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
} else if (mise.status === "done") {
|
||||||
|
setCmd({
|
||||||
|
text: `repository '${created.id}' registered; a mise-init agent is now writing, committing, and pushing .mise.toml (watch it in Runs).`,
|
||||||
|
error: false,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCmd({
|
||||||
|
text: `repository '${created.id}' registered but the mise-init agent failed to launch — ${mise.error ?? ""}`,
|
||||||
|
error: true,
|
||||||
|
busy: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof AuthError) return false;
|
if (e instanceof AuthError) return false;
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface Project {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
/* Present on the registration response in git-server mode: the enqueued clone. */
|
/* Present on the registration response in git-server mode: the enqueued clone. */
|
||||||
sync_command_id?: number | null;
|
sync_command_id?: number | null;
|
||||||
|
/* Present on the registration response when "Initialize mise" was ticked: the
|
||||||
|
* enqueued bootstrap agent that writes + commits + pushes a .mise.toml. */
|
||||||
|
mise_init_command_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
|
|||||||
@@ -100,7 +100,22 @@ def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict
|
|||||||
conn, "sync", project_id=project_id, requested_by="operator:web"
|
conn, "sync", project_id=project_id, requested_by="operator:web"
|
||||||
)
|
)
|
||||||
sync_command_id = command["id"]
|
sync_command_id = command["id"]
|
||||||
return {**project, "sync_command_id": sync_command_id}
|
|
||||||
|
# "Initialize mise": queue a bootstrap agent *after* the clone (FIFO by id, so the
|
||||||
|
# sync runs first) to author a .mise.toml with a [tasks.test] task for the repo's
|
||||||
|
# stack and commit + push it. It needs a remote to push, so skip when there is none.
|
||||||
|
mise_init_command_id = None
|
||||||
|
if body.init_mise and git_remote:
|
||||||
|
mise_command = repo.enqueue_command(
|
||||||
|
conn, "mise_init", project_id=project_id, requested_by="operator:web"
|
||||||
|
)
|
||||||
|
mise_init_command_id = mise_command["id"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
**project,
|
||||||
|
"sync_command_id": sync_command_id,
|
||||||
|
"mise_init_command_id": mise_init_command_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)])
|
@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)])
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ class ProjectIn(BaseModel):
|
|||||||
credential_ref: str | None = None
|
credential_ref: str | None = None
|
||||||
git_server: str | None = None
|
git_server: str | None = None
|
||||||
repo: str | None = None
|
repo: str | None = None
|
||||||
|
# Bootstrap tooling: enqueue a mise-init agent after the clone that writes a
|
||||||
|
# ``.mise.toml`` with a ``[tasks.test]`` task for the repo's stack, then commits + pushes
|
||||||
|
# it. Only acts when the project has a git_remote (the agent must push its work).
|
||||||
|
init_mise: bool = False
|
||||||
|
|
||||||
@field_validator("credential_ref")
|
@field_validator("credential_ref")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -101,9 +105,11 @@ class ProjectOut(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ProjectCreatedOut(ProjectOut):
|
class ProjectCreatedOut(ProjectOut):
|
||||||
"""Registration response; carries the enqueued clone command in git-server mode."""
|
"""Registration response; carries the enqueued clone command in git-server mode, and
|
||||||
|
the mise-init bootstrap command when ``init_mise`` was requested."""
|
||||||
|
|
||||||
sync_command_id: int | None = None
|
sync_command_id: int | None = None
|
||||||
|
mise_init_command_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentIn(BaseModel):
|
class AgentIn(BaseModel):
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-5b19e394a5d5460f.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-5b19e394a5d5460f.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"UFaQ4h6X_9jNfr8PwK2IS\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],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\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",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.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"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\"}]]\n3:null\n"])</script></body></html>
|
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-4aea2dcf515b45ea.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[9859,[\"931\",\"static/chunks/app/page-4aea2dcf515b45ea.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"j4wMy4OdPdBURLtWQmeHO\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],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\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",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.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"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\"}]]\n3:null\n"])</script></body></html>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
2:I[9107,[],"ClientPageRoot"]
|
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,[],""]
|
4:I[4707,[],""]
|
||||||
5:I[6423,[],""]
|
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"}]]
|
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
|
1:null
|
||||||
|
|||||||
@@ -61,6 +61,27 @@ def config_local(cwd: str, key: str, value: str) -> tuple[bool, str]:
|
|||||||
return _run(["config", "--local", key, value], cwd)
|
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]:
|
def add(cwd: str, paths: list[str]) -> tuple[bool, str]:
|
||||||
return _run(["add", *paths], cwd)
|
return _run(["add", *paths], cwd)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {})
|
||||||
@@ -10,12 +10,11 @@ process launched, so a project without a canonical test task never gets an agent
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tomllib
|
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
from ..db import repository as repo
|
from ..db import repository as repo
|
||||||
from ..db.engine import connection
|
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):
|
class SpawnError(Exception):
|
||||||
@@ -24,16 +23,12 @@ class SpawnError(Exception):
|
|||||||
|
|
||||||
def require_test_task(working_dir: str) -> None:
|
def require_test_task(working_dir: str) -> None:
|
||||||
"""Hard gate: refuse to spawn unless ``.mise.toml`` defines ``[tasks.test]``."""
|
"""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.mise_path(working_dir)):
|
||||||
if not os.path.exists(mise_path):
|
|
||||||
raise SpawnError(
|
raise SpawnError(
|
||||||
f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task "
|
f"no .mise.toml in {working_dir}: a project must define a [tasks.test] task "
|
||||||
"before an agent can run against it"
|
"before an agent can run against it"
|
||||||
)
|
)
|
||||||
with open(mise_path, "rb") as fh:
|
if not mise.has_test_task(working_dir):
|
||||||
data = tomllib.load(fh)
|
|
||||||
tasks = data.get("tasks", {})
|
|
||||||
if "test" not in tasks:
|
|
||||||
raise SpawnError(
|
raise SpawnError(
|
||||||
f".mise.toml in {working_dir} has no [tasks.test]: the verification gate "
|
f".mise.toml in {working_dir} has no [tasks.test]: the verification gate "
|
||||||
"requires a canonical test task"
|
"requires a canonical test task"
|
||||||
@@ -77,8 +72,17 @@ def spawn(
|
|||||||
worktree_branch: str | None = None,
|
worktree_branch: str | None = None,
|
||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
role: str | None = None,
|
role: str | None = None,
|
||||||
|
require_tests: bool = True,
|
||||||
|
mise_init: bool = False,
|
||||||
) -> dict:
|
) -> 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
|
sync_note = None
|
||||||
with connection() as conn:
|
with connection() as conn:
|
||||||
project = repo.get_project(conn, project_id)
|
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
|
# Hard gates before any state is written or process launched: the test task must
|
||||||
# exist, and configured credentials must actually resolve — a broken pointer
|
# exist, and configured credentials must actually resolve — a broken pointer
|
||||||
# should fail fast, not leave an orphaned agent row behind.
|
# should fail fast, not leave an orphaned agent row behind. The mise-init agent
|
||||||
require_test_task(working_dir)
|
# skips the test-task gate (it's here to create that very task).
|
||||||
|
if require_tests:
|
||||||
|
require_test_task(working_dir)
|
||||||
try:
|
try:
|
||||||
token = credentials.resolve_for_project(project, conn)
|
token = credentials.resolve_for_project(project, conn)
|
||||||
except credentials.CredentialError as exc:
|
except credentials.CredentialError as exc:
|
||||||
@@ -137,6 +143,9 @@ def spawn(
|
|||||||
}
|
}
|
||||||
if role:
|
if role:
|
||||||
env["HANDLER_AGENT_ROLE"] = 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
|
# 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).
|
# registry (falling back to the built-in host map when a host has no row).
|
||||||
with connection() as conn:
|
with connection() as conn:
|
||||||
|
|||||||
@@ -150,6 +150,50 @@ def _cmd_forge_init(command: dict) -> dict:
|
|||||||
return result
|
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:
|
def _cmd_poll_ci(command: dict) -> dict:
|
||||||
return poller.sweep(project_id=command.get("project_id"))
|
return poller.sweep(project_id=command.get("project_id"))
|
||||||
|
|
||||||
@@ -199,6 +243,7 @@ _DISPATCH = {
|
|||||||
"approve": _cmd_approve,
|
"approve": _cmd_approve,
|
||||||
"reject": _cmd_reject,
|
"reject": _cmd_reject,
|
||||||
"forge_init": _cmd_forge_init,
|
"forge_init": _cmd_forge_init,
|
||||||
|
"mise_init": _cmd_mise_init,
|
||||||
"poll_ci": _cmd_poll_ci,
|
"poll_ci": _cmd_poll_ci,
|
||||||
"sync": _cmd_sync,
|
"sync": _cmd_sync,
|
||||||
"login_start": _cmd_login_start,
|
"login_start": _cmd_login_start,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ COMMAND_TYPES = (
|
|||||||
"approve",
|
"approve",
|
||||||
"reject",
|
"reject",
|
||||||
"forge_init",
|
"forge_init",
|
||||||
|
"mise_init",
|
||||||
"poll_ci",
|
"poll_ci",
|
||||||
"sync",
|
"sync",
|
||||||
"login_start",
|
"login_start",
|
||||||
|
|||||||
@@ -13,12 +13,80 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
from sqlalchemy import Connection
|
from sqlalchemy import Connection
|
||||||
|
|
||||||
|
from ..control import gitops, mise
|
||||||
from ..db import repository as repo
|
from ..db import repository as repo
|
||||||
from . import verify
|
from . import verify
|
||||||
from .context import HookInput, Identity, emit
|
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:
|
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 "."
|
working_dir = ident.working_dir or hook_input.cwd or "."
|
||||||
ok, output = verify.run_test(working_dir)
|
ok, output = verify.run_test(working_dir)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class Identity:
|
|||||||
project_id: str
|
project_id: str
|
||||||
agent_name: str
|
agent_name: str
|
||||||
working_dir: str | None = None
|
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)
|
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")
|
project_id = os.environ.get("HANDLER_PROJECT_ID")
|
||||||
agent_name = os.environ.get("HANDLER_AGENT_NAME")
|
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:
|
if agent_id and project_id and agent_name:
|
||||||
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
|
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
|
||||||
working_dir = row._mapping["working_dir"] if row else None
|
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.
|
# Fallback: match by working_dir == cwd.
|
||||||
if hook_input.cwd:
|
if hook_input.cwd:
|
||||||
|
|||||||
@@ -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 "."
|
working_dir = ident.working_dir or hook_input.cwd or "."
|
||||||
now = datetime.now(UTC)
|
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.
|
# Cheap check first: tests. Only on success do we pay for the image build.
|
||||||
tests_ok, tests_out = verify.run_test(working_dir)
|
tests_ok, tests_out = verify.run_test(working_dir)
|
||||||
if not tests_ok:
|
if not tests_ok:
|
||||||
|
|||||||
@@ -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})")
|
||||||
@@ -68,6 +68,32 @@ def test_spawn_creates_agent_settings_and_session(env, fake_tmux):
|
|||||||
assert call["env"]["DATABASE_URL"] == env["url"]
|
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):
|
def test_kill_sets_done_and_kills_session(env, fake_tmux):
|
||||||
root = env["tmp"] / "proj"
|
root = env["tmp"] / "proj"
|
||||||
_write_mise(root, with_test=True)
|
_write_mise(root, with_test=True)
|
||||||
|
|||||||
@@ -2,15 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from handler.control import gitops, mise
|
||||||
from handler.db import repository as repo
|
from handler.db import repository as repo
|
||||||
from handler.hooks import checkpoint, verify
|
from handler.hooks import checkpoint, verify
|
||||||
from handler.hooks.context import HookInput, Identity
|
from handler.hooks.context import HookInput, Identity
|
||||||
|
|
||||||
|
|
||||||
def _seed(conn):
|
def _seed(conn, mise_init=False):
|
||||||
repo.create_project(conn, "p", "/tmp/p")
|
repo.create_project(conn, "p", "/tmp/p")
|
||||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
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):
|
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"
|
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):
|
def test_session_end_records_without_gate(conn, monkeypatch):
|
||||||
ident = _seed(conn)
|
ident = _seed(conn)
|
||||||
# Even if tests would fail, SessionEnd must not run the gate or block.
|
# Even if tests would fail, SessionEnd must not run the gate or block.
|
||||||
|
|||||||
+23
-2
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from handler.control import gitops
|
||||||
from handler.db import repository as repo
|
from handler.db import repository as repo
|
||||||
from handler.hooks import gate, verify
|
from handler.hooks import gate, verify
|
||||||
from handler.hooks.context import HookInput, Identity
|
from handler.hooks.context import HookInput, Identity
|
||||||
|
|
||||||
|
|
||||||
def _seed(conn):
|
def _seed(conn, mise_init=False):
|
||||||
repo.create_project(conn, "p", "/tmp/p")
|
repo.create_project(conn, "p", "/tmp/p")
|
||||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
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):
|
def _decision(result):
|
||||||
@@ -73,6 +74,26 @@ def test_git_push_allowed_when_both_pass(conn, monkeypatch):
|
|||||||
assert _decision(result) == "allow"
|
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):
|
def test_non_push_bash_is_ignored(conn):
|
||||||
ident = _seed(conn)
|
ident = _seed(conn)
|
||||||
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
|
hi = HookInput({"tool_name": "Bash", "tool_input": {"command": "ls -la"}}, "pre_tool_use")
|
||||||
|
|||||||
@@ -50,6 +50,46 @@ def test_create_project_from_server_with_ssh_key(client, auth, env, secret_key):
|
|||||||
assert cmd["project_id"] == "coolproj"
|
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):
|
def test_create_project_from_server_https_without_key(client, auth, env):
|
||||||
_add_server(client, auth, hostname="git.corp", forge_type="gitea",
|
_add_server(client, auth, hostname="git.corp", forge_type="gitea",
|
||||||
base_url="https://git.corp:8443")
|
base_url="https://git.corp:8443")
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_poll_ci_command_returns_summary(env, monkeypatch):
|
||||||
_seed_project()
|
_seed_project()
|
||||||
summary = {"checked": 0, "resolved": 0, "pending": 0}
|
summary = {"checked": 0, "resolved": 0, "pending": 0}
|
||||||
|
|||||||
Reference in New Issue
Block a user