diff --git a/engine/hermes_client.py b/engine/hermes_client.py index 301e252..e5e7f22 100644 --- a/engine/hermes_client.py +++ b/engine/hermes_client.py @@ -121,6 +121,10 @@ class HermesClient: """Returns {name, path, size, mime_type, data_url} (base64 data URL).""" return await self._json("GET", "/api/files/read", params={"path": path}) + async def task_log(self, task_id: str) -> str: + data = await self._json("GET", f"/api/plugins/kanban/tasks/{task_id}/log") + return data.get("content") or "" + # ------------------------------------------------------------ kanban (= work dispatch) async def create_task(self, title: str, *, body: str, assignee: str, tenant: str, diff --git a/engine/main.py b/engine/main.py index a4dc8bd..6705a10 100644 --- a/engine/main.py +++ b/engine/main.py @@ -422,6 +422,25 @@ async def _set_company_state(slug: str, expect: str, new: str, event: str) -> di return {"slug": slug, "lifecycle_state": new} +@app.get("/companies/{slug}/work-items/{item_id}/log") +async def work_item_log(slug: str, item_id: UUID, tail: int = 6000) -> Response: + """Live worker log (incl. reasoning) for a dispatched work item.""" + 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 not item["hermes_task_id"]: + return Response("(not dispatched yet — no log)", media_type="text/plain") + try: + content = await hermes.task_log(item["hermes_task_id"]) + except HermesError as e: + raise HTTPException(502, f"log fetch failed: {e}") + header = f"work item: {item['title']}\nstatus: {item['status']}\n{'='*60}\n" + return Response(header + content[-max(tail, 500):], + media_type="text/plain; charset=utf-8") + + # ---------------------------------------------------------------- workspace files (demos) diff --git a/engine/static/index.html b/engine/static/index.html index f6dee4c..7f96c59 100644 --- a/engine/static/index.html +++ b/engine/static/index.html @@ -105,7 +105,9 @@ async function refresh() { const rows = items.filter(w => w.type !== 'objective').map(w => { const answer = w.status === 'blocked' ? ` ` : ''; - return `${w.title}${w.owner_name ?? ''}${pill(w.status)}${answer}`; + const log = w.hermes_task_id + ? ` log` : ''; + return `${w.title}${log}${w.owner_name ?? ''}${pill(w.status)}${answer}`; }).join(''); const objs = items.filter(w => w.type === 'objective').map(w => `${w.title}${pill(w.status)}`).join(''); diff --git a/engine/static/office.html b/engine/static/office.html index 03ab78c..3aa64f2 100644 --- a/engine/static/office.html +++ b/engine/static/office.html @@ -69,7 +69,18 @@ const px = (x,y,w,h,c) => { ctx.fillStyle=c; ctx.fillRect(x|0,y|0,w,h); }; const hash = s => [...s].reduce((a,ch)=>(a*31+ch.charCodeAt(0))>>>0, 7); const center = ([tx,ty]) => [tx*T+T/2, ty*T+T/2]; -let tick = 0, actors = {}, companyName = '…'; +let tick = 0, actors = {}, companyName = '…', companySlug = null; + +// click a working employee to open their live worker log +document.getElementById('c').addEventListener('click', ev => { + const r = ev.target.getBoundingClientRect(); + const x = (ev.clientX - r.left) * (480 / r.width), y = (ev.clientY - r.top) * (320 / r.height); + for (const a of Object.values(actors)) + if (a.itemId && Math.abs(x - a.x) < 12 && y > a.y - 26 && y < a.y + 6) { + window.open(`/companies/${companySlug}/work-items/${a.itemId}/log`, '_blank'); + return; + } +}); // ---------------------------------------------------------------- tiles function drawMap() { @@ -178,7 +189,7 @@ async function poll() { const want = new URLSearchParams(location.search).get('company'); const co = companies.find(c=>c.slug===want) ?? companies[0]; if (!co) return; - companyName = co.name; + companyName = co.name; companySlug = co.slug; document.getElementById('tabs').innerHTML = companies.length > 1 ? companies.map(c => `${c.name}`).join('') : ''; @@ -195,6 +206,7 @@ async function poll() { hair:HAIR[hash(e.name)%HAIR.length], name:e.name, i }; } const w = working[e.name]; a.task = w?.title; + a.itemId = w?.id; const taskType = w?.type; if (e.status !== a.status || taskType !== a.taskType) { a.status = e.status; a.taskType = taskType; route(a, targetFor(a, i));