feat: git servers own credentials, one-line project adds with auto-clone, and scheduled agents

Git servers (forge_hosts) become full credential owners:
- an encrypted forge token (Fernet, HANDLER_SECRET_KEY) stored per server and
  never returned by the API (has_token flag only); used automatically by every
  project on that host and addressable as db:host:<hostname> — the reserved
  db: credential scheme is now live
- a per-server ed25519 SSH deploy key: generated server-side, public half
  shown in the dashboard to paste into the forge, private half encrypted at
  rest and materialized 0600 only in the control container (GIT_SSH_COMMAND /
  core.sshCommand)

Project registration gets a git-server mode: pick a registered server, type
owner/name, and the API derives the remote (ssh when the server has a deploy
key, https otherwise), computes root_dir under PROJECTS_ROOT, and enqueues a
new 'sync' command the worker executes (clone, or ff-only pull). Spawn always
pulls first, so runs start from the remote's latest state; POST /projects/:p/sync
and 'handler sync' re-pull on demand.

Schedules: recurring agent spawns (prefix, prompt, interval, role). The worker
fires due schedules as ordinary queued spawn commands with timestamped agent
names, so runs are fresh stateless agents and appear in the Activity audit
trail; missed intervals collapse into one catch-up run.

Dashboard: Git Servers pane shows the SSH public key (copy button) and takes a
write-only token; Repositories gains the server-first add form and a Pull now
button; new Schedules pane. Rebuilt static export. Also restores the missing
frontend/lib (api client + format helpers) the components import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
This commit is contained in:
Claude
2026-07-10 19:17:12 +00:00
parent 7399315185
commit 71a7550f48
37 changed files with 2236 additions and 157 deletions
+7
View File
@@ -22,6 +22,13 @@ AUTH_TOKEN=change-me-to-a-long-random-string
# Fully bring-your-own; the Notification hook is a no-op when unset.
# WEBHOOK_URL=https://ntfy.sh/my-topic
# Symmetric key for the encrypted secret store: git-server tokens and SSH private keys
# are Fernet-encrypted with it before they reach the database. Set the SAME value on the
# API (encrypts on write) and the control container (decrypts at clone/spawn). Generate:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Unset => storing tokens/SSH keys on git servers is refused with a clear error.
# HANDLER_SECRET_KEY=
# Base directory under which per-project roots and agent worktrees live (isolation).
PROJECTS_ROOT=/var/lib/handler/projects
+67 -21
View File
@@ -103,7 +103,8 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| `SHARED_CONTEXT_WRITE_TOKEN` | Higher-trust token gating `PUT /shared/context/:key` | falls back to `AUTH_TOKEN` |
| `ADMIN_TOKEN` | Gates the web control surface (enqueue commands, project/host CRUD, credential edits) | falls back to `AUTH_TOKEN` |
| `WEBHOOK_URL` | Generic target for the `Notification` hook (ntfy, Slack, …) | unset → no-op |
| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees | `./projects` |
| `HANDLER_SECRET_KEY` | Fernet key encrypting git-server tokens + SSH keys at rest (set the same value on API and control) | unset → secret store disabled |
| `PROJECTS_ROOT` | Base dir for per-project roots / worktrees / auto-clones | `./projects` |
| `CLAUDE_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `mise` / `tmux` / `forge` / `git` |
| `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check |
| `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` |
@@ -193,25 +194,42 @@ and the worker in the control container executes it and writes the result back:
What the dashboard can now do (all state-changing actions require `ADMIN_TOKEN`):
- **Projects** — create / edit / delete (`root_dir`, `git_remote`, `credential_ref`).
- **Git servers** — one entry per forge host, and the server **owns its credentials**:
- a **forge token**, submitted once and stored **encrypted** (`HANDLER_SECRET_KEY`,
Fernet) — the API never returns it, only a `has_token` flag. Every project on that
server uses it automatically (for both `forge` and git-over-HTTPS), no per-repo setup.
- an **SSH deploy key** (ed25519), generated server-side; the **public key is shown in
the dashboard** to paste into GitHub/Gitea/… as a deploy or account key. The private
key is encrypted at rest and only ever materialized (0600) in the control container.
- **Projects** — add a repo by picking a **configured git server** and typing
**`owner/name`** — that's the whole form. Handler derives the remote (ssh when the
server has a deploy key, https via the stored token otherwise), computes `root_dir`
under `PROJECTS_ROOT` (stateless workflows don't care where the clone lives), and
enqueues a `sync` command so the worker clones it. Manual mode (existing `root_dir`)
still works; every project with a remote gets a **Pull now** button, and spawn always
pulls first.
- **Schedules** — recurring agent spawns: a name prefix, a prompt, and an interval. The
worker fires each due schedule as a normal queued `spawn` with a timestamped agent name
(`nightly-20260710-090000`), so runs are fresh, stateless agents and show up in
Activity. The canonical prompt keeps its state in the repo: *"Read @notes.md, continue
from there; before finishing, overwrite that file."*
- **Agents** — spawn (name, role, worktree/subdir, task) and kill via the queue; delete the
row; plus the existing checkmark / log / answer-resume views.
- **Approvals** — record an operator verdict per branch (approve/reject); the deploy gate
treats an operator verdict as a genuine second party (no self-approval).
- **Forge hosts** — a registry mapping a host to the token env var to inject at spawn, so
self-hosted forges work without a code change (the built-in host map is the fallback).
- **Credentials** — manage a project's `credential_ref` **pointer**. The DB still never
stores a raw token: web-settable schemes are `env:` / `file:` / `db:` (the `cmd:` scheme
is CLI-only, since it would run an arbitrary command in the control container). `db:` is
reserved for a future encrypted secret store.
- **Credentials** — a project's `credential_ref` **pointer** still overrides everything.
Web-settable schemes are `env:` / `file:` / `db:host:<hostname>` (the `cmd:` scheme is
CLI-only, since it would run an arbitrary command in the control container).
`db:host:<hostname>` reads the named git server's encrypted stored token.
- **Activity** — every enqueued command with its status (queued → running → done/failed) —
the audit log of what the dashboard triggered. The UI polls `GET /commands/{id}` for
live status.
The command queue is exposed over HTTP as `POST …/agents/spawn`, `POST …/agents/{n}/kill`,
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, and `GET /commands[/{id}]`;
hosts as `/hosts`; project mutation as `PATCH`/`DELETE /projects/{id}`. Run the worker with
`handler worker` (the control image's default command).
`POST …/approvals`, `POST …/forge-init`, `POST …/poll-ci`, `POST …/sync`, and
`GET /commands[/{id}]`; hosts as `/hosts`; schedules as `/schedules` +
`/projects/{id}/schedules`; project mutation as `PATCH`/`DELETE /projects/{id}`. Run the
worker with `handler worker` (the control image's default command).
## Control CLI
@@ -223,6 +241,7 @@ handler spawn --project leeworks-api --name junior --role junior --worktree fea
handler list [--project leeworks-api]
handler attach --project leeworks-api --name junior
handler kill --project leeworks-api --name junior
handler sync --project leeworks-api # clone or fast-forward the repo now
# Phase 2 — forge workflow
handler forge-init --project leeworks-api # write + commit the role skills
@@ -253,6 +272,10 @@ All routes require `Authorization: Bearer <AUTH_TOKEN>`. `GET /health` is unauth
| `GET /projects/:p/agents/:name/log` | The agent's log history (paginated) |
| `POST /projects/:p/agents/:name/answer` | Backfill the operator's answer to an open question |
| `POST /projects/:p/agents/:name/resume` | Feed the answer back via `claude --resume` |
| `GET /hosts` · `POST /hosts` · `PATCH`/`DELETE /hosts/:h` | Git servers (token stored encrypted; `ssh_public_key` returned) |
| `GET /schedules` · `GET`/`POST /projects/:p/schedules` | Recurring agent spawns |
| `PATCH`/`DELETE /schedules/:id` | Edit / pause / remove a schedule |
| `POST /projects/:p/sync` | Clone-or-pull the project's repo (enqueued) |
| `GET /shared/log` | Cross-project feed of entries explicitly marked `global` |
| `GET /shared/context` · `GET /shared/context/:key` | Read shared key/value facts |
| `PUT /shared/context/:key` | Write a shared fact — requires the shared-write token |
@@ -304,22 +327,45 @@ project's `credential_ref` (and optionally a `FORGE_VERSION` pin).
the runs tied to that commit and backfills the authoritative verdict — one interface,
any forge, no inbound webhook.
### Credentials — resolution, not storage (README 3.7)
### Credentials — resolution over raw storage (README 3.7)
The database never stores a raw token. A project's `credential_ref` is a **pointer**:
The database never stores a *usable* secret. A project's `credential_ref` is a **pointer**:
| Form | Meaning |
|---|---|
| `env:VAR_NAME` | read the value from an environment variable |
| `file:/path` | read (and strip) the value from a file |
| `cmd:some command` | run the command; its stdout is the value |
| `cmd:some command` | run the command; its stdout is the value (CLI-only) |
| `db:host:<hostname>` | decrypt the named git server's stored token (`HANDLER_SECRET_KEY`) |
At spawn the control layer resolves the pointer and injects the value into that one
agent's environment as `FORGE_TOKEN` (plus the host-specific `GITHUB_TOKEN` /
`GITEA_TOKEN` / … when the remote is recognized). A repo-local git credential helper is
installed that hands the same value back for HTTPS push/pull — so one secret services both
`forge` and `git`, and the raw token lives only in the process environment, never on disk
or in the database.
When a project has **no** `credential_ref`, the git server matching its remote supplies
the token automatically (its stored token, decrypted at spawn) — so projects added from a
configured server need zero per-repo credential setup.
At spawn the control layer resolves the token and injects it into that one agent's
environment as `FORGE_TOKEN` (plus the host-specific `GITHUB_TOKEN` / `GITEA_TOKEN` / …
when the remote is recognized). A repo-local git credential helper is installed that
hands the same value back for HTTPS push/pull — so one secret services both `forge` and
`git`, and the raw token lives only in the process environment. For **SSH remotes** the
server's deploy key is materialized to a 0600 file in the control container and pinned
via `GIT_SSH_COMMAND` / repo-local `core.sshCommand`, so agents' pushes over ssh just
work. Tokens and private keys stored in the database are Fernet-encrypted with
`HANDLER_SECRET_KEY`; without the key a database dump holds only ciphertext.
### Scheduled agents
A **schedule** spawns a fresh agent every `interval_seconds`: pick a repository, a name
prefix, a role, and a standing prompt. On each firing the worker enqueues an ordinary
`spawn` command (visible in Activity) with a timestamped agent name, and the repo is
pulled before the run — every run starts stateless from the remote's latest state.
Continuity lives in the repo itself; the canonical prompt is:
> Read @notes.md and continue from where it left off. Before finishing, overwrite
> @notes.md with the current state so the next run can pick up from there.
Missed intervals (worker down) collapse into a single catch-up run. Manage schedules in
the dashboard's **Schedules** pane or via `GET/POST /projects/:p/schedules`,
`PATCH`/`DELETE /schedules/:id`.
## Development
@@ -341,7 +387,7 @@ and `control.spawn.resume` — are the mock points that stand in for live
The dashboard (`frontend/`) is a **Next.js** app (React + TypeScript) that builds to a
**static export** — the `Claude Activity` Control Center: a left-nav hub over Runs,
Repositories, Agents, Approvals, Git Servers, Activity, and Shared. It is a pure client of
Repositories, Agents, Schedules, Approvals, Git Servers, Activity, and Shared. It is a pure client of
the API (same contract as `curl`): the browser prompts for the token once, stores it in
`localStorage`, and attaches it to every call. All API values render as React text
(never `dangerouslySetInnerHTML`) so agent-authored strings can't inject markup.
+9 -1
View File
@@ -15,6 +15,11 @@ services:
WEBHOOK_URL: ${WEBHOOK_URL:-}
UI_ENABLED: ${UI_ENABLED:-true}
CORS_ORIGINS: ${CORS_ORIGINS:-}
# The API computes new projects' root_dir under this path (shared volume with the
# control container, which does the actual cloning).
PROJECTS_ROOT: /var/lib/handler/projects
# Encrypts git-server tokens/SSH keys at rest; must match the control container.
HANDLER_SECRET_KEY: ${HANDLER_SECRET_KEY:-}
volumes:
- handler-data:/var/lib/handler
depends_on:
@@ -23,7 +28,8 @@ services:
restart: unless-stopped
# Control layer: the `handler` worker. Drains the control-command queue the API enqueues
# (spawn/kill/resume/approve/reject/forge-init/poll-ci) and sweeps CI on an interval.
# (spawn/kill/resume/approve/reject/forge-init/poll-ci/sync), fires due schedules, and
# sweeps CI on an interval.
# Shares the database and the handler-data volume with the API. It waits for the API
# (which owns migrations), so RUN_MIGRATIONS is off here to avoid a startup race. Run
# one-shot control commands against the same image with, e.g.,
@@ -42,6 +48,8 @@ services:
# Per-project forge credentials are resolved from credential_ref pointers at spawn;
# export the referenced vars here when spawning agents from this container.
FORGE_VERSION: ${FORGE_VERSION:-}
# Decrypts git-server tokens/SSH keys stored by the API; must match the API's key.
HANDLER_SECRET_KEY: ${HANDLER_SECRET_KEY:-}
volumes:
- handler-data:/var/lib/handler
depends_on:
+3
View File
@@ -7,6 +7,7 @@ import { useDashboard, type Section } from "@/components/store";
import { RunsSection } from "@/components/sections/RunsSection";
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
import { AgentsSection } from "@/components/sections/AgentsSection";
import { SchedulesSection } from "@/components/sections/SchedulesSection";
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
import { GitServersSection } from "@/components/sections/GitServersSection";
import { ActivitySection } from "@/components/sections/ActivitySection";
@@ -28,6 +29,7 @@ const NAV: NavDef[] = [
},
{ key: "repositories", label: "Repositories", count: (s) => s.projects.length },
{ key: "agents", label: "Agents", count: (s) => s.agents.length },
{ key: "schedules", label: "Schedules", count: (s) => s.schedules.length },
{ key: "approvals", label: "Approvals", count: (s) => s.approvals.length },
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
@@ -90,6 +92,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
<div className="main-scroll">
{s.section === "repositories" && <RepositoriesSection />}
{s.section === "agents" && <AgentsSection />}
{s.section === "schedules" && <SchedulesSection />}
{s.section === "approvals" && <ApprovalsSection />}
{s.section === "servers" && <GitServersSection />}
{s.section === "activity" && <ActivitySection />}
@@ -1,5 +1,7 @@
/* Git Servers — the forge host registry. Each row maps a host to the token env var to
* inject at spawn (and the credential-helper scope). Holds no secrets, only the var name. */
/* Git Servers — one entry per forge host, carrying the server's own credentials:
* a forge token (stored encrypted, write-only) and an SSH deploy key whose public half
* is shown here to paste into the forge. Projects on a configured server need no
* per-repo credentials. */
"use client";
import { useState } from "react";
@@ -15,7 +17,52 @@ const FORGE_OPTS = [
{ value: "bitbucket", label: "bitbucket" },
];
const empty = { hostname: "", forge_type: "github", token_env_var: "", base_url: "" };
const empty = {
hostname: "",
forge_type: "github",
token_env_var: "",
base_url: "",
token: "",
generate_ssh_key: true,
};
function PublicKey({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable (http) — the key is selectable below */
}
};
return (
<div style={{ marginTop: 10 }}>
<div className="hstack" style={{ justifyContent: "space-between" }}>
<span className="eyebrow">SSH public key add it to the forge (deploy key)</span>
<Button size="sm" variant="secondary" onClick={copy}>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<pre
className="mono"
style={{
fontSize: "var(--text-xs)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: "6px 0 0",
padding: 8,
border: "1px solid var(--border-default)",
borderRadius: 6,
userSelect: "all",
}}
>
{value}
</pre>
</div>
);
}
export function GitServersSection() {
const s = useDashboard();
@@ -38,6 +85,8 @@ export function GitServersSection() {
forge_type: h.forge_type,
token_env_var: h.token_env_var ?? "",
base_url: h.base_url ?? "",
token: "",
generate_ssh_key: false,
});
setEditing(true);
};
@@ -47,8 +96,10 @@ export function GitServersSection() {
<div className="section-head">
<div className="section-title">Git Servers</div>
<div className="section-desc">
Maps a git host to the token env var injected at spawn. The built-in host map is the
fallback when no row matches.
Each server carries its own credentials: a forge token (encrypted at rest, used by
agents&apos; <span className="mono">forge</span> + git) and an SSH deploy key paste
the public key into the forge. New repositories are added by picking a server and
typing owner/name.
</div>
</div>
<div className="section-body">
@@ -63,7 +114,7 @@ export function GitServersSection() {
label="Hostname"
value={form.hostname}
onChange={(v) => setForm({ ...form, hostname: v })}
placeholder="git.corp.internal"
placeholder="github.com"
disabled={editing}
/>
<Select
@@ -73,17 +124,37 @@ export function GitServersSection() {
options={FORGE_OPTS}
/>
<Input
label="Token env var"
label={editing ? "Forge token (blank = keep current)" : "Forge token"}
type="password"
value={form.token}
onChange={(v) => setForm({ ...form, token: v })}
placeholder="stored encrypted; used by forge + git"
/>
<Input
label="Base URL (optional)"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="https://git.corp.internal:8443"
/>
<Input
label="Token env var override (optional)"
value={form.token_env_var}
onChange={(v) => setForm({ ...form, token_env_var: v })}
placeholder="GITEA_TOKEN"
/>
<Input
label="Base URL"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="https://git.corp.internal (optional)"
/>
<label className="field">
<span className="field-label">SSH deploy key</span>
<label className="hstack" style={{ gap: 8, cursor: "pointer" }}>
<input
type="checkbox"
checked={form.generate_ssh_key}
onChange={(e) => setForm({ ...form, generate_ssh_key: e.target.checked })}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
{editing ? "Regenerate keypair (replaces the current key)" : "Generate a keypair"}
</span>
</label>
</label>
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.hostname.trim()} onClick={save}>
@@ -109,6 +180,12 @@ export function GitServersSection() {
</span>
<div className="hstack">
<Badge tone="info">{h.forge_type}</Badge>
<Badge tone={h.has_token ? "success" : "neutral"}>
{h.has_token ? "token stored" : "no token"}
</Badge>
<Badge tone={h.ssh_public_key ? "success" : "neutral"}>
{h.ssh_public_key ? "ssh key" : "no ssh key"}
</Badge>
<Button size="sm" variant="secondary" onClick={() => edit(h)}>
Edit
</Button>
@@ -121,6 +198,7 @@ export function GitServersSection() {
token env {h.token_env_var || "—"}
{h.base_url ? ` · ${h.base_url}` : ""}
</div>
{h.ssh_public_key && <PublicKey value={h.ssh_public_key} />}
</Card>
))}
</div>
@@ -1,16 +1,28 @@
/* Repositories — register / edit / remove the projects (repos) Handler manages.
* Maps the design's "Repositories" pane to Handler's project registry. */
/* Repositories — the projects Handler manages. Adding one is now server-first: pick a
* configured git server, type owner/name, and Handler derives the remote, decides where
* the clone lives, and pulls it (stateless workflows don't care about disk paths).
* Manual mode (an existing checkout) remains for everything else. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input } from "@/components/ui";
import { useDashboard, type NewProjectBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Tabs } from "@/components/ui";
import { fmtFull } from "@/lib/format";
import type { Project } from "@/lib/api";
const CRED_HELP = "credential_ref is a pointer, never the token (env: / file: / db:). cmd: is CLI-only.";
const CRED_HELP =
"Optional override — projects on a configured git server use its stored token automatically. " +
"credential_ref is a pointer, never the token (env: / file: / db:host:<hostname>).";
const empty = { id: "", root_dir: "", git_remote: "", credential_ref: "" };
const empty: NewProjectBody = {
mode: "server",
git_server: "",
repo: "",
id: "",
root_dir: "",
git_remote: "",
credential_ref: "",
};
export function RepositoriesSection() {
const s = useDashboard();
@@ -23,20 +35,28 @@ export function RepositoriesSection() {
return m;
}, [s.agents]);
const serverOpts = useMemo(
() => [
{ value: "", label: s.hosts.length ? "Pick a git server…" : "No git servers configured" },
...s.hosts.map((h) => ({ value: h.hostname, label: `${h.hostname} (${h.forge_type})` })),
],
[s.hosts],
);
const reset = () => {
setForm(empty);
setEditing(false);
};
const save = async () => {
const ok = editing
? await s.updateProject(form.id, form)
: await s.createProject(form);
const ok = editing ? await s.updateProject(form.id, form) : await s.createProject(form);
if (ok) reset();
};
const edit = (p: Project) => {
setForm({
...empty,
mode: "manual",
id: p.id,
root_dir: p.root_dir,
git_remote: p.git_remote ?? "",
@@ -45,6 +65,12 @@ export function RepositoriesSection() {
setEditing(true);
};
const canSave = editing
? !!form.root_dir.trim()
: form.mode === "server"
? !!form.git_server && /^[\w.-]+\/[\w.-]+$/.test(form.repo.trim())
: !!form.id.trim() && !!form.root_dir.trim();
return (
<>
<div className="section-head">
@@ -57,46 +83,89 @@ export function RepositoriesSection() {
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editing ? `Edit repository · ${form.id}` : "Register a repository"}
{editing ? `Edit repository · ${form.id}` : "Add a repository"}
</span>
</div>
<div className="form-grid">
<Input
label="ID / slug"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="leeworks-api"
disabled={editing}
/>
<Input
label="Root dir"
value={form.root_dir}
onChange={(v) => setForm({ ...form, root_dir: v })}
placeholder="/var/lib/handler/projects/leeworks"
/>
<Input
label="Git remote"
value={form.git_remote}
onChange={(v) => setForm({ ...form, git_remote: v })}
placeholder="git@github.com:user/repo.git (optional)"
/>
<Input
label="Credential ref"
value={form.credential_ref}
onChange={(v) => setForm({ ...form, credential_ref: v })}
placeholder="env:VAR / file:/path / db:id"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{CRED_HELP}
</p>
{!editing && (
<div style={{ marginBottom: 14 }}>
<Tabs
tabs={[
{ value: "server", label: "From a git server" },
{ value: "manual", label: "Manual (existing checkout)" },
]}
value={form.mode}
onChange={(v) => setForm({ ...form, mode: v as "server" | "manual" })}
/>
</div>
)}
{!editing && form.mode === "server" ? (
<>
<div className="form-grid">
<Select
label="Git server"
value={form.git_server}
onChange={(v) => setForm({ ...form, git_server: v })}
options={serverOpts}
/>
<Input
label="Repository (owner/name)"
value={form.repo}
onChange={(v) => setForm({ ...form, repo: v })}
placeholder="me/coolproj"
/>
<Input
label="ID / slug (optional — defaults to the repo name)"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="coolproj"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
The repo is always pulled: Handler derives the remote from the server (ssh when it
has a deploy key, https via the stored token otherwise), clones it under
PROJECTS_ROOT, and keeps it fresh before every run.
</p>
</>
) : (
<>
<div className="form-grid">
<Input
label="ID / slug"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="leeworks-api"
disabled={editing}
/>
<Input
label="Root dir"
value={form.root_dir}
onChange={(v) => setForm({ ...form, root_dir: v })}
placeholder="/var/lib/handler/projects/leeworks"
/>
<Input
label="Git remote"
value={form.git_remote}
onChange={(v) => setForm({ ...form, git_remote: v })}
placeholder="git@github.com:user/repo.git (optional)"
/>
<Input
label="Credential ref"
value={form.credential_ref}
onChange={(v) => setForm({ ...form, credential_ref: v })}
placeholder="env:VAR / file:/path / db:host:github.com"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{CRED_HELP}
</p>
</>
)}
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.id.trim() || !form.root_dir.trim()}
onClick={save}
>
{editing ? "Save changes" : "Register"}
<Button variant="primary" disabled={s.cmd.busy || !canSave} onClick={save}>
{editing ? "Save changes" : form.mode === "server" ? "Add & pull" : "Register"}
</Button>
{editing && (
<Button variant="ghost" onClick={reset}>
@@ -122,9 +191,14 @@ export function RepositoriesSection() {
</div>
<div className="hstack" style={{ marginTop: 12, justifyContent: "space-between" }}>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
cred {p.credential_ref || ""} · added {fmtFull(p.created_at)}
cred {p.credential_ref || "server default"} · added {fmtFull(p.created_at)}
</span>
<div className="hstack">
{p.git_remote && (
<Button size="sm" variant="secondary" onClick={() => s.syncProject(p.id)}>
Pull now
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
Edit
</Button>
@@ -0,0 +1,196 @@
/* Schedules — recurring agent spawns. Every interval the worker starts a fresh,
* stateless agent named <prefix>-<timestamp> with the stored prompt. The canonical
* pattern: keep state in a file in the repo and have the prompt continue from it. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, Textarea, Toggle } from "@/components/ui";
import { fmtFull } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
];
const INTERVAL_OPTS = [
{ value: "900", label: "every 15 minutes" },
{ value: "1800", label: "every 30 minutes" },
{ value: "3600", label: "every hour" },
{ value: "21600", label: "every 6 hours" },
{ value: "86400", label: "every day" },
{ value: "604800", label: "every week" },
];
const TASK_PLACEHOLDER =
"Read @notes.md and continue from where it left off. Before finishing, overwrite " +
"@notes.md with the current state so the next run can pick up from there.";
function intervalLabel(seconds: number): string {
const opt = INTERVAL_OPTS.find((o) => Number(o.value) === seconds);
if (opt) return opt.label;
if (seconds % 3600 === 0) return `every ${seconds / 3600}h`;
if (seconds % 60 === 0) return `every ${seconds / 60}m`;
return `every ${seconds}s`;
}
const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "" };
export function SchedulesSection() {
const s = useDashboard();
const [form, setForm] = useState(emptyForm);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
const create = async () => {
const ok = await s.createSchedule(s.selectedProjectId, {
name_prefix: form.name_prefix,
task: form.task,
interval_seconds: Number(form.interval),
role: form.role,
});
if (ok) setForm(emptyForm);
};
return (
<>
<div className="section-head">
<div className="section-title">Schedules</div>
<div className="section-desc">
Spawn a fresh agent on an interval. Each run is stateless keep continuity in a
file the prompt reads and overwrites.
</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
New schedule
</span>
</div>
<div className="form-grid">
<Input
label="Name prefix"
value={form.name_prefix}
onChange={(v) => setForm({ ...form, name_prefix: v })}
placeholder="nightly"
/>
<Select
label="Interval"
value={form.interval}
onChange={(v) => setForm({ ...form, interval: v })}
options={INTERVAL_OPTS}
/>
<Select
label="Role"
value={form.role}
onChange={(v) => setForm({ ...form, role: v })}
options={ROLE_OPTS}
/>
</div>
<div className="mt14">
<Textarea
label="Prompt (the task every run starts with)"
value={form.task}
onChange={(v) => setForm({ ...form, task: v })}
rows={3}
placeholder={TASK_PLACEHOLDER}
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
Runs are named <span className="mono">{form.name_prefix.trim() || "prefix"}-YYYYMMDD-HHMMSS</span>.
The repo is pulled before every run; the first run fires on the worker&apos;s next
pass.
</p>
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.name_prefix.trim() || !form.task.trim()}
onClick={create}
>
Create schedule
</Button>
</div>
</Card>
{s.schedules.length === 0 ? (
<div className="empty">No schedules yet.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>On</th>
<th>Name</th>
<th>Repository</th>
<th>Interval</th>
<th>Prompt</th>
<th>Next run</th>
<th>Last run</th>
<th />
</tr>
</thead>
<tbody>
{s.schedules.map((sc) => (
<tr key={sc.id}>
<td>
<Toggle
on={sc.enabled}
onClick={() => s.updateSchedule(sc.id, { enabled: !sc.enabled })}
/>
</td>
<td className="mono">
{sc.name_prefix}
{sc.role ? (
<>
{" "}
<Badge tone="info">{sc.role}</Badge>
</>
) : null}
</td>
<td className="mono faint">{sc.project_id}</td>
<td className="nowrap">{intervalLabel(sc.interval_seconds)}</td>
<td className="faint" style={{ maxWidth: 340 }}>
<span className="truncate" style={{ display: "block" }} title={sc.task}>
{sc.task}
</span>
</td>
<td className="faint nowrap">{sc.enabled ? fmtFull(sc.next_run_at) : "paused"}</td>
<td className="faint nowrap">{fmtFull(sc.last_run_at)}</td>
<td className="nowrap">
<Button size="sm" variant="danger" onClick={() => s.deleteSchedule(sc.id)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}
+164 -17
View File
@@ -24,6 +24,7 @@ import {
type Host,
type LogEntry,
type Project,
type Schedule,
type SharedContext,
} from "@/lib/api";
@@ -31,6 +32,7 @@ export type Section =
| "runs"
| "repositories"
| "agents"
| "schedules"
| "approvals"
| "servers"
| "activity"
@@ -68,6 +70,7 @@ interface StoreValue {
approvals: Approval[];
hosts: Host[];
commands: Command[];
schedules: Schedule[];
shared: { log: LogEntry[]; context: SharedContext[] };
cmd: CmdState;
@@ -81,13 +84,17 @@ interface StoreValue {
killAgent: (projectId: string, name: string) => Promise<void>;
deleteAgent: (projectId: string, name: string) => Promise<void>;
submitAnswer: (answer: string, resume: boolean) => Promise<boolean>;
createProject: (b: ProjectBody) => Promise<boolean>;
createProject: (b: NewProjectBody) => Promise<boolean>;
updateProject: (id: string, b: Omit<ProjectBody, "id">) => Promise<boolean>;
deleteProject: (id: string) => Promise<void>;
syncProject: (id: string) => Promise<void>;
submitApproval: (b: ApprovalBody) => Promise<void>;
createHost: (b: HostBody) => Promise<boolean>;
updateHost: (hostname: string, b: Omit<HostBody, "hostname">) => Promise<boolean>;
deleteHost: (hostname: string) => Promise<void>;
createSchedule: (projectId: string, b: ScheduleBody) => Promise<boolean>;
updateSchedule: (id: number, b: Partial<ScheduleBody> & { enabled?: boolean }) => Promise<boolean>;
deleteSchedule: (id: number) => Promise<void>;
pollCi: () => Promise<void>;
setSharedKey: (key: string, value: string) => Promise<boolean>;
}
@@ -106,6 +113,23 @@ export interface ProjectBody {
git_remote: string;
credential_ref: string;
}
/* New-project form. "server" mode = pick a configured git server + owner/name (Handler
* derives the remote, picks the disk location, and clones); "manual" = the old fields. */
export interface NewProjectBody {
mode: "server" | "manual";
git_server: string;
repo: string;
id: string;
root_dir: string;
git_remote: string;
credential_ref: string;
}
export interface ScheduleBody {
name_prefix: string;
task: string;
interval_seconds: number;
role: string;
}
export interface ApprovalBody {
branch: string;
status: string;
@@ -118,6 +142,10 @@ export interface HostBody {
forge_type: string;
token_env_var: string;
base_url: string;
/* Write-only: encrypted at rest server-side, never echoed back. Blank = no change. */
token: string;
/* Create: mint a deploy keypair. Update: replace the existing one. */
generate_ssh_key: boolean;
}
const Ctx = createContext<StoreValue | null>(null);
@@ -153,6 +181,7 @@ export function DashboardProvider({
const [approvals, setApprovals] = useState<Approval[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [commands, setCommands] = useState<Command[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [shared, setShared] = useState<{ log: LogEntry[]; context: SharedContext[] }>({
log: [],
context: [],
@@ -256,6 +285,14 @@ export function DashboardProvider({
}
}, []);
const loadSchedules = useCallback(async () => {
try {
setSchedules(await clientRef.current.api<Schedule[]>("/schedules"));
} catch (e) {
swallow(e);
}
}, []);
const loadShared = useCallback(async () => {
try {
const [logRows, context] = await Promise.all([
@@ -288,8 +325,9 @@ export function DashboardProvider({
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
if (s === "servers") await loadHosts();
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadShared]);
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
@@ -316,9 +354,10 @@ export function DashboardProvider({
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
if (s === "servers") void loadHosts();
if (s === "activity") void loadCommands();
if (s === "schedules") void loadSchedules();
if (s === "shared") void loadShared();
},
[loadApprovals, loadHosts, loadCommands, loadShared],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared],
);
const selectProject = useCallback(
@@ -459,19 +498,53 @@ export function DashboardProvider({
);
const createProject = useCallback(
async (b: ProjectBody) => {
async (b: NewProjectBody) => {
try {
await clientRef.current.api("/projects", {
const body: Record<string, unknown> =
b.mode === "server"
? {
git_server: b.git_server,
repo: b.repo.trim(),
id: b.id.trim() || null,
credential_ref: b.credential_ref.trim() || null,
}
: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
};
const created = await clientRef.current.api<Project>("/projects", {
method: "POST",
body: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
},
body,
});
setCmd({ text: `repository '${b.id}' registered`, error: false, busy: false });
await loadProjects();
// Server mode enqueues a clone; follow it so the operator sees the repo land.
if (created.sync_command_id != null) {
setCmd({ text: `repository '${created.id}': cloning…`, error: false, busy: true });
const final = await clientRef.current.trackCommand(created.sync_command_id);
if (!final) {
setCmd({
text: `repository '${created.id}' registered; clone still running (see Activity). Is the worker up?`,
error: false,
busy: false,
});
} else if (final.status === "done") {
setCmd({
text: `repository '${created.id}' registered and cloned`,
error: false,
busy: false,
});
} else {
setCmd({
text: `repository '${created.id}' registered but the clone failed — ${final.error ?? ""}`,
error: true,
busy: false,
});
}
} else {
setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false });
}
return true;
} catch (e) {
if (e instanceof AuthError) return false;
@@ -482,6 +555,13 @@ export function DashboardProvider({
[loadProjects],
);
const syncProject = useCallback(
async (id: string) => {
await enqueueAndTrack(`/projects/${encodeURIComponent(id)}/sync`, undefined, `pull ${id}`);
},
[enqueueAndTrack],
);
const updateProject = useCallback(
async (id: string, b: Omit<ProjectBody, "id">) => {
try {
@@ -549,6 +629,8 @@ export function DashboardProvider({
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
token: b.token.trim() || null,
generate_ssh_key: b.generate_ssh_key,
},
});
setCmd({ text: `git server '${b.hostname}' added`, error: false, busy: false });
@@ -566,13 +648,16 @@ export function DashboardProvider({
const updateHost = useCallback(
async (hostname: string, b: Omit<HostBody, "hostname">) => {
try {
const body: Record<string, unknown> = {
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
};
if (b.token.trim()) body.token = b.token.trim();
if (b.generate_ssh_key) body.regenerate_ssh_key = true;
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, {
method: "PATCH",
body: {
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
},
body,
});
setCmd({ text: `git server '${hostname}' updated`, error: false, busy: false });
await loadHosts();
@@ -586,6 +671,63 @@ export function DashboardProvider({
[loadHosts],
);
const createSchedule = useCallback(
async (projectId: string, b: ScheduleBody) => {
try {
await clientRef.current.api(`/projects/${encodeURIComponent(projectId)}/schedules`, {
method: "POST",
body: {
name_prefix: b.name_prefix.trim(),
task: b.task.trim(),
interval_seconds: b.interval_seconds,
role: b.role || null,
},
});
setCmd({
text: `schedule '${b.name_prefix}' created — first run on the worker's next pass`,
error: false,
busy: false,
});
await loadSchedules();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadSchedules],
);
const updateSchedule = useCallback(
async (id: number, b: Partial<ScheduleBody> & { enabled?: boolean }) => {
try {
await clientRef.current.api(`/schedules/${id}`, { method: "PATCH", body: b });
await loadSchedules();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadSchedules],
);
const deleteSchedule = useCallback(
async (id: number) => {
try {
await clientRef.current.api(`/schedules/${id}`, { method: "DELETE" });
setCmd({ text: `schedule ${id} removed`, error: false, busy: false });
await loadSchedules();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadSchedules],
);
const deleteHost = useCallback(
async (hostname: string) => {
try {
@@ -641,6 +783,7 @@ export function DashboardProvider({
approvals,
hosts,
commands,
schedules,
shared,
cmd,
lastError,
@@ -653,10 +796,14 @@ export function DashboardProvider({
createProject,
updateProject,
deleteProject,
syncProject,
submitApproval,
createHost,
updateHost,
deleteHost,
createSchedule,
updateSchedule,
deleteSchedule,
pollCi,
setSharedKey,
};
+1
View File
@@ -18,6 +18,7 @@ dependencies = [
"pydantic>=2.10,<3.0",
"pydantic-settings>=2.7,<3.0",
"httpx>=0.28,<0.29",
"cryptography>=41,<46",
]
[project.optional-dependencies]
+11 -1
View File
@@ -14,7 +14,16 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from ..config import get_settings
from .routes import agents, approvals, commands, hosts, interaction, projects, shared
from .routes import (
agents,
approvals,
commands,
hosts,
interaction,
projects,
schedules,
shared,
)
_STATIC_DIR = Path(__file__).parent / "static"
@@ -38,6 +47,7 @@ def create_app() -> FastAPI:
app.include_router(approvals.router)
app.include_router(commands.router)
app.include_router(hosts.router)
app.include_router(schedules.router)
app.include_router(shared.router)
# Optional CORS, only for operators who host the UI on a different origin than the
+58 -14
View File
@@ -1,9 +1,11 @@
"""Forge-host registry — the web-managed replacement for the hardcoded host->token-env map.
"""Git-server registry — hosts, their token env mapping, and their own credentials.
Registering a host lets ``control.credentials`` inject the right per-host token env var
(and scope the git credential helper) for self-hosted forges without a code change. The
registry only holds the env-var *name* and metadata — never a secret (secrets stay behind
``credential_ref`` pointers). Reads take the normal token; writes take the admin token.
Each row maps a host to the token env var to inject at spawn, and may carry the
server's credentials itself: a forge token (encrypted with ``HANDLER_SECRET_KEY``
before it reaches the database) and an ed25519 deploy keypair whose public half is
returned so the operator can paste it into the forge. The API never returns the token
or the private key — responses expose only ``has_token`` and ``ssh_public_key``.
Reads take the normal token; writes take the admin token.
"""
from __future__ import annotations
@@ -12,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError
from ... import secretstore, sshkeys
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import HostIn, HostOut, HostUpdateIn
@@ -26,14 +29,28 @@ def _get_or_404(conn: Connection, hostname: str) -> dict:
return host
def _public(row: dict) -> dict:
"""A host row shaped for responses: secrets replaced by the ``has_token`` flag."""
return {**row, "has_token": bool(row.get("token_enc"))}
def _encrypt_or_400(value: str, what: str) -> str:
try:
return secretstore.encrypt(value)
except secretstore.SecretStoreError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=f"cannot store {what}: {exc}"
) from exc
@router.get("", response_model=list[HostOut])
def list_hosts(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_hosts(conn)
return [_public(h) for h in repo.list_hosts(conn)]
@router.get("/{hostname}", response_model=HostOut)
def get_host(hostname: str, conn: Connection = Depends(db_conn)) -> dict:
return _get_or_404(conn, hostname)
return _public(_get_or_404(conn, hostname))
@router.post(
@@ -43,13 +60,25 @@ def get_host(hostname: str, conn: Connection = Depends(db_conn)) -> dict:
def create_host(body: HostIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_host(conn, body.hostname) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"host '{body.hostname}' exists")
token_enc = _encrypt_or_400(body.token, "the forge token") if body.token else None
ssh_public_key = ssh_private_key_enc = None
if body.generate_ssh_key:
private_key, ssh_public_key = sshkeys.generate_keypair(f"handler@{body.hostname}")
ssh_private_key_enc = _encrypt_or_400(private_key, "the SSH private key")
try:
return repo.create_host(
conn,
hostname=body.hostname,
forge_type=body.forge_type,
token_env_var=body.token_env_var,
base_url=body.base_url,
return _public(
repo.create_host(
conn,
hostname=body.hostname,
forge_type=body.forge_type,
token_env_var=body.token_env_var,
base_url=body.base_url,
token_enc=token_enc,
ssh_public_key=ssh_public_key,
ssh_private_key_enc=ssh_private_key_enc,
)
)
except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="host exists") from exc
@@ -60,7 +89,22 @@ def update_host(
hostname: str, body: HostUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
_get_or_404(conn, hostname)
return repo.update_host(conn, hostname, **body.model_dump(exclude_unset=True))
fields = body.model_dump(
exclude_unset=True,
exclude={"token", "clear_token", "regenerate_ssh_key", "clear_ssh_key"},
)
if body.token:
fields["token_enc"] = _encrypt_or_400(body.token, "the forge token")
elif body.clear_token:
fields["token_enc"] = None
if body.regenerate_ssh_key:
private_key, public_key = sshkeys.generate_keypair(f"handler@{hostname}")
fields["ssh_public_key"] = public_key
fields["ssh_private_key_enc"] = _encrypt_or_400(private_key, "the SSH private key")
elif body.clear_ssh_key:
fields["ssh_public_key"] = None
fields["ssh_private_key_enc"] = None
return _public(repo.update_host(conn, hostname, **fields))
@router.delete("/{hostname}", dependencies=[Depends(require_admin)])
+79 -8
View File
@@ -7,13 +7,17 @@ Reads and row registration take the normal token; edits/deletes and the enqueue
from __future__ import annotations
import os
import re
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from sqlalchemy.exc import IntegrityError
from ...config import get_settings
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import CommandOut, ProjectIn, ProjectOut, ProjectUpdateIn
from ..schemas import CommandOut, ProjectCreatedOut, ProjectIn, ProjectOut, ProjectUpdateIn
router = APIRouter(prefix="/projects", tags=["projects"], dependencies=[Depends(require_auth)])
@@ -35,21 +39,69 @@ def get_project(project_id: str, conn: Connection = Depends(db_conn)) -> dict:
return _get_or_404(conn, project_id)
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
def _slug(value: str) -> str:
slug = re.sub(r"[^a-z0-9._-]+", "-", value.lower()).strip("-.")
return slug or "project"
def _from_git_server(body: ProjectIn, conn: Connection) -> tuple[str, str, str]:
"""(id, root_dir, git_remote) for git-server mode.
The remote prefers ssh when the server has a deploy key (that's what the key is
for), else https (served by the stored token through the credential helper). The
clone lands under ``PROJECTS_ROOT/<id>`` — stateless workflows don't care where.
"""
host = repo.get_host(conn, body.git_server.strip().lower())
if host is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=(
f"git server '{body.git_server}' is not registered — "
"add it under Git Servers first"
),
)
project_id = body.id or _slug(body.repo.split("/", 1)[1])
if body.git_remote:
remote = body.git_remote
elif host.get("ssh_public_key"):
remote = f"git@{host['hostname']}:{body.repo}.git"
else:
base = (host.get("base_url") or f"https://{host['hostname']}").rstrip("/")
remote = f"{base}/{body.repo}.git"
root_dir = os.path.join(get_settings().projects_root, project_id)
return project_id, root_dir, remote
@router.post("", response_model=ProjectCreatedOut, status_code=status.HTTP_201_CREATED)
def create_project(body: ProjectIn, conn: Connection = Depends(db_conn)) -> dict:
if repo.get_project(conn, body.id) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"project '{body.id}' exists")
if body.git_server:
project_id, root_dir, git_remote = _from_git_server(body, conn)
else:
project_id, root_dir, git_remote = body.id, body.root_dir, body.git_remote
if repo.get_project(conn, project_id) is not None:
raise HTTPException(status.HTTP_409_CONFLICT, detail=f"project '{project_id}' exists")
try:
return repo.create_project(
project = repo.create_project(
conn,
project_id=body.id,
root_dir=body.root_dir,
git_remote=body.git_remote,
project_id=project_id,
root_dir=root_dir,
git_remote=git_remote,
credential_ref=body.credential_ref,
)
except IntegrityError as exc: # pragma: no cover - guarded above
raise HTTPException(status.HTTP_409_CONFLICT, detail="project exists") from exc
# Git-server mode always pulls: the worker clones (or fast-forwards) the repo into
# root_dir. The command id lets the client watch the clone land.
sync_command_id = None
if git_remote:
command = repo.enqueue_command(
conn, "sync", project_id=project_id, requested_by="operator:web"
)
sync_command_id = command["id"]
return {**project, "sync_command_id": sync_command_id}
@router.patch("/{project_id}", response_model=ProjectOut, dependencies=[Depends(require_admin)])
def update_project(
@@ -86,6 +138,25 @@ def enqueue_forge_init(
)
@router.post(
"/{project_id}/sync",
response_model=CommandOut,
status_code=status.HTTP_202_ACCEPTED,
dependencies=[Depends(require_admin)],
)
def enqueue_sync(project_id: str, conn: Connection = Depends(db_conn)) -> dict:
"""Clone-or-pull the project's repo now (the worker executes it)."""
project = _get_or_404(conn, project_id)
if not project.get("git_remote"):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"project '{project_id}' has no git_remote to sync from",
)
return repo.enqueue_command(
conn, "sync", project_id=project_id, requested_by="operator:web"
)
@router.post(
"/{project_id}/poll-ci",
response_model=CommandOut,
+92
View File
@@ -0,0 +1,92 @@
"""Recurring agent spawns (schedules).
A schedule fires an ordinary ``spawn`` command every ``interval_seconds`` — the worker
sweeps due rows on each loop pass and enqueues the spawn with a timestamped agent name,
so each run is a fresh, stateless agent. The canonical use: a standing prompt like
"Read @notes.md, continue from there, and overwrite that file before finishing", where
the file in the repo carries the state between runs.
Reads take the normal token; writes take the admin token (a schedule ultimately runs
``claude`` in the control container).
"""
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Connection
from ...db import repository as repo
from ..deps import db_conn, require_admin, require_auth
from ..schemas import ScheduleIn, ScheduleOut, ScheduleUpdateIn
router = APIRouter(tags=["schedules"], dependencies=[Depends(require_auth)])
def _schedule_or_404(conn: Connection, schedule_id: int) -> dict:
schedule = repo.get_schedule(conn, schedule_id)
if schedule is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"schedule {schedule_id} not found"
)
return schedule
@router.get("/schedules", response_model=list[ScheduleOut])
def list_all_schedules(conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_schedules(conn)
@router.get("/projects/{project_id}/schedules", response_model=list[ScheduleOut])
def list_project_schedules(project_id: str, conn: Connection = Depends(db_conn)) -> list[dict]:
return repo.list_schedules(conn, project_id)
@router.post(
"/projects/{project_id}/schedules",
response_model=ScheduleOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_admin)],
)
def create_schedule(
project_id: str, body: ScheduleIn, conn: Connection = Depends(db_conn)
) -> dict:
if repo.get_project(conn, project_id) is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"project '{project_id}' not found"
)
# next_run_at starts at now, so the first run fires on the worker's next pass — the
# operator sees the schedule work immediately instead of waiting a full interval.
return repo.create_schedule(
conn,
project_id=project_id,
name_prefix=body.name_prefix.strip(),
task=body.task,
interval_seconds=body.interval_seconds,
next_run_at=datetime.now(UTC),
role=body.role,
worktree=body.worktree,
subdir=body.subdir,
enabled=body.enabled,
)
@router.patch(
"/schedules/{schedule_id}",
response_model=ScheduleOut,
dependencies=[Depends(require_admin)],
)
def update_schedule(
schedule_id: int, body: ScheduleUpdateIn, conn: Connection = Depends(db_conn)
) -> dict:
_schedule_or_404(conn, schedule_id)
fields = body.model_dump(exclude_unset=True)
return repo.update_schedule(conn, schedule_id, **fields)
@router.delete("/schedules/{schedule_id}", dependencies=[Depends(require_admin)])
def delete_schedule(schedule_id: int, conn: Connection = Depends(db_conn)) -> dict:
_schedule_or_404(conn, schedule_id)
repo.delete_schedule(conn, schedule_id)
return {"deleted": schedule_id}
+95 -3
View File
@@ -4,10 +4,11 @@ mapping straight in; timestamps serialize as ISO-8601.
from __future__ import annotations
import re
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Roles + forge families mirrored from db.tables; Literal gives clean 422s on bad input.
Role = Literal["junior", "senior", "deploy"]
@@ -34,17 +35,47 @@ def _validate_web_credential_ref(value: str | None) -> str | None:
return value
# "owner/name" — the only thing an operator types when adding a project from a
# configured git server.
_REPO_RE = re.compile(r"^[\w.-]+/[\w.-]+$")
class ProjectIn(BaseModel):
id: str
root_dir: str
"""Register a project, in one of two modes.
**Git-server mode** (preferred): pass ``git_server`` (a registered host) and
``repo`` (``owner/name``). The API derives the remote URL from the server's config
(ssh when the server has a deploy key, https otherwise), computes ``root_dir``
under ``PROJECTS_ROOT``, and enqueues a ``sync`` command so the worker clones it
where the clone lands on disk is Handler's concern, not the operator's.
**Manual mode**: pass ``root_dir`` (an existing checkout) as before.
"""
id: str | None = None # defaults to a slug of the repo name in git-server mode
root_dir: str | None = None
git_remote: str | None = None
credential_ref: str | None = None
git_server: str | None = None
repo: str | None = None
@field_validator("credential_ref")
@classmethod
def _check_credential_ref(cls, v: str | None) -> str | None:
return _validate_web_credential_ref(v)
@model_validator(mode="after")
def _check_mode(self) -> ProjectIn:
if self.git_server:
if not self.repo or not _REPO_RE.match(self.repo.strip()):
raise ValueError("git-server mode needs 'repo' as owner/name")
self.repo = self.repo.strip()
elif not self.root_dir or not self.root_dir.strip():
raise ValueError("pass either root_dir, or git_server + repo (owner/name)")
if not self.git_server and not self.id:
raise ValueError("id is required when registering by root_dir")
return self
class ProjectUpdateIn(BaseModel):
"""Editable project columns; omit a field to leave it unchanged."""
@@ -69,6 +100,12 @@ class ProjectOut(BaseModel):
created_at: datetime
class ProjectCreatedOut(ProjectOut):
"""Registration response; carries the enqueued clone command in git-server mode."""
sync_command_id: int | None = None
class AgentIn(BaseModel):
name: str
working_dir: str
@@ -122,12 +159,21 @@ class HostIn(BaseModel):
forge_type: ForgeType
token_env_var: str | None = None
base_url: str | None = None
# Write-only credentials. ``token`` is encrypted (HANDLER_SECRET_KEY) before it
# touches the database and is never returned; ``generate_ssh_key`` mints a per-server
# ed25519 deploy keypair — the public half comes back in ``ssh_public_key``.
token: str | None = None
generate_ssh_key: bool = False
class HostUpdateIn(BaseModel):
forge_type: ForgeType | None = None
token_env_var: str | None = None
base_url: str | None = None
token: str | None = None
clear_token: bool = False
regenerate_ssh_key: bool = False
clear_ssh_key: bool = False
class HostOut(BaseModel):
@@ -137,6 +183,52 @@ class HostOut(BaseModel):
forge_type: str
token_env_var: str | None = None
base_url: str | None = None
# The public key to paste into the forge (deploy key / account key). The private
# key and the token never leave the server; ``has_token`` says one is stored.
ssh_public_key: str | None = None
has_token: bool = False
created_at: datetime
class ScheduleIn(BaseModel):
"""A recurring agent spawn: every ``interval_seconds``, run ``task`` as a fresh
agent named ``<name_prefix>-<timestamp>``. The first run fires on the worker's next
pass (``next_run_at`` starts at now)."""
name_prefix: str = Field(min_length=1)
task: str = Field(min_length=1)
interval_seconds: int = Field(ge=10)
role: Role | None = None
worktree: str | None = None
subdir: str | None = None
enabled: bool = True
class ScheduleUpdateIn(BaseModel):
name_prefix: str | None = Field(default=None, min_length=1)
task: str | None = Field(default=None, min_length=1)
interval_seconds: int | None = Field(default=None, ge=10)
role: Role | None = None
worktree: str | None = None
subdir: str | None = None
enabled: bool | None = None
class ScheduleOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
project_id: str
name_prefix: str
task: str
role: Role | None = None
worktree: str | None = None
subdir: str | None = None
interval_seconds: int
enabled: bool
next_run_at: datetime
last_run_at: datetime | None = None
last_command_id: int | None = None
created_at: datetime
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
View File
@@ -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-67c3ee7b71a251cf.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[3815,[\"931\",\"static/chunks/app/page-67c3ee7b71a251cf.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"GQbm47pHcnN5aChqrNLgi\",\"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-b4a814bde4e81779.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[8423,[\"931\",\"static/chunks/app/page-b4a814bde4e81779.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"twm_FqRjMWdGYZ-lbTh7o\",\"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>
+2 -2
View File
@@ -1,7 +1,7 @@
2:I[9107,[],"ClientPageRoot"]
3:I[3815,["931","static/chunks/app/page-67c3ee7b71a251cf.js"],"default",1]
3:I[8423,["931","static/chunks/app/page-b4a814bde4e81779.js"],"default",1]
4:I[4707,[],""]
5:I[6423,[],""]
0:["GQbm47pHcnN5aChqrNLgi",[[["",{"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:["twm_FqRjMWdGYZ-lbTh7o",[[["",{"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
+6
View File
@@ -35,6 +35,12 @@ class Settings(BaseSettings):
# Optional generic webhook target for the Notification hook. No-op when unset.
webhook_url: str | None = None
# Symmetric key (Fernet, urlsafe-base64) for the DB-backed secret store: git-server
# tokens and SSH private keys are encrypted with it at rest. Generate one with
# ``python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"``.
# Unset => storing/decrypting secrets in the database is refused with a clear error.
handler_secret_key: str = ""
# Base directory under which per-project roots / agent worktrees live.
projects_root: str = "./projects"
+20 -1
View File
@@ -15,7 +15,7 @@ import sys
from ..db import repository as repo
from ..db.engine import connection
from . import poller, skills_gen, spawn, tmux, worker
from . import poller, reposync, skills_gen, spawn, tmux, worker
def _cmd_spawn(args: argparse.Namespace) -> int:
@@ -191,6 +191,21 @@ def _cmd_forge_init(args: argparse.Namespace) -> int:
return 0
def _cmd_sync(args: argparse.Namespace) -> int:
with connection() as conn:
project = repo.get_project(conn, args.project)
if project is None:
print(f"error: project '{args.project}' not registered", file=sys.stderr)
return 1
try:
result = reposync.sync_project(project)
except reposync.SyncError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
print(f"{result['action']} '{args.project}' at {result['root_dir']}")
return 0
def _cmd_worker(args: argparse.Namespace) -> int:
print(
f"worker starting (poll={args.interval}s, ci-sweep={args.ci_interval}s); "
@@ -254,6 +269,10 @@ def build_parser() -> argparse.ArgumentParser:
p_forge.add_argument("--no-commit", action="store_true", help="write but don't git-commit")
p_forge.set_defaults(func=_cmd_forge_init)
p_sync = sub.add_parser("sync", help="clone or fast-forward a project's repo")
p_sync.add_argument("--project", required=True)
p_sync.set_defaults(func=_cmd_sync)
p_worker = sub.add_parser(
"worker", help="run the control worker: drain enqueued commands + sweep CI"
)
+60 -8
View File
@@ -48,8 +48,8 @@ class CredentialError(Exception):
# Schemes an operator may set from the web. ``cmd:`` is deliberately excluded there — it
# executes an arbitrary command in the control container at spawn — so the API rejects it
# while the CLI/DB path still allows it (see api/schemas.py). ``db:`` is reserved for the
# future encrypted secret store (not yet resolvable).
# while the CLI/DB path still allows it (see api/schemas.py). ``db:host:<hostname>`` reads
# a git server's encrypted stored token (see ``handler.secretstore``).
WEB_SETTABLE_SCHEMES = ("env", "file", "db")
@@ -87,12 +87,38 @@ def _resolve_cmd(rest: str) -> str:
def _resolve_db(rest: str) -> str:
# Reserved for the encrypted secret store (a later phase); the ``db:`` scheme and this
# dispatch seam land now so that store is a drop-in without touching every caller.
raise CredentialError(
f"credential_ref 'db:{rest}' scheme is reserved for the encrypted secret store, "
"which is not enabled yet"
)
# The encrypted secret store: ``db:host:<hostname>`` reads the git server's stored
# token (``forge_hosts.token_enc``) and decrypts it with HANDLER_SECRET_KEY.
kind, _, ident = rest.partition(":")
ident = ident.strip().lower()
if kind != "host" or not ident:
raise CredentialError(
f"credential_ref 'db:{rest}' is malformed; db: refs take the form "
"db:host:<hostname>"
)
from ..db import repository as repo
from ..db.engine import connection
with connection() as conn:
row = repo.get_host(conn, ident)
if row is None:
raise CredentialError(f"credential_ref 'db:{rest}': git server '{ident}' not registered")
if not row.get("token_enc"):
raise CredentialError(
f"credential_ref 'db:{rest}': git server '{ident}' has no stored token"
)
return _decrypt_host_token(row)
def _decrypt_host_token(host_row: dict) -> str:
from .. import secretstore
try:
return secretstore.decrypt(host_row["token_enc"])
except secretstore.SecretStoreError as exc:
raise CredentialError(
f"stored token for git server '{host_row['hostname']}': {exc}"
) from exc
# Scheme -> resolver. Adding the encrypted store later means wiring _resolve_db to it,
@@ -129,6 +155,32 @@ def resolve(credential_ref: str | None) -> str | None:
return resolver(rest)
def resolve_for_project(project: dict, conn: Connection | None = None) -> str | None:
"""The token a project's agents/git should use.
The project's own ``credential_ref`` wins when set; otherwise fall back to the
stored (encrypted) token of the git server matching the project's remote — so a
project registered against a configured git server needs no per-repo credentials.
Returns ``None`` when neither exists.
"""
if project.get("credential_ref"):
return resolve(project["credential_ref"])
host = remote_host(project.get("git_remote"))
if not host:
return None
row = _host_row(conn, host) if conn is not None else _host_row_fresh(host)
if row and row.get("token_enc"):
return _decrypt_host_token(row)
return None
def _host_row_fresh(host: str) -> dict | None:
from ..db.engine import connection
with connection() as conn:
return _host_row(conn, host)
def remote_host(git_remote: str | None) -> str | None:
"""Parse the hostname from an https or scp-style (``git@host:path``) remote."""
if not git_remote:
+48 -7
View File
@@ -1,30 +1,42 @@
"""Thin ``git`` wrapper — the mock seam for the git operations Handler itself runs.
Handler runs git for a few narrow, non-mutating-or-config-only jobs: reading the
current branch and HEAD sha (so the push/approval gates know *what* is being pushed or
merged), and installing a credential helper at spawn (README 3.7). Agents run their own
git for the actual work; this seam is only Handler's own use, kept behind one module so
tests never touch a real repo.
Handler runs git for a few narrow jobs: reading the current branch and HEAD sha (so the
push/approval gates know *what* is being pushed or merged), installing a credential
helper at spawn (README 3.7), and cloning/pulling a project's repo for the stateless
"always pull" workflow. Agents run their own git for the actual work; this seam is only
Handler's own use, kept behind one module so tests never touch a real repo.
"""
from __future__ import annotations
import os
import subprocess
from ..config import get_settings
_TIMEOUT = 30
# Clones and pulls move real data over the network; give them room.
_NETWORK_TIMEOUT = 600
def _run(args: list[str], cwd: str) -> tuple[bool, str]:
def _run(
args: list[str],
cwd: str | None,
env: dict[str, str] | None = None,
timeout: int = _TIMEOUT,
) -> tuple[bool, str]:
git = get_settings().git_bin
run_env = None
if env:
run_env = {**os.environ, **env}
try:
result = subprocess.run(
[git, *args],
cwd=cwd,
capture_output=True,
text=True,
timeout=_TIMEOUT,
timeout=timeout,
env=run_env,
)
except FileNotFoundError:
return False, f"'{git}' not found"
@@ -55,3 +67,32 @@ def add(cwd: str, paths: list[str]) -> tuple[bool, str]:
def commit(cwd: str, message: str) -> tuple[bool, str]:
return _run(["commit", "-m", message], cwd)
def is_repo(path: str) -> bool:
"""Whether ``path`` already holds a clone (cheap check, no subprocess)."""
return os.path.isdir(os.path.join(path, ".git"))
def clone(
remote: str,
dest: str,
env: dict[str, str] | None = None,
config: list[tuple[str, str]] | None = None,
) -> tuple[bool, str]:
"""``git clone remote dest``, with optional one-shot ``-c key=value`` config.
The ``-c`` config (e.g. the scoped credential helper) applies only to the clone
command itself; persistent repo config is installed afterwards via
:func:`config_local`.
"""
args: list[str] = []
for key, value in config or []:
args += ["-c", f"{key}={value}"]
args += ["clone", remote, dest]
return _run(args, cwd=None, env=env, timeout=_NETWORK_TIMEOUT)
def pull_ff(cwd: str, env: dict[str, str] | None = None) -> tuple[bool, str]:
"""Fast-forward-only pull — never merges, so a diverged clone fails loudly."""
return _run(["pull", "--ff-only"], cwd, env=env, timeout=_NETWORK_TIMEOUT)
+107
View File
@@ -0,0 +1,107 @@
"""Clone-or-pull a project's repository — the stateless "always pull" step.
Where a clone lands on disk is Handler's concern, not the operator's: the API computes
``root_dir`` under ``PROJECTS_ROOT`` at registration, and this module makes the checkout
real. Auth comes from the project's git server: an HTTPS remote uses the stored token
through the scoped credential helper (token only ever in the process environment); an
SSH remote uses the server's deploy key, materialized to a 0600 file and pinned via
``GIT_SSH_COMMAND`` / repo-local ``core.sshCommand``.
"""
from __future__ import annotations
import os
from sqlalchemy import Connection
from .. import sshkeys
from ..db.engine import connection
from . import credentials, gitops
class SyncError(Exception):
"""Raised when a project's repo cannot be cloned or pulled."""
def _auth_context(
project: dict, conn: Connection
) -> tuple[dict[str, str], list[tuple[str, str]]]:
"""(env, one-shot git config) that authenticates git against the project's remote."""
remote = project["git_remote"]
try:
token = credentials.resolve_for_project(project, conn)
except credentials.CredentialError as exc:
raise SyncError(str(exc)) from exc
env: dict[str, str] = credentials.credential_env(token, remote, conn)
config: list[tuple[str, str]] = []
if token:
helper = credentials.git_credential_config(remote, conn)
if helper is not None:
config.append(helper)
env.update(ssh_env(remote, conn))
return env, config
def ssh_env(git_remote: str | None, conn: Connection) -> dict[str, str]:
"""``GIT_SSH_COMMAND`` pinned to the server's deploy key, when one is stored.
Empty for hosts without a stored key (ambient ssh config still applies). Raises
:class:`SyncError` when a stored key exists but cannot be decrypted a broken
secret should fail loudly, not silently fall back to unauthenticated ssh.
"""
host = credentials.remote_host(git_remote)
if not host:
return {}
from ..db import repository as repo
host_row = repo.get_host(conn, host)
if not host_row or not host_row.get("ssh_private_key_enc"):
return {}
from .. import secretstore
try:
private_key = secretstore.decrypt(host_row["ssh_private_key_enc"])
except secretstore.SecretStoreError as exc:
raise SyncError(f"SSH key for git server '{host}': {exc}") from exc
key_path = sshkeys.materialize_private_key(host, private_key)
return {"GIT_SSH_COMMAND": sshkeys.git_ssh_command(key_path)}
def sync_project(project: dict, conn: Connection | None = None) -> dict:
"""Clone the project's remote into ``root_dir``, or fast-forward an existing clone.
Idempotent by design scheduled/stateless runs call this before every spawn so the
working tree always starts from the remote's latest state. Raises :class:`SyncError`
when the project has no remote or git fails.
"""
remote = project.get("git_remote")
root = project["root_dir"]
if not remote:
raise SyncError(f"project '{project['id']}' has no git_remote to sync from")
if conn is None:
with connection() as fresh:
env, config = _auth_context(project, fresh)
else:
env, config = _auth_context(project, conn)
if gitops.is_repo(root):
ok, out = gitops.pull_ff(root, env=env)
if not ok:
raise SyncError(f"pull failed in {root}: {out}")
return {"action": "pulled", "root_dir": root, "detail": out}
os.makedirs(os.path.dirname(root) or ".", exist_ok=True)
ok, out = gitops.clone(remote, root, env=env, config=config)
if not ok:
raise SyncError(f"clone of {remote} failed: {out}")
# Persist auth into the fresh clone so later pulls — and the agents working in it —
# authenticate without re-deriving anything: the scoped credential helper for HTTPS,
# the pinned deploy key for SSH.
for key, value in config:
gitops.config_local(root, key, value)
if "GIT_SSH_COMMAND" in env:
gitops.config_local(root, "core.sshCommand", env["GIT_SSH_COMMAND"])
return {"action": "cloned", "root_dir": root, "detail": out}
+29 -5
View File
@@ -15,7 +15,7 @@ import tomllib
from ..config import get_settings
from ..db import repository as repo
from ..db.engine import connection
from . import credentials, forge, gitops, settings_gen, tmux, worktree
from . import credentials, forge, gitops, reposync, settings_gen, tmux, worktree
class SpawnError(Exception):
@@ -79,6 +79,7 @@ def spawn(
role: str | None = None,
) -> dict:
"""Create and launch an agent. Returns the agent row."""
sync_note = None
with connection() as conn:
project = repo.get_project(conn, project_id)
if project is None:
@@ -86,16 +87,34 @@ def spawn(
if repo.get_agent_by_name(conn, project_id, name) is not None:
raise SpawnError(f"agent '{name}' already exists in project '{project_id}'")
# Stateless workflows: start every run from the remote's latest state. An
# existing clone is fast-forwarded (failure degrades to a note — a stale tree is
# usable, an offline forge shouldn't brick spawning); a missing/empty root is
# cloned, and that failing is fatal (there is nothing to run against). A
# non-empty root that isn't a git repo is left alone — it's manually managed.
root = project["root_dir"]
if project.get("git_remote"):
if gitops.is_repo(root):
try:
reposync.sync_project(project, conn)
except reposync.SyncError as exc:
sync_note = str(exc)
elif not os.path.isdir(root) or not os.listdir(root):
try:
reposync.sync_project(project, conn)
except reposync.SyncError as exc:
raise SpawnError(str(exc)) from exc
working_dir = worktree.resolve_working_dir(
project["root_dir"], name, subdir=subdir, worktree_branch=worktree_branch
)
# Hard gates before any state is written or process launched: the test task must
# exist, and a configured credential_ref must actually resolve — a broken
# pointer should fail fast, not leave an orphaned agent row behind.
# 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)
try:
token = credentials.resolve(project.get("credential_ref"))
token = credentials.resolve_for_project(project, conn)
except credentials.CredentialError as exc:
raise SpawnError(str(exc)) from exc
@@ -124,6 +143,11 @@ def spawn(
env.update(credentials.credential_env(token, project.get("git_remote"), conn))
if token:
_install_git_credentials(working_dir, project.get("git_remote"), conn)
# SSH remotes: pin the agent's git to the server's deploy key, when one is stored.
try:
env.update(reposync.ssh_env(project.get("git_remote"), conn))
except reposync.SyncError as exc:
raise SpawnError(str(exc)) from exc
# Verify the pinned forge version, if one is configured. Non-fatal: a version drift
# is recorded as a warning rather than blocking the spawn, since not every agent
@@ -133,7 +157,7 @@ def spawn(
session = tmux.session_name(project_id, name)
command = _claude_command(task, settings_path)
tmux.new_session(session, cwd=working_dir, command=command, env=env)
agent = {**agent, "forge_note": forge_note}
agent = {**agent, "forge_note": forge_note, "sync_note": sync_note}
return agent
+60 -1
View File
@@ -16,10 +16,11 @@ from __future__ import annotations
import os
import time
from datetime import UTC, datetime, timedelta
from ..db import repository as repo
from ..db.engine import connection
from . import gitops, poller, skills_gen, spawn
from . import gitops, poller, reposync, skills_gen, spawn
class CommandError(Exception):
@@ -153,6 +154,20 @@ def _cmd_poll_ci(command: dict) -> dict:
return poller.sweep(project_id=command.get("project_id"))
def _cmd_sync(command: dict) -> dict:
project_id = command.get("project_id")
if not project_id:
raise CommandError("sync requires project_id")
with connection() as conn:
project = repo.get_project(conn, project_id)
if project is None:
raise CommandError(f"project '{project_id}' not registered")
try:
return reposync.sync_project(project)
except reposync.SyncError as exc:
raise CommandError(str(exc)) from exc
_DISPATCH = {
"spawn": _cmd_spawn,
"kill": _cmd_kill,
@@ -161,6 +176,7 @@ _DISPATCH = {
"reject": _cmd_reject,
"forge_init": _cmd_forge_init,
"poll_ci": _cmd_poll_ci,
"sync": _cmd_sync,
}
@@ -187,6 +203,45 @@ def _run_one(command: dict) -> None:
repo.finish_command(conn, command["id"], "failed", error=str(exc))
def fire_due_schedules(now: datetime | None = None) -> int:
"""Enqueue a spawn command for every schedule whose ``next_run_at`` has passed.
Each firing becomes an ordinary queued ``spawn`` (visible in the Activity audit
trail) with a timestamped agent name ``<prefix>-YYYYMMDD-HHMMSS`` so repeated
runs never collide with the per-project name uniqueness. The schedule is advanced
*before* the spawn executes: missed intervals collapse into a single run, and a
failing spawn shows up as a failed command rather than a hot retry loop.
"""
now = now or datetime.now(UTC)
fired = 0
with connection() as conn:
due = repo.due_schedules(conn, now)
for sched in due:
name = f"{sched['name_prefix']}-{now.strftime('%Y%m%d-%H%M%S')}"
payload: dict = {"task": sched["task"]}
for key in ("role", "worktree", "subdir"):
if sched.get(key):
payload[key] = sched[key]
with connection() as conn:
command = repo.enqueue_command(
conn,
"spawn",
project_id=sched["project_id"],
agent_name=name,
payload=payload,
requested_by=f"schedule:{sched['id']}",
)
repo.mark_schedule_run(
conn,
sched["id"],
last_run_at=now,
next_run_at=now + timedelta(seconds=sched["interval_seconds"]),
last_command_id=command["id"],
)
fired += 1
return fired
def drain(worker_id: str, limit: int | None = None) -> int:
"""Claim and run queued commands until the queue is empty (or ``limit`` reached).
@@ -220,6 +275,10 @@ def run(
last_ci = 0.0
count = 0
while iterations is None or count < iterations:
try:
fire_due_schedules()
except Exception: # noqa: BLE001 - a schedule hiccup must not kill the worker
pass
did_work = drain(worker_id) > 0
now = time.monotonic()
if ci_interval > 0 and now - last_ci >= ci_interval:
+112 -1
View File
@@ -29,6 +29,7 @@ from .tables import (
forge_hosts,
log_entries,
projects,
schedules,
shared_context,
)
from .upsert import upsert_checkmark
@@ -458,6 +459,9 @@ def create_host(
forge_type: str,
token_env_var: str | None = None,
base_url: str | None = None,
token_enc: str | None = None,
ssh_public_key: str | None = None,
ssh_private_key_enc: str | None = None,
) -> dict:
conn.execute(
forge_hosts.insert().values(
@@ -465,6 +469,9 @@ def create_host(
forge_type=forge_type,
token_env_var=token_env_var,
base_url=base_url,
token_enc=token_enc,
ssh_public_key=ssh_public_key,
ssh_private_key_enc=ssh_private_key_enc,
created_at=_now(),
)
)
@@ -472,7 +479,14 @@ def create_host(
def update_host(conn: Connection, hostname: str, **fields: Any) -> dict | None:
allowed = {"forge_type", "token_env_var", "base_url"}
allowed = {
"forge_type",
"token_env_var",
"base_url",
"token_enc",
"ssh_public_key",
"ssh_private_key_enc",
}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(
@@ -484,3 +498,100 @@ def update_host(conn: Connection, hostname: str, **fields: Any) -> dict | None:
def delete_host(conn: Connection, hostname: str) -> bool:
result = conn.execute(forge_hosts.delete().where(forge_hosts.c.hostname == hostname))
return result.rowcount > 0
# ------------------------------------------------------------------ schedules (recurring)
def list_schedules(conn: Connection, project_id: str | None = None) -> list[dict]:
stmt = select(schedules)
if project_id is not None:
stmt = stmt.where(schedules.c.project_id == project_id)
rows = conn.execute(stmt.order_by(schedules.c.id)).all()
return [dict(r._mapping) for r in rows]
def get_schedule(conn: Connection, schedule_id: int) -> dict | None:
row = conn.execute(select(schedules).where(schedules.c.id == schedule_id)).first()
return _row_to_dict(row)
def create_schedule(
conn: Connection,
project_id: str,
name_prefix: str,
task: str,
interval_seconds: int,
next_run_at: datetime,
role: str | None = None,
worktree: str | None = None,
subdir: str | None = None,
enabled: bool = True,
) -> dict:
result = conn.execute(
schedules.insert().values(
project_id=project_id,
name_prefix=name_prefix,
task=task,
role=role,
worktree=worktree,
subdir=subdir,
interval_seconds=interval_seconds,
enabled=enabled,
next_run_at=next_run_at,
created_at=_now(),
)
)
return get_schedule(conn, result.inserted_primary_key[0])
def update_schedule(conn: Connection, schedule_id: int, **fields: Any) -> dict | None:
allowed = {
"name_prefix",
"task",
"role",
"worktree",
"subdir",
"interval_seconds",
"enabled",
"next_run_at",
}
values = {k: v for k, v in fields.items() if k in allowed}
if values:
conn.execute(
schedules.update().where(schedules.c.id == schedule_id).values(**values)
)
return get_schedule(conn, schedule_id)
def delete_schedule(conn: Connection, schedule_id: int) -> bool:
result = conn.execute(schedules.delete().where(schedules.c.id == schedule_id))
return result.rowcount > 0
def due_schedules(conn: Connection, now: datetime, limit: int = 50) -> list[dict]:
"""Enabled schedules whose ``next_run_at`` has passed — the worker's sweep input."""
rows = conn.execute(
select(schedules)
.where(schedules.c.enabled == True, schedules.c.next_run_at <= now) # noqa: E712
.order_by(schedules.c.next_run_at.asc())
.limit(limit)
).all()
return [dict(r._mapping) for r in rows]
def mark_schedule_run(
conn: Connection,
schedule_id: int,
last_run_at: datetime,
next_run_at: datetime,
last_command_id: int | None = None,
) -> None:
"""Advance a schedule after firing it (missed intervals collapse into one run)."""
conn.execute(
schedules.update()
.where(schedules.c.id == schedule_id)
.values(
last_run_at=last_run_at, next_run_at=next_run_at, last_command_id=last_command_id
)
)
+39 -2
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
from sqlalchemy import (
BigInteger,
Boolean,
CheckConstraint,
Column,
ForeignKey,
@@ -32,7 +33,9 @@ CI_STATUSES = ("not_applicable", "pending", "pass", "fail")
VISIBILITIES = ("project", "global")
APPROVAL_STATUSES = ("approved", "rejected")
# The control actions the API enqueues and the control-container worker executes.
COMMAND_TYPES = ("spawn", "kill", "resume", "approve", "reject", "forge_init", "poll_ci")
# ``sync`` clones a project's repo into its root_dir (or fast-forward pulls an existing
# clone) using the git server's stored credentials — the API can't run git itself.
COMMAND_TYPES = ("spawn", "kill", "resume", "approve", "reject", "forge_init", "poll_ci", "sync")
COMMAND_STATUSES = ("queued", "running", "done", "failed")
# Forge families a host can belong to (drives per-host token env conventions).
FORGE_TYPES = ("github", "gitlab", "gitea", "forgejo", "bitbucket")
@@ -177,10 +180,18 @@ commands = Table(
Index("ix_commands_status_id", "status", "id"),
)
# Web-managed forge hosts (README §"web management"). Makes the host->token-env mapping
# Web-managed git servers (README §"web management"). Makes the host->token-env mapping
# that ``control.credentials`` used to hardcode into an editable registry, and lets
# operators register self-hosted forges without a code change. The built-in map in
# ``control.credentials`` remains the fallback when a host has no row here.
#
# A server may also carry its own credentials, so projects need nothing per-repo:
# - ``token_enc``: the forge/git token, Fernet-encrypted (see ``handler.secretstore``) —
# resolved for any project on this host with no ``credential_ref`` of its own, and
# addressable explicitly as ``db:host:<hostname>``.
# - ``ssh_public_key`` / ``ssh_private_key_enc``: a per-server ed25519 deploy key. The
# public half is shown in the dashboard to paste into the forge; the private half is
# encrypted at rest and only ever materialized in the control container.
forge_hosts = Table(
"forge_hosts",
metadata,
@@ -188,6 +199,32 @@ forge_hosts = Table(
Column("forge_type", String, nullable=False),
Column("token_env_var", String), # per-host env name to inject, e.g. "GITHUB_TOKEN"
Column("base_url", String), # HTTPS base for the credential-helper scope, when non-default
Column("token_enc", String), # encrypted forge token; never returned by the API
Column("ssh_public_key", String), # OpenSSH public key, shown to the operator
Column("ssh_private_key_enc", String), # encrypted private key; never returned by the API
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
CheckConstraint(_in("forge_type", FORGE_TYPES), name="ck_forge_hosts_type"),
)
# Recurring agent spawns. The worker checks for due rows on every loop pass and enqueues
# an ordinary ``spawn`` command per firing (so scheduled runs show up in the Activity
# audit trail like any other control action). Agent names must be unique per project, so
# each firing appends a UTC timestamp to ``name_prefix``.
schedules = Table(
"schedules",
metadata,
Column("id", PortableBigInt, primary_key=True, autoincrement=True),
Column("project_id", String, ForeignKey("projects.id"), nullable=False),
Column("name_prefix", String, nullable=False), # runs are named <prefix>-<timestamp>
Column("task", String, nullable=False), # the prompt each run starts with
Column("role", String),
Column("worktree", String), # optional branch for a per-run git worktree
Column("subdir", String), # optional subdir under the project root
Column("interval_seconds", BigInteger, nullable=False),
Column("enabled", Boolean, nullable=False),
Column("next_run_at", PortableTimestamp, nullable=False),
Column("last_run_at", PortableTimestamp),
Column("last_command_id", BigInteger, ForeignKey("commands.id")),
Column("created_at", PortableTimestamp, nullable=False, server_default=func.now()),
Index("ix_schedules_enabled_next", "enabled", "next_run_at"),
)
@@ -0,0 +1,70 @@
"""git servers own their credentials + scheduled agent spawns
Revision ID: 0004_git_servers_schedules
Revises: 0003_web_management
Create Date: 2026-07-10
Turns the ``forge_hosts`` registry into a full "git server" record: an encrypted forge
token (``token_enc``) and a per-server ed25519 deploy key (``ssh_public_key`` shown to
the operator, ``ssh_private_key_enc`` encrypted at rest). Adds the ``schedules`` table
for recurring agent spawns, and the ``sync`` command type (clone-or-pull a project's
repo in the control container). The commands CHECK constraint change goes through
``batch_alter_table`` so SQLite recreates the table while Postgres alters in place.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from handler.db.types import PortableBigInt, PortableTimestamp
revision: str = "0004_git_servers_schedules"
down_revision: str | None = "0003_web_management"
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'"
NEW_COMMAND_TYPES = OLD_COMMAND_TYPES + ", 'sync'"
def upgrade() -> None:
op.add_column("forge_hosts", sa.Column("token_enc", sa.String()))
op.add_column("forge_hosts", sa.Column("ssh_public_key", sa.String()))
op.add_column("forge_hosts", sa.Column("ssh_private_key_enc", sa.String()))
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})")
op.create_table(
"schedules",
sa.Column("id", PortableBigInt, primary_key=True, autoincrement=True),
sa.Column("project_id", sa.String(), sa.ForeignKey("projects.id"), nullable=False),
sa.Column("name_prefix", sa.String(), nullable=False),
sa.Column("task", sa.String(), nullable=False),
sa.Column("role", sa.String()),
sa.Column("worktree", sa.String()),
sa.Column("subdir", sa.String()),
sa.Column("interval_seconds", sa.BigInteger(), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("next_run_at", PortableTimestamp, nullable=False),
sa.Column("last_run_at", PortableTimestamp),
sa.Column("last_command_id", sa.BigInteger(), sa.ForeignKey("commands.id")),
sa.Column("created_at", PortableTimestamp, nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_schedules_enabled_next", "schedules", ["enabled", "next_run_at"])
def downgrade() -> None:
op.drop_index("ix_schedules_enabled_next", table_name="schedules")
op.drop_table("schedules")
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})")
with op.batch_alter_table("forge_hosts", schema=None) as batch_op:
batch_op.drop_column("ssh_private_key_enc")
batch_op.drop_column("ssh_public_key")
batch_op.drop_column("token_enc")
+56
View File
@@ -0,0 +1,56 @@
"""The encrypted secret store the ``db:`` credential scheme was reserved for.
Git-server tokens and SSH private keys are encrypted with a single symmetric key
(``HANDLER_SECRET_KEY``, a Fernet key) before they touch the database, and decrypted
only in the control layer at clone/spawn time. The database therefore still never
holds a *usable* secret: leaking a dump without the key leaks ciphertext.
The key lives only in the environment of the API (to encrypt on write) and the control
container (to decrypt on use) set the same value on both.
"""
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken
from .config import get_settings
class SecretStoreError(Exception):
"""Raised when a secret cannot be encrypted or decrypted."""
def enabled() -> bool:
"""Whether a secret key is configured (storing secrets requires one)."""
return bool(get_settings().handler_secret_key)
def _fernet() -> Fernet:
key = get_settings().handler_secret_key
if not key:
raise SecretStoreError(
"HANDLER_SECRET_KEY is not set: the encrypted secret store is disabled. "
"Generate a key with python -c "
'"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" '
"and set it on both the API and control containers."
)
try:
return Fernet(key.encode())
except (ValueError, TypeError) as exc:
raise SecretStoreError(
"HANDLER_SECRET_KEY is not a valid Fernet key (32 urlsafe-base64 bytes)"
) from exc
def encrypt(value: str) -> str:
return _fernet().encrypt(value.encode()).decode()
def decrypt(ciphertext: str) -> str:
try:
return _fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise SecretStoreError(
"stored secret cannot be decrypted — was HANDLER_SECRET_KEY changed since "
"it was saved?"
) from exc
+67
View File
@@ -0,0 +1,67 @@
"""Per-git-server SSH deploy keys.
Each registered git server can carry its own ed25519 keypair: the public key is shown
in the dashboard so the operator can paste it into the forge (GitHub deploy key /
account key, Gitea, ...), and the private key is stored encrypted (see ``secretstore``)
and materialized to a 0600 file in the control container only when git actually needs
it. Generation is pure Python (``cryptography``) so the API container needs no
``ssh-keygen`` binary.
"""
from __future__ import annotations
import os
import re
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from .config import get_settings
def generate_keypair(comment: str) -> tuple[str, str]:
"""A fresh ed25519 keypair as (private_key_openssh, public_key_openssh)."""
key = ed25519.Ed25519PrivateKey.generate()
private = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
public = key.public_key().public_bytes(
serialization.Encoding.OpenSSH,
serialization.PublicFormat.OpenSSH,
).decode()
comment = comment.strip()
return private, f"{public} {comment}" if comment else public
def _key_path(hostname: str) -> str:
# One key file per host under <projects_root>/.ssh; the hostname is sanitized so a
# crafted value can't traverse out of the directory.
safe = re.sub(r"[^A-Za-z0-9._-]", "_", hostname.strip()) or "_"
return os.path.join(get_settings().projects_root, ".ssh", safe)
def materialize_private_key(hostname: str, private_key: str) -> str:
"""Write the host's private key to a 0600 file and return its path.
Idempotent: rewrites the file each call so a rotated key takes effect immediately.
"""
path = _key_path(hostname)
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
if not private_key.endswith("\n"):
private_key += "\n"
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as fh:
fh.write(private_key)
return path
def git_ssh_command(key_path: str) -> str:
"""A ``GIT_SSH_COMMAND`` / ``core.sshCommand`` value pinned to the host's key.
``IdentitiesOnly`` stops ssh from offering unrelated agent keys;
``accept-new`` trusts a host on first contact without ever accepting a *changed*
host key silently.
"""
return f"ssh -i {key_path} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
+2 -2
View File
@@ -46,8 +46,8 @@ def test_credential_config_uses_registry_base_url(env):
assert "$FORGE_TOKEN" in value
def test_db_scheme_is_reserved_not_yet_resolvable():
def test_db_scheme_requires_host_form():
import pytest
with pytest.raises(credentials.CredentialError, match="reserved"):
with pytest.raises(credentials.CredentialError, match="db:host:<hostname>"):
credentials.resolve("db:42")
+209
View File
@@ -0,0 +1,209 @@
"""Git servers own their credentials: the encrypted token store, the per-server SSH
deploy key (public half visible, private half encrypted), and the resolution paths
that hand them to forge/git including the now-live ``db:host:<hostname>`` scheme."""
from __future__ import annotations
import os
import stat
import pytest
from handler.control import credentials
from handler.db import repository as repo
from handler.db.engine import get_engine
@pytest.fixture
def secret_key(env, monkeypatch):
from cryptography.fernet import Fernet
from handler import config
key = Fernet.generate_key().decode()
monkeypatch.setenv("HANDLER_SECRET_KEY", key)
config.get_settings.cache_clear()
return key
# ------------------------------------------------------------------ secret store
def test_secretstore_roundtrip(secret_key):
from handler import secretstore
assert secretstore.enabled()
assert secretstore.decrypt(secretstore.encrypt("s3cr3t")) == "s3cr3t"
def test_secretstore_refuses_without_key(env):
from handler import secretstore
assert not secretstore.enabled()
with pytest.raises(secretstore.SecretStoreError, match="HANDLER_SECRET_KEY"):
secretstore.encrypt("s3cr3t")
def test_secretstore_wrong_key_is_a_clear_error(secret_key, monkeypatch):
from cryptography.fernet import Fernet
from handler import config, secretstore
ciphertext = secretstore.encrypt("s3cr3t")
monkeypatch.setenv("HANDLER_SECRET_KEY", Fernet.generate_key().decode())
config.get_settings.cache_clear()
with pytest.raises(secretstore.SecretStoreError, match="HANDLER_SECRET_KEY changed"):
secretstore.decrypt(ciphertext)
# ------------------------------------------------------------------ ssh keys
def test_generate_keypair_is_openssh_ed25519():
from handler import sshkeys
private, public = sshkeys.generate_keypair("handler@github.com")
assert private.startswith("-----BEGIN OPENSSH PRIVATE KEY-----")
assert public.startswith("ssh-ed25519 ")
assert public.endswith(" handler@github.com")
def test_materialize_private_key_is_0600_under_projects_root(env):
from handler import sshkeys
path = sshkeys.materialize_private_key("github.com", "KEYDATA")
assert path.startswith(str(env["tmp"] / "projects"))
assert os.path.basename(path) == "github.com"
mode = stat.S_IMODE(os.stat(path).st_mode)
assert mode == 0o600
with open(path) as fh:
assert fh.read() == "KEYDATA\n"
def test_materialize_sanitizes_hostname(env):
from handler import sshkeys
path = sshkeys.materialize_private_key("../evil", "K")
assert os.path.dirname(path).endswith(".ssh")
assert "/../" not in path[len(str(env["tmp"])):]
# ------------------------------------------------------------------ hosts API
def test_create_host_with_token_and_ssh_key(client, auth, secret_key):
r = client.post(
"/hosts",
json={
"hostname": "github.com",
"forge_type": "github",
"token": "ghp_secret",
"generate_ssh_key": True,
},
headers=auth,
)
assert r.status_code == 201, r.text
body = r.json()
assert body["has_token"] is True
assert body["ssh_public_key"].startswith("ssh-ed25519 ")
# Secrets never leave the server, not even as keys in the payload.
assert "token" not in body
assert "token_enc" not in body
assert "ssh_private_key_enc" not in body
listed = client.get("/hosts", headers=auth).json()
assert listed[0]["has_token"] is True
assert "token_enc" not in listed[0]
# The row itself holds ciphertext, not the token.
with get_engine().begin() as conn:
row = repo.get_host(conn, "github.com")
assert row["token_enc"] != "ghp_secret"
from handler import secretstore
assert secretstore.decrypt(row["token_enc"]) == "ghp_secret"
def test_create_host_token_without_secret_key_is_400(client, auth):
r = client.post(
"/hosts",
json={"hostname": "github.com", "forge_type": "github", "token": "x"},
headers=auth,
)
assert r.status_code == 400
assert "HANDLER_SECRET_KEY" in r.json()["detail"]
def test_patch_host_rotate_and_clear(client, auth, secret_key):
r = client.post(
"/hosts",
json={
"hostname": "gitea.corp",
"forge_type": "gitea",
"token": "old",
"generate_ssh_key": True,
},
headers=auth,
)
first_key = r.json()["ssh_public_key"]
r = client.patch(
"/hosts/gitea.corp", json={"regenerate_ssh_key": True}, headers=auth
)
assert r.json()["ssh_public_key"] != first_key
r = client.patch("/hosts/gitea.corp", json={"clear_token": True}, headers=auth)
assert r.json()["has_token"] is False
r = client.patch("/hosts/gitea.corp", json={"clear_ssh_key": True}, headers=auth)
assert r.json()["ssh_public_key"] is None
# ------------------------------------------------------------------ resolution
def test_db_host_scheme_resolves_stored_token(secret_key):
from handler import secretstore
with get_engine().begin() as conn:
repo.create_host(
conn, "github.com", "github", token_enc=secretstore.encrypt("tok123")
)
assert credentials.resolve("db:host:github.com") == "tok123"
def test_db_host_scheme_missing_host_or_token(secret_key):
with pytest.raises(credentials.CredentialError, match="not registered"):
credentials.resolve("db:host:nowhere.example")
with get_engine().begin() as conn:
repo.create_host(conn, "bare.example", "gitea")
with pytest.raises(credentials.CredentialError, match="no stored token"):
credentials.resolve("db:host:bare.example")
def test_resolve_for_project_falls_back_to_server_token(secret_key):
from handler import secretstore
with get_engine().begin() as conn:
repo.create_host(
conn, "github.com", "github", token_enc=secretstore.encrypt("srv-tok")
)
project = {
"id": "p",
"git_remote": "git@github.com:me/repo.git",
"credential_ref": None,
}
assert credentials.resolve_for_project(project, conn) == "srv-tok"
# An explicit credential_ref still wins.
os.environ["X_TOKEN"] = "own-tok"
try:
project["credential_ref"] = "env:X_TOKEN"
assert credentials.resolve_for_project(project, conn) == "own-tok"
finally:
del os.environ["X_TOKEN"]
def test_resolve_for_project_none_without_anything(env):
with get_engine().begin() as conn:
project = {"id": "p", "git_remote": None, "credential_ref": None}
assert credentials.resolve_for_project(project, conn) is None
+206
View File
@@ -0,0 +1,206 @@
"""Project registration in git-server mode: pick a registered server, type owner/name,
and Handler derives the remote + root_dir and enqueues the clone. The worker's ``sync``
command (and the reposync module under it) is exercised against the gitops mock seam."""
from __future__ import annotations
import os
import pytest
from handler.control import gitops, reposync, worker
from handler.db import repository as repo
from handler.db.engine import get_engine
@pytest.fixture
def secret_key(env, monkeypatch):
from cryptography.fernet import Fernet
from handler import config
key = Fernet.generate_key().decode()
monkeypatch.setenv("HANDLER_SECRET_KEY", key)
config.get_settings.cache_clear()
return key
def _add_server(client, auth, hostname="github.com", **extra):
body = {"hostname": hostname, "forge_type": "github", **extra}
r = client.post("/hosts", json=body, headers=auth)
assert r.status_code == 201, r.text
return r.json()
def test_create_project_from_server_with_ssh_key(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"}, headers=auth
)
assert r.status_code == 201, r.text
body = r.json()
# id derived from the repo name; root under PROJECTS_ROOT; ssh remote (a key exists).
assert body["id"] == "coolproj"
assert body["root_dir"] == os.path.join(str(env["tmp"] / "projects"), "coolproj")
assert body["git_remote"] == "git@github.com:me/CoolProj.git"
# The clone is enqueued for the worker.
assert body["sync_command_id"] is not None
cmd = client.get(f"/commands/{body['sync_command_id']}", headers=auth).json()
assert cmd["type"] == "sync"
assert cmd["project_id"] == "coolproj"
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")
r = client.post(
"/projects", json={"git_server": "git.corp", "repo": "me/repo", "id": "corp-repo"},
headers=auth,
)
assert r.status_code == 201, r.text
assert r.json()["git_remote"] == "https://git.corp:8443/me/repo.git"
assert r.json()["id"] == "corp-repo"
def test_create_project_unknown_server_404(client, auth, env):
r = client.post(
"/projects", json={"git_server": "nowhere.example", "repo": "a/b"}, headers=auth
)
assert r.status_code == 404
def test_create_project_bad_repo_422(client, auth, env):
r = client.post(
"/projects", json={"git_server": "github.com", "repo": "not-owner-name"}, headers=auth
)
assert r.status_code == 422
def test_manual_mode_still_requires_id_and_root(client, auth, env):
r = client.post("/projects", json={"root_dir": "/tmp/x"}, headers=auth)
assert r.status_code == 422
r = client.post("/projects", json={"id": "x"}, headers=auth)
assert r.status_code == 422
def test_sync_endpoint_enqueues(client, auth, env, tmp_path):
root = tmp_path / "proj"
root.mkdir()
client.post(
"/projects",
json={"id": "p1", "root_dir": str(root), "git_remote": "https://github.com/a/b.git"},
headers=auth,
)
r = client.post("/projects/p1/sync", headers=auth)
assert r.status_code == 202
assert r.json()["type"] == "sync"
def test_sync_endpoint_400_without_remote(client, auth, env, tmp_path):
root = tmp_path / "proj2"
root.mkdir()
client.post("/projects", json={"id": "p2", "root_dir": str(root)}, headers=auth)
r = client.post("/projects/p2/sync", headers=auth)
assert r.status_code == 400
# ------------------------------------------------------------------ worker sync command
@pytest.fixture
def fake_sync_gitops(monkeypatch):
"""Fake the clone/pull side of the gitops seam."""
state = {"clone": [], "pull": [], "config": [], "repos": set(), "ok": True, "out": ""}
def is_repo(path):
return path in state["repos"]
def clone(remote, dest, env=None, config=None):
state["clone"].append({"remote": remote, "dest": dest, "env": env or {},
"config": config or []})
if state["ok"]:
state["repos"].add(dest)
return state["ok"], state["out"]
def pull_ff(cwd, env=None):
state["pull"].append({"cwd": cwd, "env": env or {}})
return state["ok"], state["out"]
def config_local(cwd, key, value):
state["config"].append({"cwd": cwd, "key": key, "value": value})
return True, ""
monkeypatch.setattr(gitops, "is_repo", is_repo)
monkeypatch.setattr(gitops, "clone", clone)
monkeypatch.setattr(gitops, "pull_ff", pull_ff)
monkeypatch.setattr(gitops, "config_local", config_local)
return state
def _register(conn, project_id="p", remote="https://github.com/me/repo.git", root="/tmp/r"):
return repo.create_project(conn, project_id, root_dir=root, git_remote=remote)
def test_cmd_sync_clones_then_pulls(env, fake_sync_gitops):
with get_engine().begin() as conn:
_register(conn)
command = repo.enqueue_command(conn, "sync", project_id="p")
result = worker.execute_command(command)
assert result["action"] == "cloned"
assert fake_sync_gitops["clone"][0]["remote"] == "https://github.com/me/repo.git"
result = worker.execute_command(command)
assert result["action"] == "pulled"
assert fake_sync_gitops["pull"][0]["cwd"] == "/tmp/r"
def test_cmd_sync_failure_is_command_error(env, fake_sync_gitops):
fake_sync_gitops["ok"] = False
fake_sync_gitops["out"] = "fatal: repository not found"
with get_engine().begin() as conn:
_register(conn)
command = repo.enqueue_command(conn, "sync", project_id="p")
with pytest.raises(worker.CommandError, match="repository not found"):
worker.execute_command(command)
def test_sync_uses_server_token_and_installs_helper(env, secret_key, fake_sync_gitops):
from handler import secretstore
with get_engine().begin() as conn:
repo.create_host(conn, "github.com", "github",
token_enc=secretstore.encrypt("srv-tok"))
project = _register(conn)
result = reposync.sync_project(project)
assert result["action"] == "cloned"
call = fake_sync_gitops["clone"][0]
# Token flows through the env (never argv/disk), helper is scoped to the host and
# persisted into the fresh clone for the agents that follow.
assert call["env"]["FORGE_TOKEN"] == "srv-tok"
assert call["env"]["GITHUB_TOKEN"] == "srv-tok"
assert any(k.startswith("credential.https://github.com") for k, _ in call["config"])
assert any(c["key"].startswith("credential.") for c in fake_sync_gitops["config"])
def test_sync_ssh_remote_uses_deploy_key(env, secret_key, fake_sync_gitops):
from handler import secretstore, sshkeys
private, public = sshkeys.generate_keypair("handler@github.com")
with get_engine().begin() as conn:
repo.create_host(conn, "github.com", "github", ssh_public_key=public,
ssh_private_key_enc=secretstore.encrypt(private))
project = _register(conn, remote="git@github.com:me/repo.git")
result = reposync.sync_project(project)
assert result["action"] == "cloned"
env_used = fake_sync_gitops["clone"][0]["env"]
assert "GIT_SSH_COMMAND" in env_used
assert "IdentitiesOnly=yes" in env_used["GIT_SSH_COMMAND"]
# The pinned key is persisted for the agents (core.sshCommand).
assert any(c["key"] == "core.sshCommand" for c in fake_sync_gitops["config"])
# And the key file was materialized 0600.
key_path = env_used["GIT_SSH_COMMAND"].split(" -i ", 1)[1].split(" ")[0]
with open(key_path) as fh:
assert "OPENSSH PRIVATE KEY" in fh.read()
+146
View File
@@ -0,0 +1,146 @@
"""Recurring agent spawns: the schedules API and the worker sweep that turns due
schedules into queued ``spawn`` commands with timestamped agent names."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from handler.control import worker
from handler.db import repository as repo
from handler.db.engine import get_engine
def _project(client, auth, tmp_path, project_id="p"):
root = tmp_path / project_id
root.mkdir(exist_ok=True)
r = client.post(
"/projects", json={"id": project_id, "root_dir": str(root)}, headers=auth
)
assert r.status_code == 201, r.text
CONTINUE_TASK = "Read @notes.md, continue from there; before finishing, overwrite that file."
def test_schedule_crud(client, auth, env, tmp_path):
_project(client, auth, tmp_path)
r = client.post(
"/projects/p/schedules",
json={"name_prefix": "nightly", "task": CONTINUE_TASK, "interval_seconds": 3600},
headers=auth,
)
assert r.status_code == 201, r.text
sched = r.json()
assert sched["enabled"] is True
assert sched["task"] == CONTINUE_TASK
# First run fires on the worker's next pass.
next_run = datetime.fromisoformat(sched["next_run_at"])
assert next_run <= datetime.now(UTC) + timedelta(seconds=5)
assert client.get("/schedules", headers=auth).json()[0]["id"] == sched["id"]
assert client.get("/projects/p/schedules", headers=auth).json()[0]["id"] == sched["id"]
r = client.patch(
f"/schedules/{sched['id']}",
json={"interval_seconds": 60, "enabled": False},
headers=auth,
)
assert r.json()["interval_seconds"] == 60
assert r.json()["enabled"] is False
r = client.delete(f"/schedules/{sched['id']}", headers=auth)
assert r.status_code == 200
assert client.get("/schedules", headers=auth).json() == []
def test_schedule_unknown_project_404(client, auth, env):
r = client.post(
"/projects/ghost/schedules",
json={"name_prefix": "x", "task": "t", "interval_seconds": 60},
headers=auth,
)
assert r.status_code == 404
def test_schedule_validation_422(client, auth, env, tmp_path):
_project(client, auth, tmp_path)
r = client.post(
"/projects/p/schedules",
json={"name_prefix": "", "task": "t", "interval_seconds": 60},
headers=auth,
)
assert r.status_code == 422
r = client.post(
"/projects/p/schedules",
json={"name_prefix": "x", "task": "t", "interval_seconds": 1},
headers=auth,
)
assert r.status_code == 422
def test_fire_due_schedules_enqueues_spawn(env, tmp_path):
now = datetime.now(UTC)
with get_engine().begin() as conn:
repo.create_project(conn, "p", root_dir=str(tmp_path))
sched = repo.create_schedule(
conn,
project_id="p",
name_prefix="nightly",
task=CONTINUE_TASK,
interval_seconds=3600,
next_run_at=now - timedelta(seconds=1),
role="junior",
)
assert worker.fire_due_schedules(now) == 1
with get_engine().begin() as conn:
commands = repo.list_commands(conn)
advanced = repo.get_schedule(conn, sched["id"])
assert len(commands) == 1
cmd = commands[0]
assert cmd["type"] == "spawn"
assert cmd["project_id"] == "p"
assert cmd["agent_name"].startswith("nightly-")
assert cmd["payload"]["task"] == CONTINUE_TASK
assert cmd["payload"]["role"] == "junior"
assert cmd["requested_by"] == f"schedule:{sched['id']}"
# The schedule advanced: it will not re-fire until the next interval.
assert advanced["last_run_at"] is not None
assert advanced["next_run_at"] > now
assert advanced["last_command_id"] == cmd["id"]
assert worker.fire_due_schedules(now) == 0
def test_fire_skips_disabled_and_future(env, tmp_path):
now = datetime.now(UTC)
with get_engine().begin() as conn:
repo.create_project(conn, "p", root_dir=str(tmp_path))
repo.create_schedule(
conn, project_id="p", name_prefix="off", task="t",
interval_seconds=60, next_run_at=now - timedelta(seconds=1), enabled=False,
)
repo.create_schedule(
conn, project_id="p", name_prefix="later", task="t",
interval_seconds=60, next_run_at=now + timedelta(hours=1),
)
assert worker.fire_due_schedules(now) == 0
def test_missed_intervals_collapse_into_one_run(env, tmp_path):
"""A worker that was down for hours fires once, not once per missed interval."""
now = datetime.now(UTC)
with get_engine().begin() as conn:
repo.create_project(conn, "p", root_dir=str(tmp_path))
sched = repo.create_schedule(
conn, project_id="p", name_prefix="hourly", task="t",
interval_seconds=3600, next_run_at=now - timedelta(hours=10),
)
assert worker.fire_due_schedules(now) == 1
with get_engine().begin() as conn:
advanced = repo.get_schedule(conn, sched["id"])
assert advanced["next_run_at"] == now + timedelta(hours=1)
assert worker.fire_due_schedules(now) == 0