feat(board): session batch — delete + unified fixed header + in-card live console + reviewer field + 3/4 kanban token-insights redesign

Delete endpoint + trash UI, shared header across all pages, task-log SSE + in-card console, reviewer separate from assignee (board+team), codex board redesign (3/4 kanban + company half-donuts + per-agent bars + square cards).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-08 20:43:42 +02:00
parent 1bb18d96b5
commit 0884032db1
17 changed files with 1152 additions and 365 deletions

View File

@ -14,6 +14,7 @@ export interface IndexEntry {
status?: string;
role?: string;
assignedTo?: string;
reviewer?: string;
tags?: string;
// Handoff-specific routing fields
fromRole?: string;
@ -45,6 +46,7 @@ export class Index {
status TEXT,
role TEXT,
assignedTo TEXT,
reviewer TEXT,
tags TEXT,
fromRole TEXT,
toRole TEXT,
@ -60,7 +62,7 @@ export class Index {
const existingCols = new Set(
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
);
for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
for (const col of ['reviewer', 'fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
}
@ -72,6 +74,7 @@ export class Index {
status: null,
role: null,
assignedTo: null,
reviewer: null,
tags: null,
fromRole: null,
toRole: null,
@ -83,12 +86,12 @@ export class Index {
};
const insert = this.db.prepare(`
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, reviewer, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @reviewer, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
assignedTo=@assignedTo, tags=@tags,
assignedTo=@assignedTo, reviewer=@reviewer, tags=@tags,
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
taskId=@taskId, relatedTasks=@relatedTasks
`);
@ -100,6 +103,12 @@ export class Index {
search.run(params);
}
/** Permanently drop an entity from both the entities table and the FTS index. */
remove(id: string): void {
this.db.prepare('DELETE FROM entities WHERE id = @id').run({ id });
this.db.prepare('DELETE FROM search WHERE id = @id').run({ id });
}
search(query: string): Array<{ id: string; type: string; title: string }> {
const ftsQuery = toFts5Phrase(query);
if (!ftsQuery) return [];

View File

@ -15,6 +15,7 @@ export const TaskSchema = z.object({
priority: Priority.default('medium'),
role: Role.optional(),
assignedTo: z.string().optional(),
reviewer: z.string().optional(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
dueDate: z.string().datetime().optional(),

View File

@ -0,0 +1,54 @@
import { join } from 'path';
import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { getEntityDir } from '../paths.js';
/** One streamed progress line for a task's live console. */
export interface TaskLogEntry {
ts: string;
agent?: string;
level?: string;
text: string;
}
/** Log lives next to the task's markdown: `.agenthub/tasks/TSK-XXXX.log` (JSONL). */
function logPath(cwd: string, id: string): string {
const dir = getEntityDir(cwd, 'tasks');
mkdirSync(dir, { recursive: true });
return join(dir, `${id}.log`);
}
/** Append one progress line to a task's log. Returns the stored entry. */
export function appendTaskLog(
cwd: string,
id: string,
entry: { text: string; agent?: string; level?: string; ts?: string },
): TaskLogEntry {
if (!id) throw new Error('Task ID is required');
const text = String(entry.text ?? '');
if (!text.trim()) throw new Error('log text is required');
const rec: TaskLogEntry = {
ts: entry.ts || new Date().toISOString(),
agent: entry.agent,
level: entry.level || 'info',
text,
};
appendFileSync(logPath(cwd, id), JSON.stringify(rec) + '\n');
return rec;
}
/** Read a task's log (most recent `limit` lines). Empty array if none yet. */
export function readTaskLog(cwd: string, id: string, limit = 500): TaskLogEntry[] {
if (!id) throw new Error('Task ID is required');
const p = logPath(cwd, id);
if (!existsSync(p)) return [];
const lines = readFileSync(p, 'utf8').split('\n').filter(Boolean).slice(-limit);
const out: TaskLogEntry[] = [];
for (const line of lines) {
try {
out.push(JSON.parse(line) as TaskLogEntry);
} catch {
// Tolerate a torn/partial final line rather than failing the whole read.
}
}
return out;
}

View File

@ -1,9 +1,11 @@
import { join } from 'path';
import { rmSync } from 'fs';
import { getEntityDir } from '../paths.js';
import { getNextId } from '../counter.js';
import { readEntity, writeEntity } from '../files.js';
import { TaskSchema, type Task } from '../schema.js';
import { Index } from '../index.js';
import { loadConfig } from '../config.js';
export function createTask(cwd: string, options: Partial<Task> = {}): Task {
const now = new Date().toISOString();
@ -15,6 +17,7 @@ export function createTask(cwd: string, options: Partial<Task> = {}): Task {
priority: options.priority ?? 'medium',
role: options.role,
assignedTo: options.assignedTo,
reviewer: options.reviewer,
createdAt: now,
updatedAt: now,
});
@ -72,8 +75,14 @@ export function doneTask(
});
}
export function reviewTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'review' });
export function reviewTask(cwd: string, id: string, reviewer?: string): Task {
const { task } = getTask(cwd, id);
const resolvedReviewer = reviewer?.trim() || getPreferredReviewer(cwd);
const patch: Partial<Task> = { status: 'review' };
if (resolvedReviewer && resolvedReviewer !== task.assignedTo) {
patch.reviewer = resolvedReviewer;
}
return updateTask(cwd, id, patch);
}
export function cancelTask(cwd: string, id: string): Task {
@ -84,6 +93,24 @@ export function reopenTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'open' });
}
/**
* Permanently delete a task: remove its markdown file and drop it from the
* search index. Unlike {@link cancelTask} (which just flips status), this
* leaves no trace used to purge ghost/test tasks from the board. Idempotent:
* a missing file is not an error.
*/
export function deleteTask(cwd: string, id: string): { id: string } {
if (!id) throw new Error('Task ID is required');
const filePath = join(getEntityDir(cwd, 'tasks'), `${id}.md`);
rmSync(filePath, { force: true });
const index = new Index(cwd);
index.remove(id);
index.close();
return { id };
}
/**
* Address an OPEN task to an agent without claiming it: sets assignedTo but
* leaves the status untouched (open). The agent's `agenthub work` then auto-
@ -94,6 +121,14 @@ export function assignTask(cwd: string, id: string, agentName: string): Task {
return updateTask(cwd, id, { assignedTo: agentName });
}
function getPreferredReviewer(cwd: string): string | undefined {
try {
return loadConfig(cwd).roles.reviewer?.preferredAgent;
} catch {
return undefined;
}
}
function toIndexEntry(task: Task, filePath: string) {
return {
id: task.id,
@ -106,6 +141,7 @@ function toIndexEntry(task: Task, filePath: string) {
status: task.status,
role: task.role,
assignedTo: task.assignedTo,
reviewer: task.reviewer,
tags: JSON.stringify(task.tags),
};
}

View File

@ -1,7 +1,7 @@
import { loadConfig } from '../core/config.js';
import { listMessages } from '../core/services/messageService.js';
import { listTasks } from '../core/services/taskService.js';
import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { IndexEntry } from '../core/index.js';
function compactDuration(ms: number): string {
@ -84,14 +84,9 @@ export function renderActivityHtml(cwd: string): string {
<title>AgentHub Activity</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project,.id,.meta,.state { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a.active,.nav a:hover { color:var(--text);background:var(--raised); }
${appHeaderCss()}
body { padding: 96px 20px 32px; }
.id,.meta,.state { color:var(--muted);font:12px/1.4 var(--font-mono); }
main { max-width:1120px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);gap:12px;align-items:start; }
.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px;min-width:0; }
.panel-head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
@ -109,22 +104,12 @@ export function renderActivityHtml(cwd: string): string {
.task-row:hover,.activity-row:hover { color:var(--text); }
.agent { display:flex;justify-content:flex-end; }
.empty { color:var(--muted);font-size:12px; }
@media (max-width:860px){ main{grid-template-columns:1fr}.app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center} }
@media (max-width:860px){ main{grid-template-columns:1fr} }
@media (max-width:560px){ .activity-row,.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a class="active" href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
${appHeader(config.projectName, 'activity')}
<main>
<section class="panel">
<div class="panel-head"><h1>Recent Activity</h1><span class="meta">${activityRows.length} events</span></div>
@ -135,6 +120,8 @@ export function renderActivityHtml(cwd: string): string {
<div class="task-list">${archive}</div>
</section>
</main>
${taskModalHtml()}
${appHeaderJs()}
</body>
</html>`;
}

View File

@ -1,6 +1,6 @@
import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js';
import { agentAvatar, designTokensCss, escapeHtml } from './ui-shared.js';
import { agentAvatar, designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { IndexEntry } from '../core/index.js';
function compactDuration(ms: number): string {
@ -48,14 +48,9 @@ export function renderArchiveHtml(cwd: string): string {
<title>AgentHub Done Archive</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project,.id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
${appHeaderCss()}
body { padding: 96px 20px 32px; }
.id,.meta { color:var(--muted);font:12px/1.4 var(--font-mono); }
main { max-width:1040px;margin:0 auto; }
.head { display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px; }
h1 { font-size:18px;margin:0; }
@ -65,21 +60,11 @@ export function renderArchiveHtml(cwd: string): string {
.title { min-width:0;overflow-wrap:anywhere;font-weight:600; }
.agent { display:flex;justify-content:flex-end; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
@media (max-width:640px){ .task-row{grid-template-columns:1fr;gap:4px}.agent{justify-content:flex-start} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
${appHeader(config.projectName, 'archive')}
<main>
<div class="head">
<h1>Done Archive</h1>
@ -87,6 +72,8 @@ export function renderArchiveHtml(cwd: string): string {
</div>
<div class="task-list">${rows}</div>
</main>
${taskModalHtml()}
${appHeaderJs()}
</body>
</html>`;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { loadConfig } from '../core/config.js';
import { getDecision, listDecisions } from '../core/services/decisionService.js';
import { designTokensCss, escapeHtml } from './ui-shared.js';
import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, appHeaderJs } from './ui-shared.js';
import type { Decision } from '../core/schema.js';
function ago(iso: string): string {
@ -65,14 +65,8 @@ export function renderDecisionsHtml(cwd: string): string {
<title>AgentHub Decisions</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project { color:var(--muted);font:12px/1.4 var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a.active,.nav a:hover { color:var(--text);background:var(--raised); }
${appHeaderCss()}
body { padding: 96px 20px 32px; }
main { max-width:980px;margin:0 auto;display:grid;gap:10px; }
.decision { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.decision-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
@ -83,25 +77,16 @@ export function renderDecisionsHtml(cwd: string): string {
p { margin:0;color:var(--text);overflow-wrap:anywhere; }
.meta { margin-top:10px;display:grid;gap:5px;color:var(--muted);font-size:12px; }
.empty { color:var(--muted);background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.project{width:100%} }
</style>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a class="active" href="/decisions">Decisions</a>
</nav>
</header>
${appHeader(config.projectName, 'decisions')}
<main>
<h1>Decision Log</h1>
${rows}
</main>
${taskModalHtml()}
${appHeaderJs()}
</body>
</html>`;
}

View File

@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events';
export type AgentHubEventType = 'task' | 'handoff' | 'decision' | 'memory' | 'message' | 'agent';
export type AgentHubEventAction = 'created' | 'updated' | 'joined' | 'left';
export type AgentHubEventAction = 'created' | 'updated' | 'deleted' | 'joined' | 'left';
export interface AgentHubEvent {
type: AgentHubEventType;
@ -12,6 +12,7 @@ export interface AgentHubEvent {
status?: string;
role?: string;
assignedTo?: string;
reviewer?: string;
}
/**
@ -21,11 +22,27 @@ export interface AgentHubEvent {
* Singleton per Node.js process in server mode that is always exactly one
* process, which is the intended topology.
*/
/** A live progress line an agent streams while working a task. */
export interface TaskLogPayload {
taskId: string;
ts: string;
agent?: string;
level?: string;
text: string;
}
class AgentHubEventBus extends EventEmitter {
/** Publish a change event to all current SSE subscribers. */
publish(event: AgentHubEvent): void {
this.emit('change', event);
}
/** Publish a task-log line delivered as a NAMED `task-log` SSE event so the
* board's generic onmessage handler ignores it and only the task-detail
* live console picks it up. */
publishLog(payload: TaskLogPayload): void {
this.emit('log', payload);
}
}
export const eventBus = new AgentHubEventBus();

View File

@ -60,7 +60,7 @@ function toEvent(
case 'task':
return {
stamp,
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo) },
event: { type, action, id, title: str(fm.title), status: str(fm.status), role: str(fm.role), assignedTo: str(fm.assignedTo), reviewer: str(fm.reviewer) },
};
case 'handoff':
return {

View File

@ -1,6 +1,7 @@
import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask } from '../core/services/taskService.js';
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask, assignTask, deleteTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js';
import { appendTaskLog, readTaskLog } from '../core/services/taskLogService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { createMessage, listMessages, listInbox, markMessageRead } from '../core/services/messageService.js';
@ -109,6 +110,13 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
eventBus.on('change', listener);
// Task-log lines ride a NAMED `task-log` SSE event so the board's generic
// onmessage handler ignores them; only the task-detail live console listens.
const logListener = (payload: unknown) => {
raw.write(`event: task-log\ndata: ${JSON.stringify(payload)}\n\n`);
};
eventBus.on('log', logListener);
const keepAliveTimer = setInterval(() => {
raw.write(': keepalive\n\n');
}, SSE_KEEPALIVE_MS);
@ -117,6 +125,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
request.raw.on('close', () => {
clearInterval(keepAliveTimer);
eventBus.off('change', listener);
eventBus.off('log', logListener);
});
});
@ -181,12 +190,24 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status,
role: task.role,
assignedTo: task.assignedTo,
reviewer: task.reviewer,
},
task.updatedAt,
);
return task;
});
app.delete('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
const result = deleteTask(cwd, id);
emitChange({ type: 'task', action: 'deleted', id }, new Date().toISOString());
return result;
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Delete failed');
}
});
app.get('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
try {
@ -209,6 +230,27 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
}
});
// ─── Live agent console ────────────────────────────────────────────────────
// GET returns the task's streamed progress log; POST appends one line and
// fans it out over SSE so an open task-detail console tails it live.
app.get('/tasks/:id/log', async (request) => {
const { id } = request.params as { id: string };
return { log: readTaskLog(cwd, id) };
});
app.post('/tasks/:id/log', async (request, reply) => {
const { id } = request.params as { id: string };
const { text, agent, level } = request.body as { text?: string; agent?: string; level?: string };
let entry;
try {
entry = appendTaskLog(cwd, id, { text: text ?? '', agent, level });
} catch (err) {
return badRequest(reply, err instanceof Error ? err.message : 'Invalid log line');
}
eventBus.publishLog({ taskId: id, ...entry });
return entry;
});
app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const patch = request.body as Partial<Task>;
@ -226,6 +268,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: assigned.status,
role: assigned.role,
assignedTo: assigned.assignedTo,
reviewer: assigned.reviewer,
},
assigned.updatedAt,
);
@ -246,7 +289,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
});
break;
case 'review':
task = reviewTask(cwd, id);
task = reviewTask(cwd, id, patch.reviewer);
break;
case 'cancelled':
task = cancelTask(cwd, id);
@ -267,6 +310,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
status: task.status,
role: task.role,
assignedTo: task.assignedTo,
reviewer: task.reviewer,
},
task.updatedAt,
);

