- docker-compose.yml: Postgres 16 (schema auto-applied) + Redis, nonstandard host ports 5433/6380. - engine/db.py: async psycopg pool, append_event in caller's transaction. - engine/souls.py: versioned role charters (manager/engineer/qa) + reporting protocol appended to every soul. - engine/main.py: POST/GET companies, employee hire with Hermes profile creation (outside tx, retryable; DB authoritative), event feed, archive with profile cleanup, global pause/resume in Redis. - Verified live end-to-end: create -> 4 events + 3 real Hermes profiles; pause flips health; archive 204 removes profiles, keeps rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
"""FarmHouse engine API — Slice 1 core.
|
|
|
|
Run: uvicorn engine.main:app --reload --port 8100
|
|
"""
|
|
from contextlib import asynccontextmanager
|
|
|
|
import redis.asyncio as aioredis
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from engine import db
|
|
from engine.config import settings
|
|
from engine.hermes_client import HermesClient, HermesError
|
|
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)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await db.open_pool()
|
|
yield
|
|
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("/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")
|
|
|
|
async with db.pool.connection() as conn:
|
|
async with conn.transaction():
|
|
cur = await conn.execute(
|
|
"INSERT INTO company (slug, name, description, lifecycle_state) "
|
|
"VALUES (%s, %s, %s, 'active') RETURNING id",
|
|
(body.slug, body.name, body.brief),
|
|
)
|
|
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, hermes_profile) "
|
|
"VALUES (%s, %s, %s, %s) RETURNING id",
|
|
(company_id, spec.name, spec.title, 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 profiles outside the tx: DB is source of truth; profile creation is
|
|
# retryable. Failure leaves employees hired with a repairable profile gap.
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------- 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}
|