From 71a7550f48bb8e0a0ebfb8c5c57c6ca8250c38ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:17:12 +0000 Subject: [PATCH] feat: git servers own credentials, one-line project adds with auto-clone, and scheduled agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: — 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 Claude-Session: https://claude.ai/code/session_01XY1tEhQZXHZ5wci7dLc7rM --- .env.example | 7 + README.md | 88 ++++++-- docker-compose.yml | 10 +- frontend/components/Dashboard.tsx | 3 + .../components/sections/GitServersSection.tsx | 104 +++++++-- .../sections/RepositoriesSection.tsx | 168 ++++++++++---- .../components/sections/SchedulesSection.tsx | 196 ++++++++++++++++ frontend/components/store.tsx | 181 +++++++++++++-- pyproject.toml | 1 + src/handler/api/app.py | 12 +- src/handler/api/routes/hosts.py | 72 ++++-- src/handler/api/routes/projects.py | 87 +++++++- src/handler/api/routes/schedules.py | 92 ++++++++ src/handler/api/schemas.py | 98 +++++++- src/handler/api/static/404.html | 2 +- .../chunks/app/page-67c3ee7b71a251cf.js | 1 - .../chunks/app/page-b4a814bde4e81779.js | 1 + .../_buildManifest.js | 0 .../_ssgManifest.js | 0 src/handler/api/static/index.html | 2 +- src/handler/api/static/index.txt | 4 +- src/handler/config.py | 6 + src/handler/control/cli.py | 21 +- src/handler/control/credentials.py | 68 +++++- src/handler/control/gitops.py | 55 ++++- src/handler/control/reposync.py | 107 +++++++++ src/handler/control/spawn.py | 34 ++- src/handler/control/worker.py | 61 ++++- src/handler/db/repository.py | 113 +++++++++- src/handler/db/tables.py | 41 +++- .../versions/0004_git_servers_schedules.py | 70 ++++++ src/handler/secretstore.py | 56 +++++ src/handler/sshkeys.py | 67 ++++++ tests/test_credentials_hosts.py | 4 +- tests/test_git_servers.py | 209 ++++++++++++++++++ tests/test_project_from_server.py | 206 +++++++++++++++++ tests/test_schedules.py | 146 ++++++++++++ 37 files changed, 2236 insertions(+), 157 deletions(-) create mode 100644 frontend/components/sections/SchedulesSection.tsx create mode 100644 src/handler/api/routes/schedules.py delete mode 100644 src/handler/api/static/_next/static/chunks/app/page-67c3ee7b71a251cf.js create mode 100644 src/handler/api/static/_next/static/chunks/app/page-b4a814bde4e81779.js rename src/handler/api/static/_next/static/{GQbm47pHcnN5aChqrNLgi => twm_FqRjMWdGYZ-lbTh7o}/_buildManifest.js (100%) rename src/handler/api/static/_next/static/{GQbm47pHcnN5aChqrNLgi => twm_FqRjMWdGYZ-lbTh7o}/_ssgManifest.js (100%) create mode 100644 src/handler/control/reposync.py create mode 100644 src/handler/migrations/versions/0004_git_servers_schedules.py create mode 100644 src/handler/secretstore.py create mode 100644 src/handler/sshkeys.py create mode 100644 tests/test_git_servers.py create mode 100644 tests/test_project_from_server.py create mode 100644 tests/test_schedules.py diff --git a/.env.example b/.env.example index 9555d02..8dcc234 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6b313fd..9841d67 100644 --- a/README.md +++ b/README.md @@ -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:` (the `cmd:` scheme is + CLI-only, since it would run an arbitrary command in the control container). + `db:host:` 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 `. `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:` | 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. diff --git a/docker-compose.yml b/docker-compose.yml index 73df539..c1f6958 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/components/Dashboard.tsx b/frontend/components/Dashboard.tsx index 2744d5e..5b0192a 100644 --- a/frontend/components/Dashboard.tsx +++ b/frontend/components/Dashboard.tsx @@ -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 }) {
{s.section === "repositories" && } {s.section === "agents" && } + {s.section === "schedules" && } {s.section === "approvals" && } {s.section === "servers" && } {s.section === "activity" && } diff --git a/frontend/components/sections/GitServersSection.tsx b/frontend/components/sections/GitServersSection.tsx index 99e2129..f2d23a3 100644 --- a/frontend/components/sections/GitServersSection.tsx +++ b/frontend/components/sections/GitServersSection.tsx @@ -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 ( +
+
+ SSH public key — add it to the forge (deploy key) + +
+
+        {value}
+      
+
+ ); +} 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() {
Git Servers
- 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' forge + 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.
@@ -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} /> setForm({ ...form, token: v })} + placeholder="stored encrypted; used by forge + git" + /> + setForm({ ...form, base_url: v })} + placeholder="https://git.corp.internal:8443" + /> + setForm({ ...form, token_env_var: v })} placeholder="GITEA_TOKEN" /> - setForm({ ...form, base_url: v })} - placeholder="https://git.corp.internal (optional)" - /> +
@@ -121,6 +198,7 @@ export function GitServersSection() { token env {h.token_env_var || "—"} {h.base_url ? ` · ${h.base_url}` : ""}
+ {h.ssh_public_key && } ))}
diff --git a/frontend/components/sections/RepositoriesSection.tsx b/frontend/components/sections/RepositoriesSection.tsx index 40f7170..8479e06 100644 --- a/frontend/components/sections/RepositoriesSection.tsx +++ b/frontend/components/sections/RepositoriesSection.tsx @@ -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:)."; -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 ( <>
@@ -57,46 +83,89 @@ export function RepositoriesSection() {
- {editing ? `Edit repository · ${form.id}` : "Register a repository"} + {editing ? `Edit repository · ${form.id}` : "Add a repository"}
-
- setForm({ ...form, id: v })} - placeholder="leeworks-api" - disabled={editing} - /> - setForm({ ...form, root_dir: v })} - placeholder="/var/lib/handler/projects/leeworks" - /> - setForm({ ...form, git_remote: v })} - placeholder="git@github.com:user/repo.git (optional)" - /> - setForm({ ...form, credential_ref: v })} - placeholder="env:VAR / file:/path / db:id" - /> -
-