View File

@ -3,7 +3,7 @@ import { getTask } from '../core/services/taskService.js';
import { getTaskActivity } from '../core/services/activityService.js';
import { getDecision } from '../core/services/decisionService.js';
import { getHandoff, listHandoffs } from '../core/services/handoffService.js';
import { agentAvatar, designTokensCss, escapeHtml, statusPill } 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';
function ago(iso: string): string {
@ -149,14 +149,9 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
<title>${escapeHtml(task.id)} · AgentHub</title>
<style>
${designTokensCss()}
body { padding: 0 20px 32px; }
.app-header { position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:12px;margin:0 -20px 18px;padding:14px 20px;border-bottom:1px solid var(--border);background:rgba(15,23,42,.96); }
.brand { font-weight:700;font-size:17px; }
.project,.id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
.spacer { flex:1; }
.nav { display:flex;gap:4px;border:1px solid var(--border);background:var(--surface);padding:3px;border-radius:8px; }
.nav a { color:var(--muted);text-decoration:none;padding:7px 10px;border-radius:6px;font-size:13px; }
.nav a:hover { color:var(--text);background:var(--raised); }
${appHeaderCss()}
body { padding: 96px 20px 32px; }
.id,.when,.actor { color:var(--muted);font:12px/1.4 var(--font-mono); }
main { max-width:1040px;margin:0 auto;display:grid;gap:12px; }
.hero,.panel { background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:14px; }
.topline,.item-top { display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px; }
@ -183,21 +178,11 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
.kind { color:var(--accent);font:11px/1.4 var(--font-mono); }
.summary { min-width:0;overflow-wrap:anywhere; }
.empty { color:var(--muted); }
@media (max-width:640px){ .app-header{align-items:flex-start;flex-wrap:wrap}.spacer{display:none}.nav{width:100%}.nav a{flex:1;text-align:center}.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>
</head>
<body>
<header class="app-header">
<span class="brand">AgentHub</span>
<span class="project">${escapeHtml(config.projectName)}</span>
<span class="spacer"></span>
<nav class="nav" aria-label="Primary">
<a href="/board">Board</a>
<a href="/team">Team</a>
<a href="/activity">Activity</a>
<a href="/decisions">Decisions</a>
</nav>
</header>
${appHeader(config.projectName, 'task')}
<main>
<section class="hero">
<div class="topline"><span class="id">${escapeHtml(task.id)}</span>${statusPill(task.status)}${task.assignedTo ? agentAvatar(task.assignedTo, { size: 24 }) : ''}</div>
@ -218,6 +203,8 @@ export function renderTaskDetailHtml(cwd: string, id: string): string {
${activityRows}
</section>
</main>
${taskModalHtml()}
${appHeaderJs()}
</body>
</html>`;
}

View File

@ -15,10 +15,10 @@
import { loadConfig } from '../core/config.js';
import { listTasks } from '../core/services/taskService.js';
import { designTokensCss, escapeHtml, pageHeader, providerLogo, providerMeta } from './ui-shared.js';
import { designTokensCss, escapeHtml, appHeader, appHeaderCss, taskModalHtml, newTaskModalJs, providerLogo, providerMeta } from './ui-shared.js';
import type { Config, OrgNode, Task } from '../core/schema.js';
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'updatedAt'>;
type TaskRow = Pick<Task, 'id' | 'status' | 'assignedTo' | 'reviewer' | 'updatedAt'>;
interface AgentState { state: 'active' | 'reviewing'; task: TaskRow; }
/** Per-agent live state for the initial server render (in_progress → active). */
@ -27,9 +27,12 @@ function computeStates(cwd: string): Map<string, AgentState> {
const active = new Map<string, TaskRow>();
const review = new Map<string, TaskRow>();
for (const t of tasks) {
if (!t.assignedTo) continue;
if (t.status === 'in_progress') { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
else if (t.status === 'review') { const e = review.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) review.set(t.assignedTo, t); }
if (t.status === 'in_progress' && t.assignedTo) { const e = active.get(t.assignedTo); if (!e || t.updatedAt > e.updatedAt) active.set(t.assignedTo, t); }
else if (t.status === 'review') {
// Review state belongs to the REVIEWER (falls back to the assignee).
const who = t.reviewer || t.assignedTo;
if (who) { const e = review.get(who); if (!e || t.updatedAt > e.updatedAt) review.set(who, t); }
}
}
const out = new Map<string, AgentState>();
for (const [name, task] of active) out.set(name, { state: 'active', task });
@ -153,7 +156,8 @@ export function renderTeamHtml(cwd: string): string {
<title>AgentHub Team</title>
<style>
${designTokensCss()}
body { overflow-x: hidden; }
${appHeaderCss()}
body { overflow-x: hidden; padding-top: 96px; }
.scroll { overflow-x: auto; padding-bottom: 12px; }
.chart { position: relative; display: inline-flex; min-width: 100%; justify-content: center; padding: 12px 24px 40px; }
@ -255,7 +259,7 @@ export function renderTeamHtml(cwd: string): string {
</style>
</head>
<body>
${pageHeader(config.projectName, 'team')}
${appHeader(config.projectName, 'team')}
<div class="team-toolbar">
<span class="flow-caption" id="flowCaption" hidden></span>
<span class="tb-spacer"></span>
@ -279,12 +283,13 @@ export function renderTeamHtml(cwd: string): string {
(function() {
var NS = 'http://www.w3.org/2000/svg';
var SEG = 720, EDGE_DUR = 1850;
var connDot = document.getElementById('conn-dot');
var connBox = document.getElementById('sseStatus');
var connLabel = document.getElementById('sseLabel');
var edgeMap = {}; // nodeId -> { fwd, rev } path from its parent
var parentOf = {}; // nodeId -> parent nodeId
var taskState = {}, baseline = false;
function setConn(ok) { if (connDot) { connDot.style.background = ok ? 'var(--green)' : 'var(--status-review)'; connDot.title = ok ? 'connected' : 'reconnecting'; } }
function setConn(ok) { if (!connBox) return; connBox.classList.remove('stale', 'down'); if (!ok) connBox.classList.add('down'); if (connLabel) connLabel.textContent = ok ? 'connected' : 'reconnecting'; }
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function ago(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 centerTop(el, box) { var r = el.getBoundingClientRect(); return { x: r.left - box.left + r.width/2, y: r.top - box.top }; }
@ -405,9 +410,8 @@ export function renderTeamHtml(cwd: string): string {
var active = {}, review = {};
tasks.forEach(function(t) {
if (!t.assignedTo) return;
if (t.status === 'in_progress') { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
else if (t.status === 'review') { if (!review[t.assignedTo] || t.updatedAt > review[t.assignedTo].updatedAt) review[t.assignedTo] = t; }
if (t.status === 'in_progress' && t.assignedTo) { if (!active[t.assignedTo] || t.updatedAt > active[t.assignedTo].updatedAt) active[t.assignedTo] = t; }
else if (t.status === 'review') { var who = t.reviewer || t.assignedTo; if (who && (!review[who] || t.updatedAt > review[who].updatedAt)) review[who] = t; }
});
document.querySelectorAll('.node[data-agent]').forEach(function(card) {
var name = card.getAttribute('data-agent');
@ -553,6 +557,8 @@ export function renderTeamHtml(cwd: string): string {
}
})();
</script>
${taskModalHtml()}
${newTaskModalJs()}
</body>
</html>`;
}

View File

@ -255,16 +255,19 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
return `
<header style="
position: sticky;
position: fixed;
top: 0;
z-index: 10;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
padding-bottom: 12px;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
background: rgba(15, 23, 42, .96);
backdrop-filter: blur(10px);
">
${mark}
<h1 style="font-size:18px;margin:0;font-weight:600;">AgentHub</h1>
@ -279,3 +282,219 @@ export function pageHeader(projectName: string, current: 'board' | 'team' | 'act
<span id="conn-dot" style="width:8px;height:8px;border-radius:50%;background:var(--green);" title="connected"></span>
</header>`;
}
// ─────────────────────────────────────────────────────────────────────────────
// Unified app header — the SAME chrome on every page: brand, nav, a working
// "New task" button (opens the shared modal) and a live connection status.
// The visual design mirrors the board header; these use the designTokensCss
// token names (--font-mono / --font-sans, danger hard-coded) so they render
// identically on the non-board pages.
// ─────────────────────────────────────────────────────────────────────────────
export type HeaderPage = 'board' | 'team' | 'activity' | 'decisions' | 'archive' | 'task';
/** CSS for the shared header + new-task modal + toasts. Include once per page. */
export function appHeaderCss(): string {
return `
.app-header {
position: fixed; top: 0; left: 0; right: 0; z-index: 40;
display: flex; align-items: center; gap: 16px; min-height: 64px;
margin: 0; padding: 10px 20px;
border-bottom: 1px solid var(--border);
background: rgba(15, 23, 42, .96); backdrop-filter: blur(10px);
}
.app-header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.app-header .mark { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid rgba(88,166,255,.38); border-radius: 8px; display: grid; place-items: center; background: var(--raised); }
.app-header .mark svg { width: 22px; height: 22px; }
.brand-copy { min-width: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.brand-title { font-weight: 700; font-size: 17px; }
.project-name { color: var(--muted); font-size: 12px; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42vw; }
.header-spacer { flex: 1; }
.app-header .nav { display: flex; align-items: center; gap: 4px; border: 1px solid var(--border); background: var(--surface); padding: 3px; border-radius: 8px; }
.app-header .nav-link { min-height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border-radius: 6px; color: var(--muted); font-size: 13px; text-decoration: none; transition: color 180ms ease, background 180ms ease; }
.app-header .nav-link:hover, .app-header .nav-link.active { color: var(--text); background: var(--raised); }
.new-task-btn { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; padding: 0 14px; border-radius: 8px; border: 1px solid rgba(88,166,255,.45); background: linear-gradient(180deg, rgba(88,166,255,.20), rgba(88,166,255,.10)); color: var(--text); font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, box-shadow 160ms ease; }
.new-task-btn:hover { background: rgba(88,166,255,.28); box-shadow: 0 4px 16px rgba(88,166,255,.18); }
.new-task-btn:active { transform: translateY(1px); }
.new-task-btn .plus { font-size: 16px; line-height: 1; margin-top: -1px; }
.sse-status { display: inline-flex; align-items: center; gap: 7px; min-height: 34px; color: var(--muted); font: 12px/1 var(--font-mono); white-space: nowrap; }
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.16); }
.sse-status.stale .conn-dot { background: var(--status-review); box-shadow: 0 0 0 3px rgba(210,153,34,.14); }
.sse-status.down .conn-dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.14); }
@media (max-width: 680px) {
.app-header { flex-wrap: wrap; gap: 10px 12px; }
.header-spacer { display: none; }
.app-header .nav { order: 3; width: 100%; }
.app-header .nav-link { flex: 1; justify-content: center; }
}
/* ── New-task modal ─────────────────────────────────────────────────── */
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px; background: rgba(2,6,18,.62); backdrop-filter: blur(3px); animation: modalFade 140ms ease; }
.modal-backdrop[hidden] { display: none; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal { width: min(520px, 100%); background: var(--surface); border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 24px 64px rgba(0,0,0,.5); animation: modalRise 180ms cubic-bezier(.2,.7,.2,1); }
@keyframes modalRise { from { transform: translateY(12px); opacity: .4; } to { transform: none; opacity: 1; } }
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 8px; }
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 700; }
.modal-x { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border); background: var(--raised); color: var(--muted); font-size: 20px; line-height: 1; cursor: pointer; transition: color 140ms, border-color 140ms; }
.modal-x:hover { color: var(--text); border-color: var(--muted); }
#tmForm { padding: 6px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.modal-field { display: flex; flex-direction: column; gap: 5px; }
.modal-field-inline { flex-direction: row; align-items: center; gap: 12px; }
.modal-field-inline .modal-label { margin: 0; }
.modal-label { color: var(--muted); font: 600 11px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
.modal-field input, .modal-field textarea, .modal-field select { background: var(--raised); border: 1px solid var(--border); border-radius: 8px; color: var(--text); font: 14px/1.4 var(--font-sans); padding: 9px 11px; }
.modal-field textarea { resize: vertical; min-height: 84px; }
.modal-field input:focus, .modal-field textarea:focus, .modal-field select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.modal-note { margin: 0; color: var(--muted); font-size: 11.5px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
.modal-cancel, .modal-create { min-height: 38px; padding: 0 16px; border-radius: 8px; font: 600 13px/1 var(--font-sans); cursor: pointer; transition: background 160ms ease, transform 120ms ease, border-color 160ms ease; }
.modal-cancel { border: 1px solid var(--border); background: var(--raised); color: var(--muted); }
.modal-cancel:hover { color: var(--text); border-color: var(--muted); }
.modal-create { border: 1px solid var(--green); background: rgba(34,197,94,.16); color: #d1fadf; }
.modal-create:hover { background: rgba(34,197,94,.26); }
.modal-create:active, .modal-cancel:active { transform: translateY(1px); }
/* ── Toasts ─────────────────────────────────────────────────────────── */
.toasts { position: fixed; right: 18px; bottom: 18px; z-index: 70; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { display: flex; align-items: center; gap: 8px; background: var(--raised); border: 1px solid var(--border); border-left: 3px solid var(--green); border-radius: 10px; padding: 9px 13px; color: var(--text); font: 13px/1.3 var(--font-sans); box-shadow: 0 8px 28px rgba(0,0,0,.42); animation: toastIn 220ms cubic-bezier(.2,.7,.2,1); max-width: 340px; }
.toast.out { animation: toastOut 240ms ease forwards; }
.toast .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 3px rgba(34,197,94,.18); flex: 0 0 auto; }
.toast.err { border-left-color: #F85149; }
.toast.err .dot { background: #F85149; box-shadow: 0 0 0 3px rgba(248,81,73,.18); }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }`;
}
/** The shared header markup: brand, nav, New-task button, live status. */
export function appHeader(projectName: string, current: HeaderPage): string {
const mark = `<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><path d="M5 7.5h8.5a5.5 5.5 0 0 1 0 11H5v-11Z" stroke="#58A6FF" stroke-width="1.8"/><path d="M8.5 5.5h7a4 4 0 0 1 0 8h-7v-8Z" stroke="#22C55E" stroke-width="1.8"/></svg></span>`;
const link = (label: string, path: string, key: HeaderPage) =>
`<a class="nav-link${current === key ? ' active' : ''}" href="${path}">${label}</a>`;
return `
<header class="app-header">
<div class="brand">
${mark}
<div class="brand-copy">
<span class="brand-title">AgentHub</span>
<span class="project-name" id="projectName">${escapeHtml(projectName)}</span>
</div>
</div>
<span class="header-spacer"></span>
<nav class="nav" aria-label="Primary">
${link('Board', '/board', 'board')}
${link('Team', '/team', 'team')}
${link('Activity', '/activity', 'activity')}
${link('Decisions', '/decisions', 'decisions')}
</nav>
<button class="new-task-btn" id="newTaskBtn" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="plus" aria-hidden="true">+</span> New task
</button>
<span class="sse-status stale" id="sseStatus">
<span class="conn-dot" aria-hidden="true"></span>
<span id="sseLabel">connecting</span>
</span>
</header>`;
}
/** The New-task modal + a toasts container. Place near the end of <body>. */
export function taskModalHtml(): string {
return `
<div class="modal-backdrop" id="taskModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="tmHeading">
<div class="modal-head">
<h2 id="tmHeading">New task</h2>
<button class="modal-x" id="tmClose" type="button" aria-label="Close">&times;</button>
</div>
<form id="tmForm" autocomplete="off">
<label class="modal-field">
<span class="modal-label">Title</span>
<input id="tmTitleInput" type="text" placeholder="What needs doing?" required maxlength="140" />
</label>
<label class="modal-field">
<span class="modal-label">Description</span>
<textarea id="tmDesc" rows="4" placeholder="Context, acceptance criteria, links… (optional)"></textarea>
</label>
<label class="modal-field modal-field-inline">
<span class="modal-label">Priority</span>
<select id="tmPriority">
<option value="low">low</option>
<option value="medium" selected>medium</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<p class="modal-note">Created unassigned the architect picks it up and delegates it.</p>
<div class="modal-actions">
<button type="button" class="modal-cancel" id="tmCancel">Cancel</button>
<button type="submit" class="modal-create">Create task</button>
</div>
</form>
</div>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>`;
}
/**
* Live connection dot via SSE for pages that do NOT manage their own
* EventSource. (team.ts has its own SSE and repoints its status itself, so it
* uses newTaskModalJs() alone.)
*/
export function connectionStatusJs(): string {
return `
<script>
(function(){
var box=document.getElementById('sseStatus'), label=document.getElementById('sseLabel');
if(!box) return;
function setConn(state,text){ box.classList.remove('stale','down'); if(state==='stale')box.classList.add('stale'); if(state==='down')box.classList.add('down'); if(label)label.textContent=text; }
if(!('EventSource' in window)){ setConn('stale','polling'); return; }
try { var s=new EventSource('/events'); s.onopen=function(){ setConn('ok','connected'); }; s.onerror=function(){ setConn('down','reconnecting'); }; }
catch(_){ setConn('down','offline'); }
})();
</script>`;
}
/**
* New-task modal wiring + a toast helper. After a successful create it
* navigates to /board so the CEO sees the new card land. (The board wires its
* own richer version do not include this there.)
*/
export function newTaskModalJs(): string {
return `
<script>
(function(){
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
window.ahToast = function(msg, o){ o=o||{}; var wrap=document.getElementById('toasts'); if(!wrap) return; var el=document.createElement('div'); el.className='toast'+(o.error?' err':''); el.innerHTML='<span class="dot"></span><span>'+esc(msg)+'</span>'; wrap.appendChild(el); setTimeout(function(){ el.classList.add('out'); setTimeout(function(){ if(el.parentNode) el.parentNode.removeChild(el); }, 240); }, o.ms||3200); };
var btn=document.getElementById('newTaskBtn'), modal=document.getElementById('taskModal'), form=document.getElementById('tmForm');
if(!btn||!modal||!form) return;
var titleInput=document.getElementById('tmTitleInput');
function open(){ modal.hidden=false; btn.setAttribute('aria-expanded','true'); if(titleInput) setTimeout(function(){ titleInput.focus(); }, 20); }
function close(){ modal.hidden=true; btn.setAttribute('aria-expanded','false'); form.reset(); }
btn.addEventListener('click', open);
var x=document.getElementById('tmClose'); if(x) x.addEventListener('click', close);
var c=document.getElementById('tmCancel'); if(c) c.addEventListener('click', close);
modal.addEventListener('click', function(e){ if(e.target===modal) close(); });
document.addEventListener('keydown', function(e){ if(e.key==='Escape' && !modal.hidden) close(); });
form.addEventListener('submit', async function(e){
e.preventDefault();
var title=(titleInput.value||'').trim(); if(!title) return;
var description=(document.getElementById('tmDesc').value||'').trim();
var priority=document.getElementById('tmPriority').value||undefined;
try {
var res=await fetch('/tasks',{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ title:title, description:description, priority:priority }) });
if(!res.ok) throw new Error('create failed ('+res.status+')');
var task=await res.json();
window.ahToast(task.id+' created \\u00b7 opening board…');
close();
setTimeout(function(){ window.location.href='/board'; }, 400);
} catch(err){ window.ahToast(err.message||'create failed',{error:true}); }
});
})();
</script>`;
}
/** Convenience bundle for static pages with no own SSE: dot + new-task modal. */
export function appHeaderJs(): string {
return connectionStatusJs() + newTaskModalJs();
}

View File

@ -60,6 +60,19 @@ describe('server routes', () => {
expect(JSON.parse(res.payload).status).toBe('review');
});
it('PATCH /tasks/:id → review accepts reviewer separately from assignedTo', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer', assignedTo: 'codex' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review', reviewer: 'claude' } });
expect(res.statusCode).toBe(200);
const task = JSON.parse(res.payload);
expect(task.status).toBe('review');
expect(task.assignedTo).toBe('codex');
expect(task.reviewer).toBe('claude');
const listRes = await app.inject({ method: 'GET', url: '/tasks' });
expect(JSON.parse(listRes.payload)[0]).toMatchObject({ assignedTo: 'codex', reviewer: 'claude' });
});
it('PATCH /tasks/:id → cancelled', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } });

