Files
ai-agent/backend/cron/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

151 lines
5.4 KiB
Python

"""Cron jobs REST (/api/cron)."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import cron as cron_mod
import schemas
from core.http import _json_in_worker, _r
from core.state import AppState, State
from cron.service import _cron_job_out, _cron_prompt_path, _fire_cron_job
from runs.service import LOCAL_RUNNER
router = APIRouter()
class CronCreateBody(BaseModel):
name: str
schedule: str
promptFile: str | None = None # default: .claude/agents/<slug>.md
enabled: bool = True
model: str | None = None
worker: str | None = None # unset/"local" ⇒ the lab, else a worker id
class CronPatchBody(BaseModel):
name: str | None = None
schedule: str | None = None
promptFile: str | None = None
enabled: bool | None = None
model: str | None = None
worker: str | None = None # "" / "local" ⇒ back to the lab
def _cron_worker(deps: AppState, choice: str | None) -> str | None:
"""Normalize a job's runner pick: ``None`` keeps it as is, ``""`` (the
lab) clears it, and a worker id must be paired — an unknown id would only
fail later, at 3 a.m., as the job's lastStatus."""
if choice is None:
return None
c = choice.strip()
if c in ("", LOCAL_RUNNER):
return ""
if not deps.worker_store.get(c):
raise HTTPException(400, {"message": f"unknown worker: {c!r}",
"kind": "worker_unknown"})
return c
class CronPromptBody(BaseModel):
content: str
@router.get("/api/cron", responses=_r(schemas.CronJobsResponse))
@_json_in_worker
def cron_jobs(deps: State):
"""Every cron job, with its computed next fire time and run history."""
return {"jobs": [_cron_job_out(deps, j) for j in deps.cron_store.list()]}
@router.post("/api/cron", responses=_r(schemas.CronJob))
def cron_create(deps: State, body: CronCreateBody):
"""Create a job. The prompt file is created (a stub carrying the default
run-config frontmatter) if it doesn't exist yet, so the editor always opens
on something and the harness/model/effort keys are there to edit."""
name = (body.name or "").strip()
if not name:
raise HTTPException(400, "name is required")
prompt_file = body.promptFile or \
f"{cron_mod.PROMPT_DIR}/{cron_mod.slugify(name)}.md"
try:
job = deps.cron_store.create(name, body.schedule, prompt_file,
enabled=body.enabled, model=body.model,
worker=_cron_worker(deps, body.worker) or None)
except ValueError as e:
raise HTTPException(400, str(e))
ap, _ = _cron_prompt_path(job)
if not ap.exists():
try:
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(
cron_mod.frontmatter_block(cron_mod.DEFAULT_RUN_CONFIG)
+ f"\n# {name}\n\n", encoding="utf-8")
deps.indexer.scan_files(force_walk=True)
except OSError:
pass # surfaced as missing/empty when the job fires
deps.hub.publish({"type": "cron"})
return _cron_job_out(deps, job)
@router.put("/api/cron/{job_id}", responses=_r(schemas.CronJob))
def cron_update(deps: State, job_id: str, body: CronPatchBody):
patch = body.model_dump()
patch["worker"] = _cron_worker(deps, body.worker)
try:
job = deps.cron_store.update(job_id, patch)
except ValueError as e:
raise HTTPException(400, str(e))
if not job:
raise HTTPException(404, "cron job not found")
deps.hub.publish({"type": "cron"})
return _cron_job_out(deps, job)
@router.delete("/api/cron/{job_id}", responses=_r(schemas.OkResponse))
def cron_delete(deps: State, job_id: str):
"""Remove a job. Its prompt file is left in place (it's repo content)."""
if not deps.cron_store.delete(job_id):
raise HTTPException(404, "cron job not found")
deps.hub.publish({"type": "cron"})
return {"ok": True}
@router.post("/api/cron/{job_id}/run", responses=_r(schemas.SpawnResult))
def cron_run_now(deps: State, job_id: str):
"""Fire a job immediately (manual run — works even when disabled)."""
job = deps.cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
return _fire_cron_job(deps, job)
@router.get("/api/cron/{job_id}/prompt", responses=_r(schemas.CronPromptFile))
def cron_prompt(deps: State, job_id: str):
"""The job's prompt file content (the editable text in Settings → Cron)."""
job = deps.cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
ap, rel = _cron_prompt_path(job)
try:
return {"path": rel, "content": ap.read_text(encoding="utf-8"),
"exists": True}
except OSError:
return {"path": rel, "content": "", "exists": False}
@router.put("/api/cron/{job_id}/prompt", responses=_r(schemas.CronPromptFile))
def cron_prompt_save(deps: State, job_id: str, body: CronPromptBody):
job = deps.cron_store.get(job_id)
if not job:
raise HTTPException(404, "cron job not found")
ap, rel = _cron_prompt_path(job)
try:
ap.parent.mkdir(parents=True, exist_ok=True)
ap.write_text(body.content, encoding="utf-8")
except OSError as e:
raise HTTPException(500, f"could not write {rel}: {e}")
deps.indexer.scan_files(force_walk=True)
deps.hub.publish({"type": "cron"})
return {"path": rel, "content": body.content, "exists": True}