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:
parent
d0e45230bc
commit
b405b268a5
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 + ' → ' + 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('');
|
||||||
|
|||||||
@ -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) {
|
|
||||||
|
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);
|
return claimTask(cwd, id, patch.assignedTo);
|
||||||
}
|
case 'done':
|
||||||
if (patch.status === 'done') {
|
|
||||||
return doneTask(cwd, id);
|
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');
|
||||||
}
|
}
|
||||||
return badRequest(reply, 'Unsupported patch');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/handoffs', async () => listHandoffs(cwd));
|
app.get('/handoffs', async () => listHandoffs(cwd));
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 → arrow must be present
|
||||||
|
expect(html).toContain('handoffRoute');
|
||||||
|
expect(html).toContain('→');
|
||||||
|
// 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user