View File

@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { appendTaskLog, readTaskLog } from '../src/core/services/taskLogService.js';
describe('taskLogService', () => {
let cwd: string;
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'ah-log-'));
});
afterEach(() => {
rmSync(cwd, { recursive: true, force: true });
});
it('returns an empty log for a task with no output yet', () => {
expect(readTaskLog(cwd, 'TSK-0001')).toEqual([]);
});
it('appends lines and reads them back in order', () => {
appendTaskLog(cwd, 'TSK-0001', { text: 'cloning repo', agent: 'backyard' });
appendTaskLog(cwd, 'TSK-0001', { text: 'running build', agent: 'backyard', level: 'info' });
const log = readTaskLog(cwd, 'TSK-0001');
expect(log).toHaveLength(2);
expect(log[0].text).toBe('cloning repo');
expect(log[0].agent).toBe('backyard');
expect(log[1].text).toBe('running build');
expect(log[0].ts).toBeTruthy();
});
it('keeps each task log isolated', () => {
appendTaskLog(cwd, 'TSK-0001', { text: 'a' });
appendTaskLog(cwd, 'TSK-0002', { text: 'b' });
expect(readTaskLog(cwd, 'TSK-0001')).toHaveLength(1);
expect(readTaskLog(cwd, 'TSK-0002')[0].text).toBe('b');
});
it('rejects empty log text', () => {
expect(() => appendTaskLog(cwd, 'TSK-0001', { text: ' ' })).toThrow();
});
});