- {CRED_HELP} -

+ + {!editing && ( +
+ setForm({ ...form, mode: v as "server" | "manual" })} + /> +
+ )} + + {!editing && form.mode === "server" ? ( + <> +
+ setForm({ ...form, repo: v })} + placeholder="me/coolproj" + /> + setForm({ ...form, id: v })} + placeholder="coolproj" + /> +
+

+ 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. +

+ + ) : ( + <> +
+ setForm({ ...form, id: v })} + placeholder="leeworks-api" + disabled={editing} + /> + setForm({ ...form, root_dir: v })} + placeholder="/var/lib/handler/projects/leeworks" + /> + setForm({ ...form, git_remote: v })} + placeholder="git@github.com:user/repo.git (optional)" + /> + setForm({ ...form, credential_ref: v })} + placeholder="env:VAR / file:/path / db:host:github.com" + /> +
+

+ {CRED_HELP} +

+ + )} +
- {editing && (
- cred {p.credential_ref || "—"} · added {fmtFull(p.created_at)} + cred {p.credential_ref || "server default"} · added {fmtFull(p.created_at)}
+ {p.git_remote && ( + + )} diff --git a/frontend/components/sections/SchedulesSection.tsx b/frontend/components/sections/SchedulesSection.tsx new file mode 100644 index 0000000..18724ed --- /dev/null +++ b/frontend/components/sections/SchedulesSection.tsx @@ -0,0 +1,196 @@ +/* Schedules — recurring agent spawns. Every interval the worker starts a fresh, + * stateless agent named - 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 ( + <> +
+
Schedules
+
+ Spawn a fresh agent on an interval. Each run is stateless — keep continuity in a + file the prompt reads and overwrites. +
+
+
+ {s.projects.length === 0 ? ( +
Register a repository first.
+ ) : ( + <> +
+
+ setForm({ ...form, name_prefix: v })} + placeholder="nightly" + /> + setForm({ ...form, role: v })} + options={ROLE_OPTS} + /> +
+
+