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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM
This commit is contained in:
Claude
2026-07-10 19:17:12 +00:00
parent 7399315185
commit 71a7550f48
37 changed files with 2236 additions and 157 deletions
+3
View File
@@ -7,6 +7,7 @@ import { useDashboard, type Section } from "@/components/store";
import { RunsSection } from "@/components/sections/RunsSection";
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
import { AgentsSection } from "@/components/sections/AgentsSection";
import { SchedulesSection } from "@/components/sections/SchedulesSection";
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
import { GitServersSection } from "@/components/sections/GitServersSection";
import { ActivitySection } from "@/components/sections/ActivitySection";
@@ -28,6 +29,7 @@ const NAV: NavDef[] = [
},
{ key: "repositories", label: "Repositories", count: (s) => s.projects.length },
{ key: "agents", label: "Agents", count: (s) => s.agents.length },
{ key: "schedules", label: "Schedules", count: (s) => s.schedules.length },
{ key: "approvals", label: "Approvals", count: (s) => s.approvals.length },
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
@@ -90,6 +92,7 @@ export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
<div className="main-scroll">
{s.section === "repositories" && <RepositoriesSection />}
{s.section === "agents" && <AgentsSection />}
{s.section === "schedules" && <SchedulesSection />}
{s.section === "approvals" && <ApprovalsSection />}
{s.section === "servers" && <GitServersSection />}
{s.section === "activity" && <ActivitySection />}
@@ -1,5 +1,7 @@
/* Git Servers — the forge host registry. Each row maps a host to the token env var to
* inject at spawn (and the credential-helper scope). Holds no secrets, only the var name. */
/* Git Servers — one entry per forge host, carrying the server's own credentials:
* a forge token (stored encrypted, write-only) and an SSH deploy key whose public half
* is shown here to paste into the forge. Projects on a configured server need no
* per-repo credentials. */
"use client";
import { useState } from "react";
@@ -15,7 +17,52 @@ const FORGE_OPTS = [
{ value: "bitbucket", label: "bitbucket" },
];
const empty = { hostname: "", forge_type: "github", token_env_var: "", base_url: "" };
const empty = {
hostname: "",
forge_type: "github",
token_env_var: "",
base_url: "",
token: "",
generate_ssh_key: true,
};
function PublicKey({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard unavailable (http) — the key is selectable below */
}
};
return (
<div style={{ marginTop: 10 }}>
<div className="hstack" style={{ justifyContent: "space-between" }}>
<span className="eyebrow">SSH public key add it to the forge (deploy key)</span>
<Button size="sm" variant="secondary" onClick={copy}>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<pre
className="mono"
style={{
fontSize: "var(--text-xs)",
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: "6px 0 0",
padding: 8,
border: "1px solid var(--border-default)",
borderRadius: 6,
userSelect: "all",
}}
>
{value}
</pre>
</div>
);
}
export function GitServersSection() {
const s = useDashboard();
@@ -38,6 +85,8 @@ export function GitServersSection() {
forge_type: h.forge_type,
token_env_var: h.token_env_var ?? "",
base_url: h.base_url ?? "",
token: "",
generate_ssh_key: false,
});
setEditing(true);
};
@@ -47,8 +96,10 @@ export function GitServersSection() {
<div className="section-head">
<div className="section-title">Git Servers</div>
<div className="section-desc">
Maps a git host to the token env var injected at spawn. The built-in host map is the
fallback when no row matches.
Each server carries its own credentials: a forge token (encrypted at rest, used by
agents&apos; <span className="mono">forge</span> + git) and an SSH deploy key paste
the public key into the forge. New repositories are added by picking a server and
typing owner/name.
</div>
</div>
<div className="section-body">
@@ -63,7 +114,7 @@ export function GitServersSection() {
label="Hostname"
value={form.hostname}
onChange={(v) => setForm({ ...form, hostname: v })}
placeholder="git.corp.internal"
placeholder="github.com"
disabled={editing}
/>
<Select
@@ -73,17 +124,37 @@ export function GitServersSection() {
options={FORGE_OPTS}
/>
<Input
label="Token env var"
label={editing ? "Forge token (blank = keep current)" : "Forge token"}
type="password"
value={form.token}
onChange={(v) => setForm({ ...form, token: v })}
placeholder="stored encrypted; used by forge + git"
/>
<Input
label="Base URL (optional)"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="https://git.corp.internal:8443"
/>
<Input
label="Token env var override (optional)"
value={form.token_env_var}
onChange={(v) => setForm({ ...form, token_env_var: v })}
placeholder="GITEA_TOKEN"
/>
<Input
label="Base URL"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="https://git.corp.internal (optional)"
/>
<label className="field">
<span className="field-label">SSH deploy key</span>
<label className="hstack" style={{ gap: 8, cursor: "pointer" }}>
<input
type="checkbox"
checked={form.generate_ssh_key}
onChange={(e) => setForm({ ...form, generate_ssh_key: e.target.checked })}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
{editing ? "Regenerate keypair (replaces the current key)" : "Generate a keypair"}
</span>
</label>
</label>
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.hostname.trim()} onClick={save}>
@@ -109,6 +180,12 @@ export function GitServersSection() {
</span>
<div className="hstack">
<Badge tone="info">{h.forge_type}</Badge>
<Badge tone={h.has_token ? "success" : "neutral"}>
{h.has_token ? "token stored" : "no token"}
</Badge>
<Badge tone={h.ssh_public_key ? "success" : "neutral"}>
{h.ssh_public_key ? "ssh key" : "no ssh key"}
</Badge>
<Button size="sm" variant="secondary" onClick={() => edit(h)}>
Edit
</Button>
@@ -121,6 +198,7 @@ export function GitServersSection() {
token env {h.token_env_var || "—"}
{h.base_url ? ` · ${h.base_url}` : ""}
</div>
{h.ssh_public_key && <PublicKey value={h.ssh_public_key} />}
</Card>
))}
</div>
@@ -1,16 +1,28 @@
/* Repositories — register / edit / remove the projects (repos) Handler manages.
* Maps the design's "Repositories" pane to Handler's project registry. */
/* Repositories — the projects Handler manages. Adding one is now server-first: pick a
* configured git server, type owner/name, and Handler derives the remote, decides where
* the clone lives, and pulls it (stateless workflows don't care about disk paths).
* Manual mode (an existing checkout) remains for everything else. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input } from "@/components/ui";
import { useDashboard, type NewProjectBody } from "@/components/store";
import { Badge, Button, Card, Input, Select, Tabs } from "@/components/ui";
import { fmtFull } from "@/lib/format";
import type { Project } from "@/lib/api";
const CRED_HELP = "credential_ref is a pointer, never the token (env: / file: / db:). cmd: is CLI-only.";
const CRED_HELP =
"Optional override — projects on a configured git server use its stored token automatically. " +
"credential_ref is a pointer, never the token (env: / file: / db:host:<hostname>).";
const empty = { id: "", root_dir: "", git_remote: "", credential_ref: "" };
const empty: NewProjectBody = {
mode: "server",
git_server: "",
repo: "",
id: "",
root_dir: "",
git_remote: "",
credential_ref: "",
};
export function RepositoriesSection() {
const s = useDashboard();
@@ -23,20 +35,28 @@ export function RepositoriesSection() {
return m;
}, [s.agents]);
const serverOpts = useMemo(
() => [
{ value: "", label: s.hosts.length ? "Pick a git server…" : "No git servers configured" },
...s.hosts.map((h) => ({ value: h.hostname, label: `${h.hostname} (${h.forge_type})` })),
],
[s.hosts],
);
const reset = () => {
setForm(empty);
setEditing(false);
};
const save = async () => {
const ok = editing
? await s.updateProject(form.id, form)
: await s.createProject(form);
const ok = editing ? await s.updateProject(form.id, form) : await s.createProject(form);
if (ok) reset();
};
const edit = (p: Project) => {
setForm({
...empty,
mode: "manual",
id: p.id,
root_dir: p.root_dir,
git_remote: p.git_remote ?? "",
@@ -45,6 +65,12 @@ export function RepositoriesSection() {
setEditing(true);
};
const canSave = editing
? !!form.root_dir.trim()
: form.mode === "server"
? !!form.git_server && /^[\w.-]+\/[\w.-]+$/.test(form.repo.trim())
: !!form.id.trim() && !!form.root_dir.trim();
return (
<>
<div className="section-head">
@@ -57,46 +83,89 @@ export function RepositoriesSection() {
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editing ? `Edit repository · ${form.id}` : "Register a repository"}
{editing ? `Edit repository · ${form.id}` : "Add a repository"}
</span>
</div>
<div className="form-grid">
<Input
label="ID / slug"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="leeworks-api"
disabled={editing}
/>
<Input
label="Root dir"
value={form.root_dir}
onChange={(v) => setForm({ ...form, root_dir: v })}
placeholder="/var/lib/handler/projects/leeworks"
/>
<Input
label="Git remote"
value={form.git_remote}
onChange={(v) => setForm({ ...form, git_remote: v })}
placeholder="git@github.com:user/repo.git (optional)"
/>
<Input
label="Credential ref"
value={form.credential_ref}
onChange={(v) => setForm({ ...form, credential_ref: v })}
placeholder="env:VAR / file:/path / db:id"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{CRED_HELP}
</p>
{!editing && (
<div style={{ marginBottom: 14 }}>
<Tabs
tabs={[
{ value: "server", label: "From a git server" },
{ value: "manual", label: "Manual (existing checkout)" },
]}
value={form.mode}
onChange={(v) => setForm({ ...form, mode: v as "server" | "manual" })}
/>
</div>
)}
{!editing && form.mode === "server" ? (
<>
<div className="form-grid">
<Select
label="Git server"
value={form.git_server}
onChange={(v) => setForm({ ...form, git_server: v })}
options={serverOpts}
/>
<Input
label="Repository (owner/name)"
value={form.repo}
onChange={(v) => setForm({ ...form, repo: v })}
placeholder="me/coolproj"
/>
<Input
label="ID / slug (optional — defaults to the repo name)"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="coolproj"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
The repo is always pulled: Handler derives the remote from the server (ssh when it
has a deploy key, https via the stored token otherwise), clones it under
PROJECTS_ROOT, and keeps it fresh before every run.
</p>
</>
) : (
<>
<div className="form-grid">
<Input
label="ID / slug"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="leeworks-api"
disabled={editing}
/>
<Input
label="Root dir"
value={form.root_dir}
onChange={(v) => setForm({ ...form, root_dir: v })}
placeholder="/var/lib/handler/projects/leeworks"
/>
<Input
label="Git remote"
value={form.git_remote}
onChange={(v) => setForm({ ...form, git_remote: v })}
placeholder="git@github.com:user/repo.git (optional)"
/>
<Input
label="Credential ref"
value={form.credential_ref}
onChange={(v) => setForm({ ...form, credential_ref: v })}
placeholder="env:VAR / file:/path / db:host:github.com"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{CRED_HELP}
</p>
</>
)}
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.id.trim() || !form.root_dir.trim()}
onClick={save}
>
{editing ? "Save changes" : "Register"}
<Button variant="primary" disabled={s.cmd.busy || !canSave} onClick={save}>
{editing ? "Save changes" : form.mode === "server" ? "Add & pull" : "Register"}
</Button>
{editing && (
<Button variant="ghost" onClick={reset}>
@@ -122,9 +191,14 @@ export function RepositoriesSection() {
</div>
<div className="hstack" style={{ marginTop: 12, justifyContent: "space-between" }}>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
cred {p.credential_ref || ""} · added {fmtFull(p.created_at)}
cred {p.credential_ref || "server default"} · added {fmtFull(p.created_at)}
</span>
<div className="hstack">
{p.git_remote && (
<Button size="sm" variant="secondary" onClick={() => s.syncProject(p.id)}>
Pull now
</Button>
)}
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
Edit
</Button>
@@ -0,0 +1,196 @@
/* Schedules — recurring agent spawns. Every interval the worker starts a fresh,
* stateless agent named <prefix>-<timestamp> with the stored prompt. The canonical
* pattern: keep state in a file in the repo and have the prompt continue from it. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, Textarea, Toggle } from "@/components/ui";
import { fmtFull } from "@/lib/format";
const ROLE_OPTS = [
{ value: "", label: "Role — none" },
{ value: "junior", label: "junior" },
{ value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" },
];
const INTERVAL_OPTS = [
{ value: "900", label: "every 15 minutes" },
{ value: "1800", label: "every 30 minutes" },
{ value: "3600", label: "every hour" },
{ value: "21600", label: "every 6 hours" },
{ value: "86400", label: "every day" },
{ value: "604800", label: "every week" },
];
const TASK_PLACEHOLDER =
"Read @notes.md and continue from where it left off. Before finishing, overwrite " +
"@notes.md with the current state so the next run can pick up from there.";
function intervalLabel(seconds: number): string {
const opt = INTERVAL_OPTS.find((o) => Number(o.value) === seconds);
if (opt) return opt.label;
if (seconds % 3600 === 0) return `every ${seconds / 3600}h`;
if (seconds % 60 === 0) return `every ${seconds / 60}m`;
return `every ${seconds}s`;
}
const emptyForm = { name_prefix: "", task: "", interval: "3600", role: "" };
export function SchedulesSection() {
const s = useDashboard();
const [form, setForm] = useState(emptyForm);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
const create = async () => {
const ok = await s.createSchedule(s.selectedProjectId, {
name_prefix: form.name_prefix,
task: form.task,
interval_seconds: Number(form.interval),
role: form.role,
});
if (ok) setForm(emptyForm);
};
return (
<>
<div className="section-head">
<div className="section-title">Schedules</div>
<div className="section-desc">
Spawn a fresh agent on an interval. Each run is stateless keep continuity in a
file the prompt reads and overwrites.
</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
New schedule
</span>
</div>
<div className="form-grid">
<Input
label="Name prefix"
value={form.name_prefix}
onChange={(v) => setForm({ ...form, name_prefix: v })}
placeholder="nightly"
/>
<Select
label="Interval"
value={form.interval}
onChange={(v) => setForm({ ...form, interval: v })}
options={INTERVAL_OPTS}
/>
<Select
label="Role"
value={form.role}
onChange={(v) => setForm({ ...form, role: v })}
options={ROLE_OPTS}
/>
</div>
<div className="mt14">
<Textarea
label="Prompt (the task every run starts with)"
value={form.task}
onChange={(v) => setForm({ ...form, task: v })}
rows={3}
placeholder={TASK_PLACEHOLDER}
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
Runs are named <span className="mono">{form.name_prefix.trim() || "prefix"}-YYYYMMDD-HHMMSS</span>.
The repo is pulled before every run; the first run fires on the worker&apos;s next
pass.
</p>
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.name_prefix.trim() || !form.task.trim()}
onClick={create}
>
Create schedule
</Button>
</div>
</Card>
{s.schedules.length === 0 ? (
<div className="empty">No schedules yet.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>On</th>
<th>Name</th>
<th>Repository</th>
<th>Interval</th>
<th>Prompt</th>
<th>Next run</th>
<th>Last run</th>
<th />
</tr>
</thead>
<tbody>
{s.schedules.map((sc) => (
<tr key={sc.id}>
<td>
<Toggle
on={sc.enabled}
onClick={() => s.updateSchedule(sc.id, { enabled: !sc.enabled })}
/>
</td>
<td className="mono">
{sc.name_prefix}
{sc.role ? (
<>
{" "}
<Badge tone="info">{sc.role}</Badge>
</>
) : null}
</td>
<td className="mono faint">{sc.project_id}</td>
<td className="nowrap">{intervalLabel(sc.interval_seconds)}</td>
<td className="faint" style={{ maxWidth: 340 }}>
<span className="truncate" style={{ display: "block" }} title={sc.task}>
{sc.task}
</span>
</td>
<td className="faint nowrap">{sc.enabled ? fmtFull(sc.next_run_at) : "paused"}</td>
<td className="faint nowrap">{fmtFull(sc.last_run_at)}</td>
<td className="nowrap">
<Button size="sm" variant="danger" onClick={() => s.deleteSchedule(sc.id)}>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}
+164 -17
View File
@@ -24,6 +24,7 @@ import {
type Host,
type LogEntry,
type Project,
type Schedule,
type SharedContext,
} from "@/lib/api";
@@ -31,6 +32,7 @@ export type Section =
| "runs"
| "repositories"
| "agents"
| "schedules"
| "approvals"
| "servers"
| "activity"
@@ -68,6 +70,7 @@ interface StoreValue {
approvals: Approval[];
hosts: Host[];
commands: Command[];
schedules: Schedule[];
shared: { log: LogEntry[]; context: SharedContext[] };
cmd: CmdState;
@@ -81,13 +84,17 @@ interface StoreValue {
killAgent: (projectId: string, name: string) => Promise<void>;
deleteAgent: (projectId: string, name: string) => Promise<void>;
submitAnswer: (answer: string, resume: boolean) => Promise<boolean>;
createProject: (b: ProjectBody) => Promise<boolean>;
createProject: (b: NewProjectBody) => Promise<boolean>;
updateProject: (id: string, b: Omit<ProjectBody, "id">) => Promise<boolean>;
deleteProject: (id: string) => Promise<void>;
syncProject: (id: string) => Promise<void>;
submitApproval: (b: ApprovalBody) => Promise<void>;
createHost: (b: HostBody) => Promise<boolean>;
updateHost: (hostname: string, b: Omit<HostBody, "hostname">) => Promise<boolean>;
deleteHost: (hostname: string) => Promise<void>;
createSchedule: (projectId: string, b: ScheduleBody) => Promise<boolean>;
updateSchedule: (id: number, b: Partial<ScheduleBody> & { enabled?: boolean }) => Promise<boolean>;
deleteSchedule: (id: number) => Promise<void>;
pollCi: () => Promise<void>;
setSharedKey: (key: string, value: string) => Promise<boolean>;
}
@@ -106,6 +113,23 @@ export interface ProjectBody {
git_remote: string;
credential_ref: string;
}
/* New-project form. "server" mode = pick a configured git server + owner/name (Handler
* derives the remote, picks the disk location, and clones); "manual" = the old fields. */
export interface NewProjectBody {
mode: "server" | "manual";
git_server: string;
repo: string;
id: string;
root_dir: string;
git_remote: string;
credential_ref: string;
}
export interface ScheduleBody {
name_prefix: string;
task: string;
interval_seconds: number;
role: string;
}
export interface ApprovalBody {
branch: string;
status: string;
@@ -118,6 +142,10 @@ export interface HostBody {
forge_type: string;
token_env_var: string;
base_url: string;
/* Write-only: encrypted at rest server-side, never echoed back. Blank = no change. */
token: string;
/* Create: mint a deploy keypair. Update: replace the existing one. */
generate_ssh_key: boolean;
}
const Ctx = createContext<StoreValue | null>(null);
@@ -153,6 +181,7 @@ export function DashboardProvider({
const [approvals, setApprovals] = useState<Approval[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [commands, setCommands] = useState<Command[]>([]);
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [shared, setShared] = useState<{ log: LogEntry[]; context: SharedContext[] }>({
log: [],
context: [],
@@ -256,6 +285,14 @@ export function DashboardProvider({
}
}, []);
const loadSchedules = useCallback(async () => {
try {
setSchedules(await clientRef.current.api<Schedule[]>("/schedules"));
} catch (e) {
swallow(e);
}
}, []);
const loadShared = useCallback(async () => {
try {
const [logRows, context] = await Promise.all([
@@ -288,8 +325,9 @@ export function DashboardProvider({
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
if (s === "servers") await loadHosts();
if (s === "activity") await loadCommands();
if (s === "schedules") await loadSchedules();
if (s === "shared") await loadShared();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadShared]);
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
@@ -316,9 +354,10 @@ export function DashboardProvider({
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
if (s === "servers") void loadHosts();
if (s === "activity") void loadCommands();
if (s === "schedules") void loadSchedules();
if (s === "shared") void loadShared();
},
[loadApprovals, loadHosts, loadCommands, loadShared],
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared],
);
const selectProject = useCallback(
@@ -459,19 +498,53 @@ export function DashboardProvider({
);
const createProject = useCallback(
async (b: ProjectBody) => {
async (b: NewProjectBody) => {
try {
await clientRef.current.api("/projects", {
const body: Record<string, unknown> =
b.mode === "server"
? {
git_server: b.git_server,
repo: b.repo.trim(),
id: b.id.trim() || null,
credential_ref: b.credential_ref.trim() || null,
}
: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
};
const created = await clientRef.current.api<Project>("/projects", {
method: "POST",
body: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
},
body,
});
setCmd({ text: `repository '${b.id}' registered`, error: false, busy: false });
await loadProjects();
// Server mode enqueues a clone; follow it so the operator sees the repo land.
if (created.sync_command_id != null) {
setCmd({ text: `repository '${created.id}': cloning…`, error: false, busy: true });
const final = await clientRef.current.trackCommand(created.sync_command_id);
if (!final) {
setCmd({
text: `repository '${created.id}' registered; clone still running (see Activity). Is the worker up?`,
error: false,
busy: false,
});
} else if (final.status === "done") {
setCmd({
text: `repository '${created.id}' registered and cloned`,
error: false,
busy: false,
});
} else {
setCmd({
text: `repository '${created.id}' registered but the clone failed — ${final.error ?? ""}`,
error: true,
busy: false,
});
}
} else {
setCmd({ text: `repository '${created.id}' registered`, error: false, busy: false });
}
return true;
} catch (e) {
if (e instanceof AuthError) return false;
@@ -482,6 +555,13 @@ export function DashboardProvider({
[loadProjects],
);
const syncProject = useCallback(
async (id: string) => {
await enqueueAndTrack(`/projects/${encodeURIComponent(id)}/sync`, undefined, `pull ${id}`);
},
[enqueueAndTrack],
);
const updateProject = useCallback(
async (id: string, b: Omit<ProjectBody, "id">) => {
try {
@@ -549,6 +629,8 @@ export function DashboardProvider({
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
token: b.token.trim() || null,
generate_ssh_key: b.generate_ssh_key,
},
});
setCmd({ text: `git server '${b.hostname}' added`, error: false, busy: false });
@@ -566,13 +648,16 @@ export function DashboardProvider({
const updateHost = useCallback(
async (hostname: string, b: Omit<HostBody, "hostname">) => {
try {
const body: Record<string, unknown> = {
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
};
if (b.token.trim()) body.token = b.token.trim();
if (b.generate_ssh_key) body.regenerate_ssh_key = true;
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, {
method: "PATCH",
body: {
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
},
body,
});
setCmd({ text: `git server '${hostname}' updated`, error: false, busy: false });
await loadHosts();
@@ -586,6 +671,63 @@ export function DashboardProvider({
[loadHosts],
);
const createSchedule = useCallback(
async (projectId: string, b: ScheduleBody) => {
try {
await clientRef.current.api(`/projects/${encodeURIComponent(projectId)}/schedules`, {
method: "POST",
body: {
name_prefix: b.name_prefix.trim(),
task: b.task.trim(),
interval_seconds: b.interval_seconds,
role: b.role || null,
},
});
setCmd({
text: `schedule '${b.name_prefix}' created — first run on the worker's next pass`,
error: false,
busy: false,
});
await loadSchedules();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadSchedules],
);
const updateSchedule = useCallback(
async (id: number, b: Partial<ScheduleBody> & { enabled?: boolean }) => {
try {
await clientRef.current.api(`/schedules/${id}`, { method: "PATCH", body: b });
await loadSchedules();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadSchedules],
);
const deleteSchedule = useCallback(
async (id: number) => {
try {
await clientRef.current.api(`/schedules/${id}`, { method: "DELETE" });
setCmd({ text: `schedule ${id} removed`, error: false, busy: false });
await loadSchedules();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadSchedules],
);
const deleteHost = useCallback(
async (hostname: string) => {
try {
@@ -641,6 +783,7 @@ export function DashboardProvider({
approvals,
hosts,
commands,
schedules,
shared,
cmd,
lastError,
@@ -653,10 +796,14 @@ export function DashboardProvider({
createProject,
updateProject,
deleteProject,
syncProject,
submitApproval,
createHost,
updateHost,
deleteHost,
createSchedule,
updateSchedule,
deleteSchedule,
pollCi,
setSharedKey,
};