View File

@ -4,8 +4,10 @@ import { tmpdir } from 'os';
import { join } from 'path';
import {
createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask, deleteTask,
} from '../src/core/services/taskService.js';
import { init } from '../src/cli/commands/init.js';
import { Index } from '../src/core/index.js';
describe('taskService', () => {
let cwd: string;
@ -49,6 +51,25 @@ describe('taskService', () => {
expect(listed[0].id).toBe(task.id);
});
it('records an explicit reviewer separately from the assignee', () => {
const task = createTask(cwd, { title: 'Review me', role: 'implementer', assignedTo: 'codex' });
const inReview = reviewTask(cwd, task.id, 'claude');
expect(inReview.status).toBe('review');
expect(inReview.assignedTo).toBe('codex');
expect(inReview.reviewer).toBe('claude');
const listed = listTasks(cwd, { status: 'review' });
expect(listed[0].assignedTo).toBe('codex');
expect(listed[0].reviewer).toBe('claude');
});
it('uses the preferred reviewer when reviewTask is called without one', () => {
init(cwd, { projectName: 'reviewer-test', yes: true });
const task = createTask(cwd, { title: 'Review default', role: 'implementer', assignedTo: 'codex' });
const inReview = reviewTask(cwd, task.id);
expect(inReview.reviewer).toBe('claude');
});
it('cancels a task', () => {
const task = createTask(cwd, { title: 'D', role: 'implementer' });
const cancelled = cancelTask(cwd, task.id);
@ -72,4 +93,31 @@ describe('taskService', () => {
expect(read.title).toBe('F');
expect(read.role).toBe('architect');
});
it('deletes a task: gone from the list, file, and search index', () => {
const keep = createTask(cwd, { title: 'keep me', role: 'implementer' });
const ghost = createTask(cwd, { title: 'ghost purge me', role: 'implementer' });
expect(listTasks(cwd)).toHaveLength(2);
const res = deleteTask(cwd, ghost.id);
expect(res.id).toBe(ghost.id);
// Removed from the index list…
const remaining = listTasks(cwd);
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe(keep.id);
// …its markdown file is gone (getTask throws)…
expect(() => getTask(cwd, ghost.id)).toThrow();
// …and it no longer surfaces in the FTS search index.
const index = new Index(cwd);
const hits = index.search('purge').map((h) => h.id);
index.close();
expect(hits).not.toContain(ghost.id);
});
it('deleteTask is idempotent (deleting a missing task does not throw)', () => {
expect(() => deleteTask(cwd, 'TSK-9999')).not.toThrow();
});
});