feat(board): fix gaps A+B — full task status transitions + handoff who→whom

GAP A: extend PATCH /tasks/:id to support all five TaskStatus values
(open, in_progress, review, done, cancelled). Add reviewTask, cancelTask,
reopenTask helpers in taskService. Claim semantics preserved: in_progress
requires assignedTo. Board columns for review and cancelled now reachable
via the API.

GAP B: index fromRole/toRole/fromAgent/toAgent on handoff upsert. SQLite
migration guard adds columns to pre-existing DBs without data loss. Board
renderHandoffs shows "fromRole[@agent] → toRole[@agent]" via → arrow.
GET /handoffs now carries the routing fields in every index entry.

Tests: +14 (56 total, 21 files, all green). Covers every new status
transition, index field presence, board markup assertions, and a guard for
the claim-without-assignedTo 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-25 23:41:54 +02:00
parent d0e45230bc
commit b405b268a5
8 changed files with 214 additions and 12 deletions

View File

@ -15,6 +15,11 @@ export interface IndexEntry {
role?: string; role?: string;
assignedTo?: string; assignedTo?: string;
tags?: string; tags?: string;
// Handoff-specific routing fields
fromRole?: string;
toRole?: string;
fromAgent?: string;
toAgent?: string;
} }
export class Index { export class Index {
@ -36,10 +41,24 @@ export class Index {
status TEXT, status TEXT,
role TEXT, role TEXT,
assignedTo TEXT, assignedTo TEXT,
tags TEXT tags TEXT,
fromRole TEXT,
toRole TEXT,
fromAgent TEXT,
toAgent TEXT
); );
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content); CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content);
`); `);
// Migration: add handoff-routing columns to existing DBs that pre-date this schema.
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']) {
if (!existingCols.has(col)) {
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
}
}
} }
upsert(entry: IndexEntry): void { upsert(entry: IndexEntry): void {
@ -48,16 +67,21 @@ export class Index {
role: null, role: null,
assignedTo: null, assignedTo: null,
tags: null, tags: null,
fromRole: null,
toRole: null,
fromAgent: null,
toAgent: null,
...entry, ...entry,
}; };
const insert = this.db.prepare(` const insert = this.db.prepare(`
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags) INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent)
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags) VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
type=@type, title=@title, content=@content, filePath=@filePath, type=@type, title=@title, content=@content, filePath=@filePath,
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role, createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
assignedTo=@assignedTo, tags=@tags assignedTo=@assignedTo, tags=@tags,
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent
`); `);
insert.run(params); insert.run(params);

View File

@ -34,6 +34,10 @@ export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Hand
filePath, filePath,
createdAt: handoff.createdAt, createdAt: handoff.createdAt,
updatedAt: handoff.createdAt, updatedAt: handoff.createdAt,
fromRole: handoff.fromRole,
toRole: handoff.toRole,
fromAgent: handoff.fromAgent,
toAgent: handoff.toAgent,
}); });
index.close(); index.close();

View File

@ -63,6 +63,18 @@ export function doneTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'done' }); return updateTask(cwd, id, { status: 'done' });
} }
export function reviewTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'review' });
}
export function cancelTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'cancelled' });
}
export function reopenTask(cwd: string, id: string): Task {
return updateTask(cwd, id, { status: 'open' });
}
function toIndexEntry(task: Task, filePath: string) { function toIndexEntry(task: Task, filePath: string) {
return { return {
id: task.id, id: task.id,

View File

@ -153,6 +153,8 @@ export function renderBoardHtml(): string {
.row:first-of-type { border-top: 0; } .row:first-of-type { border-top: 0; }
.row .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; } .row .id { color: var(--muted); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; }
.row .what { flex: 1; } .row .what { flex: 1; }
.row .who-cell { font-size: 12px; white-space: nowrap; display: flex; align-items: baseline; gap: 4px; }
.row .who { display: flex; align-items: baseline; gap: 4px; color: var(--accent); font-size: 11px; white-space: nowrap; }
.row .when { color: var(--muted); font-size: 11px; white-space: nowrap; } .row .when { color: var(--muted); font-size: 11px; white-space: nowrap; }
</style> </style>
</head> </head>
@ -230,6 +232,14 @@ ${columnSkeleton()}
: '<div class="empty">—</div>'; : '<div class="empty">—</div>';
}); });
} }
function handoffRoute(h) {
// Build "fromRole[@fromAgent] → toRole[@toAgent]" label
var from = esc(h.fromRole || '?');
var to = esc(h.toRole || '?');
if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>';
if (h.toAgent) to += '<span class="badge agent">@' + esc(h.toAgent) + '</span>';
return '<span class="who">' + from + ' &rarr; ' + to + '</span>';
}
function renderHandoffs(items) { function renderHandoffs(items) {
var el = document.getElementById('handoffs'); var el = document.getElementById('handoffs');
if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; } if (!items || !items.length) { el.innerHTML = '<div class="empty">none</div>'; return; }
@ -237,6 +247,7 @@ ${columnSkeleton()}
return '<div class="row">' + return '<div class="row">' +
'<span class="id">' + esc(h.id) + '</span>' + '<span class="id">' + esc(h.id) + '</span>' +
'<span class="what">' + esc(h.title) + '</span>' + '<span class="what">' + esc(h.title) + '</span>' +
'<span class="who-cell">' + handoffRoute(h) + '</span>' +
'<span class="when">' + esc(ago(h.createdAt)) + '</span>' + '<span class="when">' + esc(ago(h.createdAt)) + '</span>' +
'</div>'; '</div>';
}).join(''); }).join('');

