mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:46:25 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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' <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'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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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 @@
|
||||
<!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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user