Merge main: consolidate JSON reads into enhanced read_json, keep error-handling improvements

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
zumayaaustin 2026-07-09 01:15:14 +00:00
commit 9e6ec12acd
2 changed files with 115 additions and 128 deletions

View File

@ -1,31 +1,20 @@
const api = {
async get(path) {
const r = await fetch(path);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async post(path, body = {}, controller) {
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
async request(path, { method = 'GET', body, controller } = {}) {
const opts = { method };
if (body !== undefined) {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
}
if (controller) opts.signal = controller.signal;
const r = await fetch(path, opts);
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async put(path, body = {}) {
const r = await fetch(path, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async patch(path, body = {}) {
const r = await fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
async del(path) {
const r = await fetch(path, { method: 'DELETE' });
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || `Request failed: ${r.status}`); }
return r.json();
},
get(path) { return api.request(path); },
post(path, body = {}, controller) { return api.request(path, { method: 'POST', body, controller }); },
put(path, body = {}) { return api.request(path, { method: 'PUT', body }); },
patch(path, body = {}) { return api.request(path, { method: 'PATCH', body }); },
del(path) { return api.request(path, { method: 'DELETE' }); },
getStatus: () => api.get('/api/status'),
getBrain: () => api.get('/api/brain'),
getBrainFile: (name) => api.get(`/api/brain/${encodeURIComponent(name)}`),

210
server.py
View File

@ -28,6 +28,9 @@ from pydantic import BaseModel
BASE_DIR = Path(__file__).parent.resolve()
# Agents supported across chat, routing, health, and kanban dispatch.
AGENTS = ["opencode", "hermes", "gemini"]
app = FastAPI(title="Agentic OS", version="1.1.0")
# Load OpenRouter API key from Hermes .env
@ -125,43 +128,53 @@ def write_file(path: Path, content: str):
path.write_text(content, encoding="utf-8")
return True
_MISSING = object()
def load_json_file(path: Path, default=_MISSING, best_effort=False):
"""Read and parse a JSON file.
Raises a descriptive HTTPException instead of leaking an opaque 500 when the
file is missing or corrupt, so callers propagate a clear error to the client.
If ``default`` is provided it is returned when the file does not exist.
Set ``best_effort=True`` (with a ``default``) for aggregate/listing callers
that should tolerate one corrupt file rather than aborting the whole view:
the corruption is logged and ``default`` is returned instead of raising.
"""
if not path.exists():
if default is not _MISSING:
return default
raise HTTPException(404, f"{path.name} not found")
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
if best_effort and default is not _MISSING:
print(f"[load] skipping corrupt {path.name}: {e}")
return default
raise HTTPException(500, f"Failed to read {path.name}: {e}")
def list_dir(path: Path):
if not path.exists():
return []
return sorted([p.name for p in path.iterdir() if not p.name.startswith(".")])
def read_json(path: Path, default=None, best_effort=False):
"""Load JSON from path, returning ``default`` when the file is missing.
On corrupt or unreadable content a descriptive ``HTTPException(500)`` is
raised so the error is propagated to the client instead of surfacing as an
opaque 500. Aggregate/listing callers can set ``best_effort=True`` to
tolerate one bad file: the corruption is logged and ``default`` is returned
instead of aborting the whole view.
"""
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
if best_effort:
print(f"[load] skipping corrupt {path.name}: {e}")
return default
raise HTTPException(500, f"Failed to read {path.name}: {e}")
def write_json(path: Path, data, indent: int = 2):
"""Serialize data as pretty JSON to path."""
path.write_text(json.dumps(data, indent=indent), encoding="utf-8")
def iter_skill_dirs():
"""Yield skill directories, skipping hidden and underscore-prefixed ones."""
skills_dir = BASE_DIR / "skills"
if not skills_dir.exists():
return
for d in sorted(skills_dir.iterdir()):
if d.is_dir() and not d.name.startswith("_"):
yield d
def new_id():
return str(uuid.uuid4())[:8]
def get_timestamp():
return datetime.now(timezone.utc).isoformat()
def append_audit(entry: dict):
audit_file = BASE_DIR / "audit" / "audit.log"
entry["timestamp"] = get_timestamp()
entry["id"] = str(uuid.uuid4())[:8]
entry["id"] = new_id()
try:
audit_file.parent.mkdir(parents=True, exist_ok=True)
with open(audit_file, "a", encoding="utf-8") as f:
@ -231,7 +244,7 @@ def check_agent(name: str) -> dict:
@app.get("/api/status")
def get_status():
agents = [check_agent(a) for a in ["opencode", "hermes", "gemini"]]
agents = [check_agent(a) for a in AGENTS]
skills = list_dir(BASE_DIR / "skills")
return {
"status": "healthy",
@ -307,19 +320,18 @@ def skill_context_file_path(name: str, filename: str) -> Path:
@app.get("/api/skills")
def list_skills():
skills = []
for d in sorted((BASE_DIR / "skills").iterdir()):
if d.is_dir() and not d.name.startswith("_"):
skill_md = read_file(d / "SKILL.md")
learnings = read_file(d / "learnings.md")
eval_data = load_json_file(d / "eval.json", default={}, best_effort=True)
score_history = load_json_file(d / "score-history.json", default=[], best_effort=True)
skills.append({
"name": d.name,
"description": skill_md[:200] if skill_md else "",
"has_learnings": bool(learnings),
"eval_criteria": eval_data.get("criteria", []),
"scores": score_history,
})
for d in iter_skill_dirs():
skill_md = read_file(d / "SKILL.md")
learnings = read_file(d / "learnings.md")
eval_data = read_json(d / "eval.json", {}, best_effort=True)
score_history = read_json(d / "score-history.json", [], best_effort=True)
skills.append({
"name": d.name,
"description": skill_md[:200] if skill_md else "",
"has_learnings": bool(learnings),
"eval_criteria": eval_data.get("criteria", []),
"scores": score_history,
})
return skills
@app.get("/api/skills/{name}")
@ -329,8 +341,8 @@ def get_skill(name: str):
"name": name,
"skill": read_file(path / "SKILL.md"),
"learnings": read_file(path / "learnings.md"),
"eval": load_json_file(path / "eval.json", default={}),
"score_history": load_json_file(path / "score-history.json", default=[]),
"eval": read_json(path / "eval.json", default={}),
"score_history": read_json(path / "score-history.json", default=[]),
"context": [f.name for f in (path / "context").iterdir()] if (path / "context").exists() else [],
}
@ -420,7 +432,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
if skill_input:
prompt += f"## User Input\n{skill_input}"
run_id = str(uuid.uuid4())[:8]
run_id = new_id()
# Execute via agent
try:
@ -464,7 +476,7 @@ def run_skill(name: str, req: Optional[SkillRunRequest] = None):
@app.get("/api/skills/{name}/eval")
def get_skill_eval(name: str):
path = resolve_skill_dir(name) / "score-history.json"
return {"scores": load_json_file(path, default=[])}
return {"scores": read_json(path, default=[])}
# ─── Routes: Scheduler ────────────────────────────────────────────
@ -473,7 +485,7 @@ def list_jobs():
jobs_dir = BASE_DIR / "scheduler" / "jobs"
jobs = []
for f in sorted(jobs_dir.glob("*.json")):
job = load_json_file(f, default=None, best_effort=True)
job = read_json(f, default=None, best_effort=True)
if job is not None:
jobs.append(job)
return jobs
@ -483,7 +495,7 @@ def create_job(job: ScheduleJobRequest):
jobs_dir = BASE_DIR / "scheduler" / "jobs"
jobs_dir.mkdir(parents=True, exist_ok=True)
job_data = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"name": job.name,
"skill": job.skill,
"cron": job.cron,
@ -502,7 +514,7 @@ def create_job(job: ScheduleJobRequest):
def delete_job(job_id: str):
jobs_dir = BASE_DIR / "scheduler" / "jobs"
for f in jobs_dir.glob("*.json"):
data = load_json_file(f, default=None, best_effort=True)
data = read_json(f, default=None, best_effort=True)
if data and data.get("id") == job_id:
f.unlink()
append_audit({"action": "job_deleted", "job_id": job_id})
@ -533,18 +545,12 @@ def get_audit(limit: int = Query(100, le=500)):
@app.get("/api/cost")
def get_cost():
cost_file = BASE_DIR / "data" / "cost-history.json"
return load_json_file(
cost_file,
default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []},
)
return read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []})
@app.post("/api/cost/record")
def record_cost(data: dict):
cost_file = BASE_DIR / "data" / "cost-history.json"
cost_data = load_json_file(
cost_file,
default={"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []},
)
cost_data = read_json(cost_file, {"entries": [], "daily_totals": {}, "monthly_projection": 0, "free_tier_alerts": []})
cost_data["entries"].append({
"timestamp": get_timestamp(),
"agent": data.get("agent", "unknown"),
@ -552,7 +558,7 @@ def record_cost(data: dict):
"cost": data.get("cost", 0.0),
"model": data.get("model", "unknown"),
})
cost_file.write_text(json.dumps(cost_data, indent=2))
write_json(cost_file, cost_data)
return {"status": "recorded"}
# ─── Routes: Registry/Plugins ─────────────────────────────────────
@ -560,7 +566,7 @@ def record_cost(data: dict):
@app.get("/api/plugins")
def list_plugins():
reg_file = BASE_DIR / "registry" / "plugins.json"
return load_json_file(reg_file, default={"plugins": []})
return read_json(reg_file, {"plugins": []})
@app.post("/api/plugins/install")
def install_plugin(data: dict):
@ -568,7 +574,7 @@ def install_plugin(data: dict):
if not name:
raise HTTPException(400, "Plugin name required")
reg_file = BASE_DIR / "registry" / "plugins.json"
reg = load_json_file(reg_file, default={"plugins": []})
reg = read_json(reg_file, {"plugins": []})
if any(p["name"] == name for p in reg["plugins"]):
return {"status": "already_installed"}
reg["plugins"].append({
@ -576,7 +582,7 @@ def install_plugin(data: dict):
"installed": get_timestamp(),
"version": "1.0.0",
})
reg_file.write_text(json.dumps(reg, indent=2))
write_json(reg_file, reg)
append_audit({"action": "plugin_installed", "plugin": name})
return {"status": "installed", "plugin": name}
@ -633,15 +639,15 @@ def list_prompts():
@app.get("/api/settings")
def get_settings():
sf = BASE_DIR / "data" / "settings.json"
return load_json_file(sf, default={})
return read_json(sf, {})
@app.put("/api/settings")
def update_settings(data: SettingsUpdate):
sf = BASE_DIR / "data" / "settings.json"
# Merge with existing
existing = load_json_file(sf, default={})
existing = read_json(sf, {})
existing.update(data.settings)
sf.write_text(json.dumps(existing, indent=2))
write_json(sf, existing)
append_audit({"action": "settings_updated"})
return {"status": "ok"}
@ -673,14 +679,14 @@ def discover_standards():
CHAT_HISTORY_FILE = BASE_DIR / "data" / "chat-history.json"
def load_chat_history():
return load_json_file(CHAT_HISTORY_FILE, default={"messages": []})
return read_json(CHAT_HISTORY_FILE, {"messages": []})
def save_chat_message(msg: dict):
history = load_chat_history()
history["messages"].append(msg)
if len(history["messages"]) > 200:
history["messages"] = history["messages"][-200:]
CHAT_HISTORY_FILE.write_text(json.dumps(history, indent=2))
write_json(CHAT_HISTORY_FILE, history)
def run_cli(args: list, timeout: int = 30) -> tuple:
r = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
@ -787,11 +793,11 @@ def execute_agent(agent: str, message: str) -> str:
@app.post("/api/chat")
def chat(req: ChatRequest):
agent = req.agent.lower().strip()
if agent not in ["opencode", "hermes", "gemini"]:
if agent not in AGENTS:
raise HTTPException(400, "Agent must be one of: opencode, hermes, gemini")
user_msg = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"role": "user",
"agent": agent,
"content": req.message,
@ -802,7 +808,7 @@ def chat(req: ChatRequest):
response_text = execute_agent(agent, req.message)
agent_msg = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"role": "assistant",
"agent": agent,
"content": response_text,
@ -1001,7 +1007,7 @@ def load_kanban_tasks():
ensure_dir(KANBAN_DIR)
tasks = []
for f in sorted(KANBAN_DIR.glob("*.json")):
task = load_json_file(f, default=None, best_effort=True)
task = read_json(f, default=None, best_effort=True)
if task is not None:
tasks.append(task)
return tasks
@ -1022,7 +1028,7 @@ def save_kanban_task(task: dict):
ensure_dir(KANBAN_DIR)
kanban_task_path(task["id"]).write_text(json.dumps(task, indent=2))
KANBAN_AGENTS = {"opencode", "hermes", "gemini"}
KANBAN_AGENTS = set(AGENTS)
def dispatch_kanban_task(task_id: str):
"""Move a task to in_progress and hand it to its assignee agent in the background."""
@ -1057,7 +1063,7 @@ def _run_kanban_agent(task_id: str):
task = json.loads(path.read_text()) # reload in case it changed while the agent ran
task.setdefault("comments", []).append({
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"message": f"🤖 **{agent}**\n\n{response}",
"timestamp": get_timestamp(),
})
@ -1085,10 +1091,10 @@ def _run_kanban_agent(task_id: str):
print(f"[kanban] could not mark task {task_id} as blocked: {inner}")
def load_goals():
return load_json_file(GOALS_FILE, default=[])
return read_json(GOALS_FILE, [])
def save_goals(goals: list):
GOALS_FILE.write_text(json.dumps(goals, indent=2))
write_json(GOALS_FILE, goals)
# ─── Routes: Kanban Board (13 endpoints) ────────────────────────
@ -1127,7 +1133,7 @@ def kanban_delete_task(task_id: str):
def kanban_create_task(data: KanbanTaskCreate):
try:
task = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"title": data.title,
"body": data.body,
"status": data.status,
@ -1224,7 +1230,7 @@ def kanban_add_comment(task_id: str, data: KanbanCommentCreate):
raise HTTPException(404, "Task not found")
task = json.loads(path.read_text())
comment = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"message": data.message,
"timestamp": get_timestamp(),
}
@ -1294,7 +1300,7 @@ def kanban_decompose_task(task_id: str):
subtask = subtask.strip().lstrip("-* ")
if subtask:
child = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"title": subtask[:80],
"body": subtask,
"status": "todo",
@ -1324,7 +1330,7 @@ def create_goal(data: GoalCreate):
try:
goals = load_goals()
goal = {
"id": str(uuid.uuid4())[:8],
"id": new_id(),
"title": data.title,
"description": data.description,
"category": data.category,
@ -1436,7 +1442,7 @@ def search_journal(q: str = Query("")):
def get_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
for name in AGENTS:
info = check_agent(name)
info["uptime"] = 0
info["success_rate"] = 100
@ -1449,7 +1455,7 @@ def get_agent_health():
@app.get("/api/agents/{name}/stats")
def get_agent_stats(name: str):
try:
if name not in ["opencode", "hermes", "gemini"]:
if name not in AGENTS:
raise HTTPException(400, "Invalid agent")
info = check_agent(name)
return {
@ -1470,7 +1476,7 @@ def get_agent_stats(name: str):
def refresh_agent_health():
try:
agents = []
for name in ["opencode", "hermes", "gemini"]:
for name in AGENTS:
info = check_agent(name)
agents.append(info)
append_audit({"action": "agent_health_refreshed"})
@ -1508,7 +1514,7 @@ def router_suggest(data: RouterSuggest):
def router_route(data: RouterRoute):
try:
agent = data.agent.lower()
if agent not in ["opencode", "hermes", "gemini"]:
if agent not in AGENTS:
return {"status": "error", "message": f"Invalid agent: {agent}"}
append_audit({"action": "task_routed", "agent": agent, "task_preview": data.task[:50]})
return {
@ -1525,22 +1531,17 @@ def router_route(data: RouterRoute):
@app.get("/api/analytics/skills")
def get_skill_analytics():
try:
skills_dir = BASE_DIR / "skills"
analytics = []
for d in sorted(skills_dir.iterdir()):
if d.is_dir() and not d.name.startswith("_"):
eval_path = d / "eval.json"
score_path = d / "score-history.json"
scores = json.loads(score_path.read_text()) if score_path.exists() else []
eval_data = json.loads(eval_path.read_text()) if eval_path.exists() else {}
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
analytics.append({
"name": d.name,
"total_runs": len(scores),
"avg_score": round(avg_score, 1),
"last_score": scores[-1].get("score", 0) if scores else 0,
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
})
for d in iter_skill_dirs():
scores = read_json(d / "score-history.json", [])
avg_score = sum(s.get("score", 0) for s in scores) / len(scores) if scores else 0
analytics.append({
"name": d.name,
"total_runs": len(scores),
"avg_score": round(avg_score, 1),
"last_score": scores[-1].get("score", 0) if scores else 0,
"trend": "up" if len(scores) >= 2 and scores[-1].get("score", 0) > scores[-2].get("score", 0) else "down" if len(scores) >= 2 else "stable",
})
return {"skills": sorted(analytics, key=lambda x: x["total_runs"], reverse=True)}
except Exception as e:
return {"skills": [], "error": str(e)}
@ -1548,18 +1549,15 @@ def get_skill_analytics():
@app.get("/api/analytics/trends")
def get_trend_analytics():
try:
skills_dir = BASE_DIR / "skills"
trends = []
for d in sorted(skills_dir.iterdir()):
if d.is_dir() and not d.name.startswith("_"):
score_path = d / "score-history.json"
scores = json.loads(score_path.read_text()) if score_path.exists() else []
if scores:
trends.append({
"name": d.name,
"scores": [s.get("score", 0) for s in scores[-10:]],
"labels": [s.get("date", "") for s in scores[-10:]],
})
for d in iter_skill_dirs():
scores = read_json(d / "score-history.json", [])
if scores:
trends.append({
"name": d.name,
"scores": [s.get("score", 0) for s in scores[-10:]],
"labels": [s.get("date", "") for s in scores[-10:]],
})
return {"trends": trends}
except Exception as e:
return {"trends": [], "error": str(e)}