View File

@ -1,5 +1,5 @@
import { FastifyInstance, FastifyReply } from 'fastify'; import { FastifyInstance, FastifyReply } from 'fastify';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../core/services/taskService.js'; import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js';
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js'; import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
import { createDecision, listDecisions } from '../core/services/decisionService.js'; import { createDecision, listDecisions } from '../core/services/decisionService.js';
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js'; import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
@ -53,13 +53,22 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.patch('/tasks/:id', async (request, reply) => { app.patch('/tasks/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const patch = request.body as Partial<Task>; const patch = request.body as Partial<Task>;
if (patch.status === 'in_progress' && patch.assignedTo) {
return claimTask(cwd, id, patch.assignedTo); switch (patch.status) {
case 'in_progress':
if (!patch.assignedTo) return badRequest(reply, 'assignedTo is required to claim a task (in_progress)');
return claimTask(cwd, id, patch.assignedTo);
case 'done':
return doneTask(cwd, id);
case 'review':
return reviewTask(cwd, id);
case 'cancelled':
return cancelTask(cwd, id);
case 'open':
return reopenTask(cwd, id);
default:
return badRequest(reply, 'Unsupported patch: status must be one of open, in_progress, review, done, cancelled');
} }
if (patch.status === 'done') {
return doneTask(cwd, id);
}
return badRequest(reply, 'Unsupported patch');
}); });
app.get('/handoffs', async () => listHandoffs(cwd)); app.get('/handoffs', async () => listHandoffs(cwd));

View File

@ -16,4 +16,34 @@ describe('handoffService', () => {
expect(getHandoff(cwd, h.id).handoff.summary).toBe('s'); expect(getHandoff(cwd, h.id).handoff.summary).toBe('s');
expect(listHandoffs(cwd)).toHaveLength(1); expect(listHandoffs(cwd)).toHaveLength(1);
}); });
it('listHandoffs carries fromRole and toRole in the index entry', () => {
createHandoff(cwd, {
fromRole: 'architect',
toRole: 'implementer',
summary: 'Hand over design',
context: 'done',
});
const items = listHandoffs(cwd);
expect(items).toHaveLength(1);
expect(items[0].fromRole).toBe('architect');
expect(items[0].toRole).toBe('implementer');
// agent names default to undefined when not supplied
expect(items[0].fromAgent == null).toBe(true);
expect(items[0].toAgent == null).toBe(true);
});
it('listHandoffs carries fromAgent and toAgent when supplied', () => {
createHandoff(cwd, {
fromRole: 'reviewer',
toRole: 'implementer',
fromAgent: 'claude',
toAgent: 'codex',
summary: 'Review complete',
context: 'lgtm',
});
const items = listHandoffs(cwd);
expect(items[0].fromAgent).toBe('claude');
expect(items[0].toAgent).toBe('codex');
});
}); });

View File

