- routes PATCH /tasks/🆔 uniformly appendTaskLog + publishLog for every status
transition (claim/review/done/cancel/reopen) and on assign; best-effort, never
fails the mutation. No double-emission (log rides the 'log' channel; fsWatch
only watches .md so the .log append is not re-emitted).
- MCP agenthub_task_log tool + remoteClient.appendTaskLog + CLI 'task log <id>
--text [--agent][--level]'
- agenthub_work LOOP reminder: 'Report meaningful progress with agenthub_task_log'
- taskDetail: Live Console panel — historic lines via readTaskLog + live tail via
the named task-log SSE event filtered to this task id
- tests: +taskLog-live.test.ts (PATCH->one log event, no double change, historic
render, POST /log, remoteClient)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
5.0 KiB
TypeScript
106 lines
5.0 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join } from 'path';
|
|
import { buildApp } from '../src/server/index.js';
|
|
import { init } from '../src/cli/commands/init.js';
|
|
import { eventBus } from '../src/server/events.js';
|
|
import { readTaskLog } from '../src/core/services/taskLogService.js';
|
|
import { remoteClient } from '../src/cli/remoteClient.js';
|
|
|
|
describe('task live console (TSK-0074)', () => {
|
|
let cwd: string;
|
|
let app: ReturnType<typeof buildApp>;
|
|
|
|
beforeEach(() => {
|
|
cwd = mkdtempSync(join(tmpdir(), 'ah-tasklog-'));
|
|
init(cwd, { projectName: 'tasklog-test', yes: true });
|
|
app = buildApp(cwd);
|
|
});
|
|
|
|
afterEach(() => {
|
|
eventBus.removeAllListeners('log');
|
|
eventBus.removeAllListeners('change');
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
it('PATCH in_progress appends a status log line + emits exactly one task-log event (no double change)', async () => {
|
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
|
|
|
const logs: Array<{ taskId?: string; text?: string; level?: string }> = [];
|
|
const changes: unknown[] = [];
|
|
const onLog = (p: { taskId?: string; text?: string; level?: string }) => logs.push(p);
|
|
const onChange = (e: unknown) => changes.push(e);
|
|
eventBus.on('log', onLog);
|
|
eventBus.on('change', onChange);
|
|
|
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
|
|
|
eventBus.off('log', onLog);
|
|
eventBus.off('change', onChange);
|
|
|
|
// Exactly one task-log SSE event, carrying the task id + a "Claimed by" line.
|
|
expect(logs).toHaveLength(1);
|
|
expect(logs[0].taskId).toBe('TSK-0001');
|
|
expect(logs[0].text).toContain('Claimed by codex');
|
|
expect(logs[0].level).toBe('status');
|
|
// No double-emission on the change channel (one emitChange per PATCH).
|
|
expect(changes).toHaveLength(1);
|
|
|
|
// Persisted to the task's .log file.
|
|
const persisted = readTaskLog(cwd, 'TSK-0001');
|
|
expect(persisted.some((e) => e.text.includes('Claimed by codex') && e.level === 'status')).toBe(true);
|
|
});
|
|
|
|
it('logs every status transition (review, done)', async () => {
|
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
|
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'in_progress' } });
|
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
|
|
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'done' } });
|
|
|
|
const texts = readTaskLog(cwd, 'TSK-0001').map((e) => e.text);
|
|
expect(texts.some((t) => t.startsWith('Claimed by'))).toBe(true);
|
|
expect(texts.some((t) => t.startsWith('Submitted for review'))).toBe(true);
|
|
expect(texts.some((t) => t.startsWith('Approved — done'))).toBe(true);
|
|
});
|
|
|
|
it('task detail HTML renders the Live Console with historic lines', async () => {
|
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
|
await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'wrote failing test', agent: 'codex', level: 'info' } });
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001', headers: { accept: 'text/html' } });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.payload).toContain('Live Console');
|
|
expect(res.payload).toContain('wrote failing test');
|
|
// Live tail wires to the named task-log SSE event, scoped to this task id.
|
|
expect(res.payload).toContain("addEventListener('task-log'");
|
|
expect(res.payload).toContain('"TSK-0001"');
|
|
});
|
|
|
|
it('POST /tasks/:id/log returns the stored entry', async () => {
|
|
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
|
|
const res = await app.inject({ method: 'POST', url: '/tasks/TSK-0001/log', payload: { text: 'green: 12 tests', level: 'status' } });
|
|
expect(res.statusCode).toBe(200);
|
|
const entry = JSON.parse(res.payload) as { text: string; level: string; ts: string };
|
|
expect(entry.text).toBe('green: 12 tests');
|
|
expect(entry.level).toBe('status');
|
|
expect(entry.ts).toBeTruthy();
|
|
});
|
|
|
|
it('remoteClient.appendTaskLog POSTs to /tasks/:id/log', async () => {
|
|
vi.restoreAllMocks();
|
|
const stored = { ts: new Date().toISOString(), text: 'progress', level: 'info' };
|
|
globalThis.fetch = vi.fn().mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
text: () => Promise.resolve(JSON.stringify(stored)),
|
|
} as Response);
|
|
|
|
const res = await remoteClient.appendTaskLog('http://localhost:3377', 'TSK-0001', { text: 'progress', agent: 'codex' });
|
|
expect(res.text).toBe('progress');
|
|
const call = vi.mocked(fetch).mock.calls[0];
|
|
expect(String(call[0])).toBe('http://localhost:3377/tasks/TSK-0001/log');
|
|
expect((call[1] as RequestInit).method).toBe('POST');
|
|
});
|
|
});
|