feat(agenthub): TSK-0074 — automatic agent progress logging to a task live console
- 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>
This commit is contained in:
parent
3f1fb76a84
commit
2c2193ec55
@ -1,5 +1,6 @@
|
|||||||
import { input, select } from '@inquirer/prompts';
|
import { input, select } from '@inquirer/prompts';
|
||||||
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, reopenTask, assignTask } from '../../core/services/taskService.js';
|
||||||
|
import { appendTaskLog } from '../../core/services/taskLogService.js';
|
||||||
import type { Task } from '../../core/schema.js';
|
import type { Task } from '../../core/schema.js';
|
||||||
|
|
||||||
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
export async function taskCreate(cwd: string, options: Partial<Task> = {}): Promise<void> {
|
||||||
@ -69,3 +70,12 @@ export function taskAssign(cwd: string, id: string, agentName: string): void {
|
|||||||
assignTask(cwd, id, agentName);
|
assignTask(cwd, id, agentName);
|
||||||
console.log(`AgentHub: Task assigned ${id} → ${agentName}`);
|
console.log(`AgentHub: Task assigned ${id} → ${agentName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function taskLog(
|
||||||
|
cwd: string,
|
||||||
|
id: string,
|
||||||
|
entry: { text: string; agent?: string; level?: string },
|
||||||
|
): void {
|
||||||
|
const rec = appendTaskLog(cwd, id, entry);
|
||||||
|
console.log(`AgentHub: logged ${id} ${rec.text}`);
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Command } from 'commander';
|
|||||||
import { init } from './commands/init.js';
|
import { init } from './commands/init.js';
|
||||||
import { status } from './commands/status.js';
|
import { status } from './commands/status.js';
|
||||||
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js';
|
||||||
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js';
|
import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign, taskLog } from './commands/task.js';
|
||||||
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
|
||||||
import { decisionCreate, decisionList } from './commands/decision.js';
|
import { decisionCreate, decisionList } from './commands/decision.js';
|
||||||
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
import { messageSend, inboxList, messageRead, inboxMarkRead, messageAck, messageReply } from './commands/message.js';
|
||||||
@ -365,6 +365,23 @@ export function createProgram(cwd: string): Command {
|
|||||||
taskReopen(projectCwd, id);
|
taskReopen(projectCwd, id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
taskCmd
|
||||||
|
.command('log <id>')
|
||||||
|
.description('Append a progress line to a task\'s live console (streams to open task-detail pages)')
|
||||||
|
.requiredOption('--text <text>', 'Progress line')
|
||||||
|
.option('--agent <agent>', 'Reporting agent')
|
||||||
|
.option('--level <level>', 'Log level (info | status | warn | error)')
|
||||||
|
.action(async (id, options: { text: string; agent?: string; level?: string }) => {
|
||||||
|
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||||
|
if (serverUrl) {
|
||||||
|
await runRemote(serverUrl, async () => {
|
||||||
|
await remoteClient.appendTaskLog(serverUrl, id, { text: options.text, agent: options.agent, level: options.level });
|
||||||
|
console.log(`AgentHub: logged ${id} ${options.text}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
taskLog(projectCwd, id, { text: options.text, agent: options.agent, level: options.level });
|
||||||
|
}
|
||||||
|
});
|
||||||
program.addCommand(taskCmd);
|
program.addCommand(taskCmd);
|
||||||
|
|
||||||
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
const handoffCmd = new Command('handoff').description('Manage handoffs');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
|
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
|
||||||
import type { IndexEntry } from '../core/index.js';
|
import type { IndexEntry } from '../core/index.js';
|
||||||
import type { InboxMessage } from '../core/services/messageService.js';
|
import type { InboxMessage } from '../core/services/messageService.js';
|
||||||
|
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
||||||
|
|
||||||
export class RemoteError extends Error {
|
export class RemoteError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@ -92,6 +93,14 @@ export const remoteClient = {
|
|||||||
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async appendTaskLog(
|
||||||
|
baseUrl: string,
|
||||||
|
id: string,
|
||||||
|
entry: { text: string; agent?: string; level?: string },
|
||||||
|
): Promise<TaskLogEntry> {
|
||||||
|
return request<TaskLogEntry>(baseUrl, 'POST', `/tasks/${id}/log`, entry);
|
||||||
|
},
|
||||||
|
|
||||||
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||||
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
doneTask,
|
doneTask,
|
||||||
} from '../core/services/taskService.js';
|
} from '../core/services/taskService.js';
|
||||||
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
import { createHandoff, getHandoff } from '../core/services/handoffService.js';
|
||||||
|
import { appendTaskLog } from '../core/services/taskLogService.js';
|
||||||
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
|
import { createMessage, listInbox, markMessageRead } from '../core/services/messageService.js';
|
||||||
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
import { addMemory, searchMemory } from '../core/services/memoryService.js';
|
||||||
import { createDecision } from '../core/services/decisionService.js';
|
import { createDecision } from '../core/services/decisionService.js';
|
||||||
@ -213,6 +214,7 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
'Never end your turn without relaunching agenthub_work.'
|
'Never end your turn without relaunching agenthub_work.'
|
||||||
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
|
: 'STAY REACHABLE: the instant you finish handling this, call agenthub_work again — ' +
|
||||||
'run it in the background so the next task or message reaches you in realtime. ' +
|
'run it in the background so the next task or message reaches you in realtime. ' +
|
||||||
|
'Report meaningful progress with agenthub_task_log while you work. ' +
|
||||||
'Never end your turn without relaunching agenthub_work.';
|
'Never end your turn without relaunching agenthub_work.';
|
||||||
const immediate = await finder();
|
const immediate = await finder();
|
||||||
if (immediate) return asText({ ...immediate, loop: LOOP });
|
if (immediate) return asText({ ...immediate, loop: LOOP });
|
||||||
@ -262,6 +264,11 @@ export async function startMcpServer(cwd: string): Promise<void> {
|
|||||||
{ id: z.string() },
|
{ id: z.string() },
|
||||||
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
async ({ id }) => asText(remote ? await remoteClient.doneTask(serverUrl!, id, {}) : doneTask(root, id)));
|
||||||
|
|
||||||
|
server.tool('agenthub_task_log',
|
||||||
|
'Report meaningful progress on the task you are working on — one short line — so the architect can watch it live on the task console. Call it as you work (e.g. "wrote failing test", "green: 12 tests", "blocked on X").',
|
||||||
|
{ id: z.string(), text: z.string(), agent: z.string().optional(), level: z.string().optional() },
|
||||||
|
async ({ id, text, agent, level }) => asText(remote ? await remoteClient.appendTaskLog(serverUrl!, id, { text, agent, level }) : appendTaskLog(root, id, { text, agent, level })));
|
||||||
|
|
||||||
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
|
server.tool('agenthub_memory_add', 'Record a result / finding / blocker as a memory entry.',
|
||||||
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
|
{ title: z.string(), category: z.enum(['architecture', 'product', 'technical', 'implementation', 'lesson']).optional(), content: z.string() },
|
||||||
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
|
async (o) => asText(remote ? await remoteClient.addMemory(serverUrl!, o) : addMemory(root, o)));
|
||||||
|
|||||||
@ -210,6 +210,19 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
|
// Token & cost rollup per agent (real recorded + time-estimated, clearly flagged).
|
||||||
app.get('/budget', async () => computeBudget(cwd));
|
app.get('/budget', async () => computeBudget(cwd));
|
||||||
|
|
||||||
|
// Auto-log a task status transition to its live console. Best-effort: the
|
||||||
|
// .log write + task-log SSE fan-out must never fail the underlying mutation.
|
||||||
|
// (No double-emission: publishLog rides the separate 'log' channel, and
|
||||||
|
// fsWatch only watches .md files, so the .log append is not re-emitted.)
|
||||||
|
const logTaskStatus = (id: string, text: string, agent?: string) => {
|
||||||
|
try {
|
||||||
|
const entry = appendTaskLog(cwd, id, { text, agent, level: 'status' });
|
||||||
|
eventBus.publishLog({ taskId: id, ...entry });
|
||||||
|
} catch {
|
||||||
|
/* logging is best-effort */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Tasks ───────────────────────────────────────────────────────────────
|
// ─── Tasks ───────────────────────────────────────────────────────────────
|
||||||
app.get('/tasks', async (request) => {
|
app.get('/tasks', async (request) => {
|
||||||
const { status, role } = request.query as { status?: string; role?: string };
|
const { status, role } = request.query as { status?: string; role?: string };
|
||||||
@ -302,6 +315,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
// change). Fires task/updated so a waiting `agenthub work` auto-claims it.
|
||||||
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
if (patch.assignedTo !== undefined && patch.status === undefined) {
|
||||||
const assigned = assignTask(cwd, id, patch.assignedTo);
|
const assigned = assignTask(cwd, id, patch.assignedTo);
|
||||||
|
logTaskStatus(id, `Addressed to ${assigned.assignedTo}`, assigned.assignedTo);
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@ -348,6 +362,17 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
|||||||
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Uniformly log the transition for every status-changing caller (claim /
|
||||||
|
// review / done / cancel / reopen), so the live console tracks progress.
|
||||||
|
const logText =
|
||||||
|
task.status === 'in_progress' ? `Claimed by ${task.claimedBy ?? task.assignedTo ?? 'agent'}`
|
||||||
|
: task.status === 'review' ? `Submitted for review${task.reviewer ? ` → ${task.reviewer}` : ''}`
|
||||||
|
: task.status === 'done' ? `Approved — done${task.doneBy ? ` by ${task.doneBy}` : ''}`
|
||||||
|
: task.status === 'cancelled' ? 'Cancelled'
|
||||||
|
: task.status === 'open' ? 'Reopened'
|
||||||
|
: `Status → ${task.status}`;
|
||||||
|
logTaskStatus(id, logText, task.claimedBy ?? task.assignedTo ?? task.reviewer ?? task.doneBy);
|
||||||
|
|
||||||
emitChange(
|
emitChange(
|
||||||
{
|
{
|
||||||
type: 'task',
|
type: 'task',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { getTask } from '../core/services/taskService.js';
|
|||||||
import { getTaskActivity } from '../core/services/activityService.js';
|
import { getTaskActivity } from '../core/services/activityService.js';
|
||||||
import { getDecision } from '../core/services/decisionService.js';
|
import { getDecision } from '../core/services/decisionService.js';
|
||||||
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
|
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
|
||||||
|
import { readTaskLog, type TaskLogEntry } from '../core/services/taskLogService.js';
|
||||||
import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
import { agentAvatar, designTokensCss, escapeHtml, statusPill, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
|
||||||
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
import type { ActivityItem, Decision, Handoff } from '../core/schema.js';
|
||||||
|
|
||||||
@ -127,6 +128,13 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.join('')
|
.join('')
|
||||||
: '<div class="empty">No linked decisions for this task.</div>';
|
: '<div class="empty">No linked decisions for this task.</div>';
|
||||||
|
|
||||||
|
const logEntries: TaskLogEntry[] = readTaskLog(cwd, id);
|
||||||
|
const logLine = (e: TaskLogEntry) =>
|
||||||
|
`<div class="log-line" data-level="${escapeHtml(e.level ?? 'info')}"><span class="log-ts">${escapeHtml(ago(e.ts))}</span>${e.agent ? `<span class="log-agent">${escapeHtml(e.agent)}</span>` : ''}<span class="log-text">${escapeHtml(e.text)}</span></div>`;
|
||||||
|
const consoleRows = logEntries.length
|
||||||
|
? logEntries.map(logLine).join('')
|
||||||
|
: '<div class="empty" data-empty>No console output yet.</div>';
|
||||||
|
|
||||||
const activityRows = activity.length
|
const activityRows = activity.length
|
||||||
? activity
|
? activity
|
||||||
.map(
|
.map(
|
||||||
@ -178,6 +186,14 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
|
||||||
.summary { min-width:0;overflow-wrap:anywhere; }
|
.summary { min-width:0;overflow-wrap:anywhere; }
|
||||||
.empty { color:var(--muted); }
|
.empty { color:var(--muted); }
|
||||||
|
.console { max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px;font:12px/1.5 var(--font-mono); }
|
||||||
|
.log-line { display:flex;gap:8px;align-items:baseline;overflow-wrap:anywhere; }
|
||||||
|
.log-ts { color:var(--muted);white-space:nowrap;flex:0 0 auto; }
|
||||||
|
.log-agent { color:var(--accent);white-space:nowrap;flex:0 0 auto; }
|
||||||
|
.log-text { color:var(--text);min-width:0; }
|
||||||
|
.log-line[data-level="status"] .log-text { color:var(--status-review); }
|
||||||
|
.log-line[data-level="warn"] .log-text { color:var(--status-review); }
|
||||||
|
.log-line[data-level="error"] .log-text { color:#F85149; }
|
||||||
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
|
@media (max-width:640px){ .activity-row{grid-template-columns:1fr}.actor{white-space:normal} }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@ -190,6 +206,10 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
<div class="stats">${taskStats}</div>
|
<div class="stats">${taskStats}</div>
|
||||||
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
${body.trim() ? `<pre>${escapeHtml(body.trim())}</pre>` : ''}
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Live Console</h2>
|
||||||
|
<div class="console" id="taskConsole">${consoleRows}</div>
|
||||||
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Handoffs</h2>
|
<h2>Handoffs</h2>
|
||||||
${handoffRows}
|
${handoffRows}
|
||||||
@ -205,6 +225,26 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
|
|||||||
</main>
|
</main>
|
||||||
${taskModalHtml()}
|
${taskModalHtml()}
|
||||||
${appHeaderJs()}
|
${appHeaderJs()}
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var TASK_ID = ${JSON.stringify(task.id)};
|
||||||
|
var box = document.getElementById('taskConsole');
|
||||||
|
if(!box || !('EventSource' in window)) return;
|
||||||
|
function fmtAgo(iso){ var t=Date.parse(iso); if(isNaN(t))return''; var s=Math.max(0,Math.floor((Date.now()-t)/1000)); if(s<60)return s+'s'; var m=Math.floor(s/60); if(m<60)return m+'m'; var h=Math.floor(m/60); if(h<24)return h+'h'; return Math.floor(h/24)+'d'; }
|
||||||
|
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
||||||
|
function append(p){
|
||||||
|
var empty=box.querySelector('[data-empty]'); if(empty) empty.remove();
|
||||||
|
var el=document.createElement('div'); el.className='log-line'; el.setAttribute('data-level', p.level||'info');
|
||||||
|
el.innerHTML='<span class="log-ts">'+esc(fmtAgo(p.ts||new Date().toISOString()))+' ago</span>'+(p.agent?'<span class="log-agent">'+esc(p.agent)+'</span>':'')+'<span class="log-text">'+esc(p.text)+'</span>';
|
||||||
|
box.appendChild(el); box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var s=new EventSource('/events');
|
||||||
|
s.addEventListener('task-log', function(ev){ try{ var p=JSON.parse(ev.data); if(p && p.taskId===TASK_ID) append(p); }catch(_){} });
|
||||||
|
window.addEventListener('pagehide', function(){ try{ s.close(); }catch(_){} });
|
||||||
|
} catch(_){}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
105
tests/taskLog-live.test.ts
Normal file
105
tests/taskLog-live.test.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user