@ -47,6 +47,48 @@ describe('server routes', () => {
expect(res.statusCode).toBe(400); expect(res.statusCode).toBe(400);
}); });
it('returns 400 when claiming without assignedTo', 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: 'in_progress' } });
expect(res.statusCode).toBe(400);
});
it('PATCH /tasks/:id → review', 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: 'review' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('review');
});
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' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('cancelled');
});
it('PATCH /tasks/:id → open (reopen)', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
// First cancel it, then reopen
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'cancelled' } });
const res = await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'open' } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).status).toBe('open');
});
it('review and cancelled tasks appear in GET /tasks list', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'B', role: 'implementer' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0001', payload: { status: 'review' } });
await app.inject({ method: 'PATCH', url: '/tasks/TSK-0002', payload: { status: 'cancelled' } });
const allRes = await app.inject({ method: 'GET', url: '/tasks' });
const all = JSON.parse(allRes.payload) as Array<{ status: string }>;
const statuses = all.map((t) => t.status);
expect(statuses).toContain('review');
expect(statuses).toContain('cancelled');
});
it('updates status via POST /status/update', async () => { it('updates status via POST /status/update', async () => {
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } }); await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'A', role: 'implementer' } });
const res = await app.inject({ method: 'POST', url: '/status/update' }); const res = await app.inject({ method: 'POST', url: '/status/update' });
@ -78,4 +120,37 @@ describe('server routes', () => {
expect(html).toContain("getJSON('/tasks')"); expect(html).toContain("getJSON('/tasks')");
expect(html).toContain('setInterval(refresh'); expect(html).toContain('setInterval(refresh');
}); });
it('board HTML contains the who-arrow rendering logic', async () => {
const res = await app.inject({ method: 'GET', url: '/board' });
const html = res.payload;
// The handoffRoute helper and the &rarr; arrow must be present
expect(html).toContain('handoffRoute');
expect(html).toContain('&rarr;');
// The who-cell class must be used in renderHandoffs
expect(html).toContain('who-cell');
});
it('GET /handoffs returns fromRole and toRole fields', async () => {
await app.inject({
method: 'POST',
url: '/handoffs',
payload: {
fromRole: 'architect',
toRole: 'implementer',
fromAgent: 'claude',
toAgent: 'codex',
summary: 'Design done',
context: 'See decisions',
},
});
const res = await app.inject({ method: 'GET', url: '/handoffs' });
expect(res.statusCode).toBe(200);
const items = JSON.parse(res.payload) as Array<Record<string, unknown>>;
expect(items).toHaveLength(1);
expect(items[0].fromRole).toBe('architect');
expect(items[0].toRole).toBe('implementer');
expect(items[0].fromAgent).toBe('claude');
expect(items[0].toAgent).toBe('codex');
});
}); });

View File

@ -2,7 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'fs'; import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os'; import { tmpdir } from 'os';
import { join } from 'path'; import { join } from 'path';
import { createTask, listTasks, getTask, claimTask, doneTask } from '../src/core/services/taskService.js'; import {
createTask, listTasks, getTask,
claimTask, doneTask, reviewTask, cancelTask, reopenTask,
} from '../src/core/services/taskService.js';
describe('taskService', () => { describe('taskService', () => {
let cwd: string; let cwd: string;
@ -35,4 +38,38 @@ describe('taskService', () => {
const done = doneTask(cwd, task.id); const done = doneTask(cwd, task.id);
expect(done.status).toBe('done'); expect(done.status).toBe('done');
}); });
it('transitions a task to review', () => {
const task = createTask(cwd, { title: 'C', role: 'implementer' });
const inReview = reviewTask(cwd, task.id);
expect(inReview.status).toBe('review');
// Verify index is updated
const listed = listTasks(cwd, { status: 'review' });
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(task.id);
});
it('cancels a task', () => {
const task = createTask(cwd, { title: 'D', role: 'implementer' });
const cancelled = cancelTask(cwd, task.id);
expect(cancelled.status).toBe('cancelled');
const listed = listTasks(cwd, { status: 'cancelled' });
expect(listed).toHaveLength(1);
});
it('reopens a task (any status → open)', () => {
const task = createTask(cwd, { title: 'E', role: 'implementer' });
cancelTask(cwd, task.id);
const reopened = reopenTask(cwd, task.id);
expect(reopened.status).toBe('open');
const listed = listTasks(cwd, { status: 'open' });
expect(listed).toHaveLength(1);
});
it('getTask reads back the correct task', () => {
const task = createTask(cwd, { title: 'F', role: 'architect' });
const { task: read } = getTask(cwd, task.id);
expect(read.title).toBe('F');
expect(read.role).toBe('architect');
});
}); });