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:
1 parent
027e7458f4
commit
eff5cb4510
4 files changed
+40
-3
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user