Files
FarmHouse-Holdings/engine/main.py
T
fez5587andClaude Fable 5 f41eac033f Research room + task kinds
- Decomposition now classifies each task kind (build/research/review);
  research/review map to matching work_item types, dispatcher dispatches
  them alongside task/subtask/defect
- Office: walled research room (bookshelves, reading table, own floor)
  bottom-right; generalized room routing (per-room door waypoints);
  workers on research-type tasks walk there and read (page-flip anim)
  instead of sitting at a desk; break area moved to mid-floor

Verified live: research directive decomposed into 4 research + 1 build
task; worker rendered at the reading table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:20:08 -05:00

376 lines
15 KiB
Python

"""FarmHouse engine API — Slice 1 core.
Run: uvicorn engine.main:app --reload --port 8100
"""
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import UUID
import redis.asyncio as aioredis
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from engine import db
from engine.config import settings
from engine.dispatcher import Dispatcher
from engine.hermes_client import HermesClient, HermesError
from engine.protocol import decompose_objective
from engine.souls import ROLE_CHARTERS, build_soul
PAUSE_KEY = "farmhouse:paused"
hermes = HermesClient()
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
dispatcher = Dispatcher(hermes, is_paused=lambda: is_paused())
@asynccontextmanager
async def lifespan(app: FastAPI):
await db.open_pool()
dispatcher.start()
yield
await dispatcher.stop()
await db.close_pool()
await hermes.aclose()
await redis_client.aclose()
app = FastAPI(title="FarmHouse Holdings Engine", version="0.1.0", lifespan=lifespan)
# ---------------------------------------------------------------- request models
class EmployeeSpec(BaseModel):
name: str
title: str
role: str # manager | engineer | qa
def validate_role(self) -> None:
if self.role not in ROLE_CHARTERS:
raise HTTPException(422, f"unknown role {self.role!r}; valid: {sorted(ROLE_CHARTERS)}")
class CompanyCreate(BaseModel):
slug: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{1,40}$")
name: str
brief: str
employees: list[EmployeeSpec] = Field(min_length=1)
# ---------------------------------------------------------------- helpers
def profile_name(company_slug: str, employee_name: str) -> str:
return f"fh-{company_slug}-{employee_name.lower().replace(' ', '-')}"
async def is_paused() -> bool:
return await redis_client.get(PAUSE_KEY) == "1"
# ---------------------------------------------------------------- endpoints
@app.get("/")
async def dashboard() -> FileResponse:
return FileResponse(Path(__file__).parent / "static" / "index.html")
@app.get("/office")
async def office() -> FileResponse:
return FileResponse(Path(__file__).parent / "static" / "office.html")
@app.get("/health")
async def health() -> dict:
row = await db.fetch_one("SELECT count(*) AS companies FROM company")
return {"status": "ok", "companies": row["companies"], "paused": await is_paused()}
@app.post("/companies", status_code=201)
async def create_company(body: CompanyCreate) -> dict:
for spec in body.employees:
spec.validate_role()
if await db.fetch_one("SELECT id FROM company WHERE slug = %s", body.slug):
raise HTTPException(409, f"company {body.slug!r} already exists")
workspace = f"/opt/data/farmhouse/{body.slug}"
async with db.pool.connection() as conn:
async with conn.transaction():
cur = await conn.execute(
"INSERT INTO company (slug, name, description, lifecycle_state, config) "
"VALUES (%s, %s, %s, 'active', %s) RETURNING id",
(body.slug, body.name, body.brief, db.Json({"workspace_path": workspace})),
)
company_id = (await cur.fetchone())["id"]
await db.append_event(conn, actor="user:philip", event_type="company.created",
company_id=company_id, source="api",
payload={"slug": body.slug, "name": body.name})
employees = []
for spec in body.employees:
cur = await conn.execute(
"INSERT INTO employee (company_id, name, title, role, hermes_profile) "
"VALUES (%s, %s, %s, %s, %s) RETURNING id",
(company_id, spec.name, spec.title, spec.role,
profile_name(body.slug, spec.name)),
)
emp_id = (await cur.fetchone())["id"]
await db.append_event(conn, actor="system", event_type="employee.hired",
company_id=company_id,
payload={"employee_id": str(emp_id), "name": spec.name,
"title": spec.title, "role": spec.role})
employees.append({"id": emp_id, "spec": spec})
# Hermes-side setup outside the tx: DB is source of truth; both calls are
# retryable. Failure leaves employees hired with a repairable gap.
try:
await hermes.mkdir(workspace)
except HermesError:
pass # dir may already exist
profile_errors = []
for emp in employees:
spec = emp["spec"]
try:
await hermes.create_profile(
profile_name(body.slug, spec.name),
soul=build_soul(spec.role, name=spec.name, title=spec.title, company_name=body.name),
model="qwen3.5:9b",
description=f"{body.name}: {spec.title}",
)
except HermesError as e:
profile_errors.append({"employee": spec.name, "error": str(e)[:200]})
return {
"company_id": str(company_id),
"slug": body.slug,
"employees": [{"id": str(e["id"]), "name": e["spec"].name} for e in employees],
"profile_errors": profile_errors,
}
@app.get("/companies")
async def list_companies() -> list[dict]:
rows = await db.fetch_all(
"SELECT c.*, count(e.id) AS employee_count FROM company c "
"LEFT JOIN employee e ON e.company_id = c.id GROUP BY c.id ORDER BY c.created_at"
)
return [db.as_json(r) for r in rows]
@app.get("/companies/{slug}")
async def get_company(slug: str) -> dict:
company = await db.fetch_one("SELECT * FROM company WHERE slug = %s", slug)
if not company:
raise HTTPException(404, "no such company")
employees = await db.fetch_all(
"SELECT id, name, title, department, status, authority_level, hermes_profile "
"FROM employee WHERE company_id = %s ORDER BY created_at", company["id"]
)
return db.as_json({**company, "employees": employees})
@app.get("/companies/{slug}/events")
async def company_events(slug: str, limit: int = 50) -> list[dict]:
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
if not company:
raise HTTPException(404, "no such company")
rows = await db.fetch_all(
"SELECT * FROM event WHERE company_id = %s ORDER BY created_at DESC LIMIT %s",
company["id"], limit,
)
return [db.as_json(r) for r in rows]
@app.delete("/companies/{slug}", status_code=204)
async def archive_company(slug: str) -> None:
"""Archive (lifecycle change + Hermes profile removal). Rows are kept."""
company = await db.fetch_one("SELECT * FROM company WHERE slug = %s", slug)
if not company:
raise HTTPException(404, "no such company")
employees = await db.fetch_all(
"SELECT hermes_profile FROM employee WHERE company_id = %s", company["id"]
)
async with db.pool.connection() as conn:
async with conn.transaction():
await conn.execute(
"UPDATE company SET lifecycle_state = 'archived' WHERE id = %s", (company["id"],)
)
await db.append_event(conn, actor="user:philip", event_type="company.archived",
company_id=company["id"], source="api")
for row in employees:
if row["hermes_profile"]:
try:
await hermes.delete_profile(row["hermes_profile"])
except HermesError:
pass # profile may already be gone; DB stays authoritative
# ---------------------------------------------------------------- objectives and work items
class ObjectiveCreate(BaseModel):
title: str
brief: str
@app.post("/companies/{slug}/objectives", status_code=201)
async def create_objective(slug: str, body: ObjectiveCreate) -> dict:
"""Submit an objective; the manager decomposes it into ready work items."""
company = await db.fetch_one(
"SELECT * FROM company WHERE slug = %s AND lifecycle_state = 'active'", slug)
if not company:
raise HTTPException(404, "no active company with that slug")
staff = await db.fetch_all(
"SELECT id, name, role FROM employee WHERE company_id = %s", company["id"])
by_role: dict[str, list] = {}
for e in staff:
by_role.setdefault(e["role"], []).append(e)
manager = (by_role.get("manager") or [None])[0]
if manager is None:
raise HTTPException(422, "company has no manager to decompose the objective")
plan = await decompose_objective(body.brief, company["name"])
async with db.pool.connection() as conn:
async with conn.transaction():
cur = await conn.execute(
"INSERT INTO work_item (company_id, type, title, description, status, owner_id) "
"VALUES (%s, 'objective', %s, %s, 'in_progress', %s) RETURNING id",
(company["id"], body.title, body.brief, manager["id"]),
)
objective_id = (await cur.fetchone())["id"]
await db.append_event(conn, actor="user:philip", event_type="work_item.created",
company_id=company["id"], work_item_id=objective_id,
source="api", payload={"type": "objective", "title": body.title})
children = []
rr_index: dict[str, int] = {}
for t in plan["tasks"]:
pool_for_role = by_role.get(t["role"]) or by_role.get("engineer") or staff
idx = rr_index.get(t["role"], 0)
owner = pool_for_role[idx % len(pool_for_role)]
rr_index[t["role"]] = idx + 1
item_type = {"research": "research", "review": "review"}.get(
t.get("kind"), "task")
cur = await conn.execute(
"INSERT INTO work_item (company_id, parent_id, type, title, description, "
"status, owner_id, acceptance_criteria) "
"VALUES (%s, %s, %s, %s, %s, 'ready', %s, %s) RETURNING id",
(company["id"], objective_id, item_type, t["title"], t["description"],
owner["id"], db.Json(t["acceptance_criteria"])),
)
child_id = (await cur.fetchone())["id"]
await db.append_event(
conn, actor=f"employee:{manager['id']}", event_type="work_item.created",
company_id=company["id"], work_item_id=child_id,
payload={"type": "task", "title": t["title"], "role": t["role"],
"owner": owner["name"], "parent": str(objective_id)},
)
children.append({"id": str(child_id), "title": t["title"],
"role": t["role"], "owner": owner["name"]})
if plan.get("clarification_needed"):
await db.append_event(
conn, actor=f"employee:{manager['id']}",
event_type="clarification.requested",
company_id=company["id"], work_item_id=objective_id,
payload={"question": plan["clarification_needed"]},
)
return {"objective_id": str(objective_id), "tasks": children,
"clarification_needed": plan.get("clarification_needed") or None}
class DirectiveCreate(BaseModel):
message: str = Field(min_length=1, max_length=4000)
@app.post("/companies/{slug}/directives", status_code=201)
async def shareholder_directive(slug: str, body: DirectiveCreate) -> dict:
"""Shareholder line: free-form message becomes an objective the manager
decomposes; completion comes back as a milestone.update event."""
title = body.message.strip().splitlines()[0][:80]
result = await create_objective(slug, ObjectiveCreate(title=title, brief=body.message))
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
async with db.pool.connection() as conn:
await db.append_event(
conn, actor="shareholder:philip", event_type="directive.received",
company_id=company["id"], work_item_id=UUID(result["objective_id"]),
source="api", payload={"message": body.message[:500]})
return result
class AnswerCreate(BaseModel):
answer: str = Field(min_length=1, max_length=4000)
@app.post("/companies/{slug}/work-items/{item_id}/answer")
async def answer_blocked_item(slug: str, item_id: UUID, body: AnswerCreate) -> dict:
"""Shareholder answers a blocked/clarification item; work resumes."""
item = await db.fetch_one(
"SELECT wi.* FROM work_item wi JOIN company c ON c.id = wi.company_id "
"WHERE wi.id = %s AND c.slug = %s", item_id, slug)
if not item:
raise HTTPException(404, "no such work item")
if item["status"] != "blocked":
raise HTTPException(409, f"work item is {item['status']}, not blocked")
new_desc = f"{item['description']}\n\n## Shareholder answer\n{body.answer}"
if item["hermes_task_id"]:
# rerun the existing kanban task with the answer appended
try:
await hermes.update_task(item["hermes_task_id"], status="ready", body=new_desc)
except HermesError as e:
raise HTTPException(502, f"hermes update failed: {e}")
new_status = "in_progress" # poll loop picks the rerun up
else:
new_status = "ready" # never dispatched (e.g. dispatch.failed); dispatch fresh
async with db.pool.connection() as conn:
async with conn.transaction():
await conn.execute(
"UPDATE work_item SET description=%s, status=%s, updated_at=now() WHERE id=%s",
(new_desc, new_status, item_id))
await conn.execute(
"UPDATE employee SET status='working' WHERE id=%s", (item["owner_id"],))
await db.append_event(
conn, actor="shareholder:philip", event_type="clarification.answered",
company_id=item["company_id"], work_item_id=item_id,
source="api", payload={"answer": body.answer[:500]})
return {"status": new_status}
@app.get("/companies/{slug}/work-items")
async def list_work_items(slug: str) -> list[dict]:
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
if not company:
raise HTTPException(404, "no such company")
rows = await db.fetch_all(
"SELECT wi.*, e.name AS owner_name FROM work_item wi "
"LEFT JOIN employee e ON e.id = wi.owner_id "
"WHERE wi.company_id = %s ORDER BY wi.created_at", company["id"])
return [db.as_json(r) for r in rows]
# ---------------------------------------------------------------- global pause (kill switch v1)
@app.post("/pause")
async def pause() -> dict:
await redis_client.set(PAUSE_KEY, "1")
async with db.pool.connection() as conn:
await db.append_event(conn, actor="user:philip", event_type="company.paused",
source="api", payload={"scope": "global"})
return {"paused": True}
@app.post("/resume")
async def resume() -> dict:
await redis_client.delete(PAUSE_KEY)
async with db.pool.connection() as conn:
await db.append_event(conn, actor="user:philip", event_type="company.resumed",
source="api", payload={"scope": "global"})
return {"paused": False}