Clarification answer path + pixel office v0
- POST /companies/{slug}/work-items/{id}/answer: shareholder answers a
blocked item; answer is appended to the description and the existing
kanban task is rerun (status=ready + updated body), or the item goes
back to ready for fresh dispatch if it was never dispatched
- Dashboard: answer button on blocked work item rows (native prompt)
- /office: pixel-art 2D office view — one desk row per employee, avatar
color by role, LED/typing animation driven only by real engine status,
live event ticker (PRD 12: no fake activity)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fd3feb6a08
commit
c2320c3dc8
3 files changed
+153
-2
No files matched your search
@@ -77,6 +77,11 @@ 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")
|
||||
@@ -295,6 +300,46 @@ async def shareholder_directive(slug: str, body: DirectiveCreate) -> dict:
|
||||
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)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏠 FarmHouse Holdings
|
||||
<a href="/office" style="color:var(--run); font-size:.8rem; text-decoration:none;">🕹 office view</a>
|
||||
<span id="health" class="pill">…</span>
|
||||
<button id="pausebtn" onclick="togglePause()">…</button>
|
||||
</h1>
|
||||
@@ -53,6 +54,15 @@ async function togglePause() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function answerItem(slug, id) {
|
||||
const answer = window.prompt('Answer / unblock instruction for this task:');
|
||||
if (!answer || !answer.trim()) return;
|
||||
const r = await fetch(`/companies/${slug}/work-items/${id}/answer`, {method:'POST',
|
||||
headers:{'Content-Type':'application/json'}, body: JSON.stringify({answer: answer.trim()})});
|
||||
if (!r.ok) alert(`answer failed: ${(await r.json()).detail ?? r.status}`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function sendDirective(ev, slug) {
|
||||
ev.preventDefault();
|
||||
const el = document.getElementById('dir-' + slug);
|
||||
@@ -86,8 +96,11 @@ async function refresh() {
|
||||
j(`/companies/${c.slug}/events?limit=40`)]);
|
||||
const emps = detail.employees.map(e =>
|
||||
`<tr><td>${e.name}</td><td class="muted">${e.title}</td><td>${pill(e.status)}</td></tr>`).join('');
|
||||
const rows = items.filter(w => w.type !== 'objective').map(w =>
|
||||
`<tr><td>${w.title}</td><td class="muted">${w.owner_name ?? ''}</td><td>${pill(w.status)}</td></tr>`).join('');
|
||||
const rows = items.filter(w => w.type !== 'objective').map(w => {
|
||||
const answer = w.status === 'blocked'
|
||||
? ` <button onclick="answerItem('${c.slug}','${w.id}')">answer</button>` : '';
|
||||
return `<tr><td>${w.title}</td><td class="muted">${w.owner_name ?? ''}</td><td>${pill(w.status)}${answer}</td></tr>`;
|
||||
}).join('');
|
||||
const objs = items.filter(w => w.type === 'objective').map(w =>
|
||||
`<tr><td>${w.title}</td><td>${pill(w.status)}</td></tr>`).join('');
|
||||
const feed = events.map(e => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FarmHouse Office</title>
|
||||
<style>
|
||||
body { background:#101418; color:#d8dee6; font:14px/1.5 system-ui, sans-serif;
|
||||
margin:0; padding:1rem; text-align:center; }
|
||||
h1 { font-size:1.1rem; } a { color:#5c9de0; }
|
||||
canvas { image-rendering:pixelated; border:1px solid #2a323c; border-radius:8px;
|
||||
background:#151b22; max-width:100%; }
|
||||
#ticker { max-width:800px; margin:.5rem auto; color:#8a94a0; font-size:.8rem;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏠 FarmHouse Office <a href="/">← dashboard</a></h1>
|
||||
<canvas id="c" width="400" height="220" style="width:800px"></canvas>
|
||||
<div id="ticker">…</div>
|
||||
<script>
|
||||
// Pixel office v0: every drawn state is real engine state (PRD 12: no fake activity).
|
||||
// desk row per employee; avatar color by role; LED + animation by live status.
|
||||
const ctx = document.getElementById('c').getContext('2d');
|
||||
const ROLE_COLORS = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' };
|
||||
const STATUS_LED = { idle:'#4caf7d', working:'#5c9de0', blocked:'#e06c5c' };
|
||||
let state = { companies: [] }, tick = 0;
|
||||
|
||||
async function j(u) { return (await fetch(u)).json(); }
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const companies = (await j('/companies')).filter(c => c.lifecycle_state === 'active');
|
||||
for (const c of companies) {
|
||||
const [detail, items, events] = await Promise.all([
|
||||
j(`/companies/${c.slug}`), j(`/companies/${c.slug}/work-items`),
|
||||
j(`/companies/${c.slug}/events?limit=1`)]);
|
||||
c.employees = detail.employees;
|
||||
c.working = Object.fromEntries(items.filter(w => w.status === 'in_progress' && w.owner_name)
|
||||
.map(w => [w.owner_name, w.title]));
|
||||
c.lastEvent = events[0];
|
||||
}
|
||||
state.companies = companies;
|
||||
const ev = companies[0]?.lastEvent;
|
||||
if (ev) document.getElementById('ticker').textContent =
|
||||
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload ?? {}).slice(0, 110)}`;
|
||||
} catch (e) { document.getElementById('ticker').textContent = 'engine unreachable'; }
|
||||
}
|
||||
|
||||
function px(x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
|
||||
|
||||
function drawEmployee(x, y, emp, workingOn) {
|
||||
const c = ROLE_COLORS[emp.role] ?? '#8a94a0';
|
||||
const working = emp.status === 'working';
|
||||
const bob = working ? (tick >> 3) % 2 : 0; // typing bob only when actually working
|
||||
// desk
|
||||
px(x - 4, y + 18, 28, 4, '#3a4653'); px(x - 2, y + 22, 3, 8, '#2a323c'); px(x + 19, y + 22, 3, 8, '#2a323c');
|
||||
// monitor (lit only when working)
|
||||
px(x + 12, y + 8 - 0, 10, 8, '#20262e'); px(x + 13, y + 9, 8, 6, working ? '#9fd0ff' : '#333c46');
|
||||
// avatar: head, body, arms
|
||||
px(x, y + bob, 8, 8, '#e8c39e'); // head
|
||||
px(x - 1, y + 8 + bob, 10, 10, c); // body
|
||||
px(x - 3, y + 10 + bob, 2, 6, c); px(x + 9, y + 10 + bob, 2, 6, c);
|
||||
// status LED
|
||||
px(x + 22, y, 4, 4, STATUS_LED[emp.status] ?? '#8a94a0');
|
||||
ctx.fillStyle = '#d8dee6'; ctx.font = '6px monospace';
|
||||
ctx.fillText(`${emp.name} · ${emp.title}`, x - 4, y + 38);
|
||||
if (workingOn) { ctx.fillStyle = '#8a94a0'; ctx.fillText(workingOn.slice(0, 34), x - 4, y + 45); }
|
||||
if (emp.status === 'blocked') { ctx.fillStyle = '#e06c5c'; ctx.fillText('!? needs answer', x - 4, y + 45); }
|
||||
}
|
||||
|
||||
function draw() {
|
||||
tick++;
|
||||
px(0, 0, 400, 220, '#151b22');
|
||||
px(0, 200, 400, 20, '#1a2027'); // floor
|
||||
let y = 24;
|
||||
for (const c of state.companies) {
|
||||
ctx.fillStyle = '#d8dee6'; ctx.font = '8px monospace';
|
||||
ctx.fillText(`▪ ${c.name}`, 12, y - 10);
|
||||
let x = 30;
|
||||
for (const emp of c.employees ?? []) {
|
||||
drawEmployee(x, y, emp, c.working?.[emp.name]);
|
||||
x += 120;
|
||||
}
|
||||
y += 80;
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
poll(); setInterval(poll, 5000); draw();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user