- 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>
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
import type { Task, Handoff, Decision, Memory, Message, ActivityItem } from '../core/schema.js';
|
|
import type { IndexEntry } from '../core/index.js';
|
|
import type { InboxMessage } from '../core/services/messageService.js';
|
|
import type { TaskLogEntry } from '../core/services/taskLogService.js';
|
|
|
|
export class RemoteError extends Error {
|
|
constructor(public status: number, message: string) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function request<T>(baseUrl: string, method: string, path: string, body?: unknown): Promise<T> {
|
|
const url = `${baseUrl}${path}`;
|
|
let res: Response;
|
|
try {
|
|
res = await fetch(url, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
throw new RemoteError(0, `Cannot reach AgentHub server at ${baseUrl}: ${message}`);
|
|
}
|
|
|
|
const text = await res.text();
|
|
if (!res.ok) {
|
|
throw new RemoteError(res.status, text || `HTTP ${res.status}`);
|
|
}
|
|
if (!text) {
|
|
throw new RemoteError(res.status, 'Empty response from server');
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export const remoteClient = {
|
|
async getStatus(baseUrl: string): Promise<string> {
|
|
return request<{ body: string }>(baseUrl, 'GET', '/status').then((r) => r.body);
|
|
},
|
|
|
|
async announce(baseUrl: string, agent: string, role?: string, action: 'joined' | 'left' = 'joined'): Promise<void> {
|
|
await request<{ ok: boolean }>(baseUrl, 'POST', '/announce', { agent, role, action });
|
|
},
|
|
|
|
async updateStatus(baseUrl: string): Promise<string> {
|
|
return request<{ body: string }>(baseUrl, 'POST', '/status/update').then((r) => r.body);
|
|
},
|
|
|
|
async createTask(baseUrl: string, options: Partial<Task>): Promise<Task> {
|
|
return request<Task>(baseUrl, 'POST', '/tasks', options);
|
|
},
|
|
|
|
async listTasks(baseUrl: string, filters?: { status?: string; role?: string }): Promise<IndexEntry[]> {
|
|
const params = new URLSearchParams((filters ?? {}) as Record<string, string>);
|
|
const qs = params.toString();
|
|
return request<IndexEntry[]>(baseUrl, 'GET', `/tasks${qs ? '?' + qs : ''}`);
|
|
},
|
|
|
|
async getTask(baseUrl: string, id: string): Promise<{ task: Task; body: string }> {
|
|
return request<{ task: Task; body: string }>(baseUrl, 'GET', `/tasks/${id}`);
|
|
},
|
|
|
|
async claimTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
|
},
|
|
|
|
async doneTask(
|
|
baseUrl: string,
|
|
id: string,
|
|
meta?: { tokens?: number; duration?: number; by?: string },
|
|
): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, {
|
|
status: 'done',
|
|
doneTokens: meta?.tokens,
|
|
doneDuration: meta?.duration,
|
|
doneBy: meta?.by,
|
|
});
|
|
},
|
|
|
|
async assignTask(baseUrl: string, id: string, agentName: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { assignedTo: agentName });
|
|
},
|
|
|
|
async reviewTask(baseUrl: string, id: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'review' });
|
|
},
|
|
|
|
async reopenTask(baseUrl: string, id: string): Promise<Task> {
|
|
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'open' });
|
|
},
|
|
|
|
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
|
|
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> {
|
|
return request<Handoff>(baseUrl, 'POST', '/handoffs', options);
|
|
},
|
|
|
|
async listHandoffs(baseUrl: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', '/handoffs');
|
|
},
|
|
|
|
async getHandoff(baseUrl: string, id: string): Promise<{ handoff: Handoff; body: string }> {
|
|
return request<{ handoff: Handoff; body: string }>(baseUrl, 'GET', `/handoffs/${id}`);
|
|
},
|
|
|
|
async createDecision(baseUrl: string, options: Partial<Decision>): Promise<Decision> {
|
|
return request<Decision>(baseUrl, 'POST', '/decisions', options);
|
|
},
|
|
|
|
async listDecisions(baseUrl: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', '/decisions');
|
|
},
|
|
|
|
async addMemory(baseUrl: string, options: Partial<Memory>): Promise<Memory> {
|
|
return request<Memory>(baseUrl, 'POST', '/memory', options);
|
|
},
|
|
|
|
async searchMemory(baseUrl: string, query: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', `/memory/search?q=${encodeURIComponent(query)}`);
|
|
},
|
|
|
|
async listMemory(baseUrl: string): Promise<IndexEntry[]> {
|
|
return request<IndexEntry[]>(baseUrl, 'GET', '/memory');
|
|
},
|
|
|
|
async sendMessage(baseUrl: string, options: Partial<Message>): Promise<Message> {
|
|
return request<Message>(baseUrl, 'POST', '/messages', options);
|
|
},
|
|
|
|
async getInbox(baseUrl: string, agent: string, unreadOnly = false): Promise<InboxMessage[]> {
|
|
const q = `?agent=${encodeURIComponent(agent)}${unreadOnly ? '&unread=1' : ''}`;
|
|
return request<InboxMessage[]>(baseUrl, 'GET', `/messages${q}`);
|
|
},
|
|
|
|
async markMessageRead(baseUrl: string, id: string): Promise<Message> {
|
|
return request<Message>(baseUrl, 'POST', `/messages/${id}/read`);
|
|
},
|
|
|
|
async ackMessage(baseUrl: string, id: string, by?: string): Promise<Message> {
|
|
return request<Message>(baseUrl, 'POST', `/messages/${id}/ack`, by ? { by } : undefined);
|
|
},
|
|
|
|
async getMessage(baseUrl: string, id: string): Promise<{ message: Message; body: string }> {
|
|
return request<{ message: Message; body: string }>(baseUrl, 'GET', `/messages/${id}`);
|
|
},
|
|
|
|
async delegate(baseUrl: string, auto: boolean): Promise<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }> {
|
|
return request<{ suggestion?: { task: IndexEntry; role: string; preferredAgent: string }; handoff?: Handoff }>(baseUrl, 'POST', `/delegate?auto=${auto}`);
|
|
},
|
|
};
|