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>
151 lines
5.4 KiB
Python
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}
|