- 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>
376 lines
15 KiB
Python
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}
|