Live worker logs: see what an in-progress worker is doing/thinking

- GET /companies/{slug}/work-items/{id}/log proxies the Hermes task log
  (agent reasoning blocks + tool calls), tail-limited, served as text
- dashboard: every dispatched work item row gets a "log" link
- office: click a working employee sprite to open their live log

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fez5587andClaude Fable 5 committed 2026-08-04 15:08:07 -05:00
1 parent 027e7458f4
commit eff5cb4510
4 files changed
+40 -3

No files matched your search

+4
View File
@@ -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,
+19
View File
@@ -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)
+3 -1
View File
@@ -105,7 +105,9 @@ async function refresh() {
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>`;
const log = w.hermes_task_id
? ` <a href="/companies/${c.slug}/work-items/${w.id}/log" target="_blank" class="muted">log</a>` : '';
return `<tr><td>${w.title}${log}</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('');
+14 -2
View File
@@ -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 => `<a href="?company=${c.slug}" style="margin:0 .4rem; ${c.slug===co.slug?'font-weight:bold;':''}">${c.name}</a>`).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));