From 2f83075719bbca0eb3682c61dbf4fe397894baff Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Mon, 6 Jul 2026 09:54:23 +0000 Subject: feat(web): add ensureTaskLogStream and taskLogStreams for task card log streaming (task 6) Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 98 +++++++++++++++++++++++++++++++++++++++++++ web/test/tasks-board.test.mjs | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/web/app.js b/web/app.js index 70184bc..b22d352 100644 --- a/web/app.js +++ b/web/app.js @@ -2429,6 +2429,104 @@ function renderRunningView(tasks) { } } +// taskId -> { source: EventSource, execId: string } — tracks which execution +// each visible task's inline log-tail is currently attached to, so a poll- +// driven re-render never reopens a stream for the same execution twice, and +// correctly reopens when a Resume/Restart produces a fresh execution. +export const taskLogStreams = {}; + +// ensureTaskLogStream attaches (or leaves alone) a log stream for a task's +// most recent execution into logAreaEl. Every column's card uses this same +// mechanism — see docs/superpowers/specs/2026-07-06-tasks-board-design.md +// section 4: the /api/executions/{id}/logs/stream endpoint already replays- +// then-closes for a terminal execution and live-tails for a RUNNING one, so +// there is no separate "static" vs. "live" code path. +export async function ensureTaskLogStream(taskId, logAreaEl, { + fetchFn = fetch, + EventSourceImpl = (typeof EventSource !== 'undefined' ? EventSource : undefined), + apiBase = API_BASE, +} = {}) { + let execs; + try { + const res = await fetchFn(`${apiBase}/api/executions?task_id=${taskId}&limit=1`); + execs = res.ok ? await res.json() : []; + } catch { + return; + } + if (!execs || execs.length === 0) return; // no execution yet (Queue) + + const execId = execs[0].id; + const existing = taskLogStreams[taskId]; + if (existing && existing.execId === execId) return; // already attached to this execution + + if (existing) existing.source.close(); + // .children is a read-only live collection on a real DOM element — the + // only correct way to clear it is innerHTML (the fake log-area mock in + // web/test/tasks-board.test.mjs mirrors this via an innerHTML setter). + logAreaEl.innerHTML = ''; + + const src = new EventSourceImpl(`${apiBase}/api/executions/${execId}/logs/stream`); + taskLogStreams[taskId] = { source: src, execId }; + + let userScrolled = false; + if (logAreaEl.addEventListener) { + logAreaEl.addEventListener('scroll', () => { + const nearBottom = logAreaEl.scrollHeight - logAreaEl.scrollTop - logAreaEl.clientHeight < 50; + userScrolled = !nearBottom; + }); + } + + src.onmessage = (event) => { + let data; + try { data = JSON.parse(event.data); } catch { return; } + + const doc = (typeof document !== 'undefined') ? document : { createElement: (t) => ({ tag: t, className: '', textContent: '', children: [], appendChild(c) { this.children.push(c); } }) }; + const line = doc.createElement('div'); + line.className = 'log-line'; + + switch (data.type) { + case 'text': + line.classList.add('log-text'); + line.textContent = data.text ?? data.content ?? ''; + break; + case 'tool_use': { + line.classList.add('log-tool-use'); + const toolName = doc.createElement('span'); + toolName.className = 'tool-name'; + toolName.textContent = `[${data.name ?? 'Tool'}]`; + line.appendChild(toolName); + const inputStr = data.input ? JSON.stringify(data.input) : ''; + const inputPreview = doc.createElement('span'); + inputPreview.textContent = ' ' + inputStr.slice(0, 120); + line.appendChild(inputPreview); + break; + } + case 'cost': + line.classList.add('log-cost'); + line.textContent = `Cost: $${Number(data.total_cost ?? data.cost ?? 0).toFixed(3)}`; + break; + default: + return; + } + + logAreaEl.appendChild(line); + while (logAreaEl.childElementCount > 200) { + logAreaEl.removeChild(logAreaEl.firstElementChild); + } + if (!userScrolled) logAreaEl.scrollTop = logAreaEl.scrollHeight; + }; + + src.addEventListener('done', () => { + src.close(); + if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId]; + }); + + src.onerror = () => { + src.close(); + if (taskLogStreams[taskId] && taskLogStreams[taskId].source === src) delete taskLogStreams[taskId]; + }; +} + function startRunningLogStream(taskId, logArea) { fetch(`${API_BASE}/api/executions?task_id=${taskId}&limit=1`) .then(r => r.ok ? r.json() : []) diff --git a/web/test/tasks-board.test.mjs b/web/test/tasks-board.test.mjs index 1e35e18..2e1f276 100644 --- a/web/test/tasks-board.test.mjs +++ b/web/test/tasks-board.test.mjs @@ -249,3 +249,80 @@ describe('cardContentSignature', () => { assert.notEqual(cardContentSignature(cardA), cardContentSignature(cardB)); }); }); + +// ── ensureTaskLogStream: stream lifecycle (reuse vs. reopen vs. no-op) ───── + +import { ensureTaskLogStream, taskLogStreams } from '../app.js'; + +function makeFakeEventSource() { + const instances = []; + function FakeEventSource(url) { + this.url = url; + this.closed = false; + this._listeners = {}; + instances.push(this); + } + FakeEventSource.prototype.close = function () { this.closed = true; }; + FakeEventSource.prototype.addEventListener = function (type, fn) { this._listeners[type] = fn; }; + Object.defineProperty(FakeEventSource.prototype, 'onmessage', { writable: true, value: null }); + Object.defineProperty(FakeEventSource.prototype, 'onerror', { writable: true, value: null }); + FakeEventSource.instances = instances; + return FakeEventSource; +} + +function makeFakeLogArea() { + return { + children: [], + appendChild(c) { this.children.push(c); }, + removeChild(c) { this.children = this.children.filter(x => x !== c); }, + get childElementCount() { return this.children.length; }, + get firstElementChild() { return this.children[0]; }, + get innerHTML() { return ''; }, + set innerHTML(_) { this.children = []; }, // mirrors real DOM: assigning innerHTML clears children + scrollTop: 0, scrollHeight: 0, clientHeight: 0, addEventListener() {}, + }; +} + +describe('ensureTaskLogStream', () => { + it('does nothing when the task has no executions yet (Queue)', async () => { + const fetchFn = async () => ({ ok: true, json: async () => [] }); + const FakeES = makeFakeEventSource(); + const logArea = makeFakeLogArea(); + await ensureTaskLogStream('task-queue-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + assert.equal(FakeES.instances.length, 0); + }); + + it('opens a stream for a task with an execution', async () => { + const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-1' }] }); + const FakeES = makeFakeEventSource(); + const logArea = makeFakeLogArea(); + await ensureTaskLogStream('task-1', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + assert.equal(FakeES.instances.length, 1); + assert.match(FakeES.instances[0].url, /\/api\/executions\/exec-1\/logs\/stream/); + assert.equal(taskLogStreams['task-1'].execId, 'exec-1'); + }); + + it('does not reopen a stream already attached to the same execution', async () => { + const fetchFn = async () => ({ ok: true, json: async () => [{ id: 'exec-2' }] }); + const FakeES = makeFakeEventSource(); + const logArea = makeFakeLogArea(); + await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + await ensureTaskLogStream('task-2', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + assert.equal(FakeES.instances.length, 1, 'should not open a second stream for the same execution'); + }); + + it('closes the old stream and opens a new one when the execution id changes', async () => { + let call = 0; + const fetchFn = async () => { + call++; + return { ok: true, json: async () => [{ id: call === 1 ? 'exec-a' : 'exec-b' }] }; + }; + const FakeES = makeFakeEventSource(); + const logArea = makeFakeLogArea(); + await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + await ensureTaskLogStream('task-3', logArea, { fetchFn, EventSourceImpl: FakeES, apiBase: '' }); + assert.equal(FakeES.instances.length, 2); + assert.ok(FakeES.instances[0].closed, 'old stream should be closed'); + assert.equal(taskLogStreams['task-3'].execId, 'exec-b'); + }); +}); -- cgit v1.2.3