agenthub/src/core/index.ts
chahinebrini b405b268a5 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>
2026-06-25 23:41:54 +02:00

136 lines
3.8 KiB
TypeScript

import Database from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
import { getIndexPath } from './paths.js';
export interface IndexEntry {
id: string;
type: string;
title: string;
content: string;
filePath: string;
createdAt: string;
updatedAt: string;
status?: string;
role?: string;
assignedTo?: string;
tags?: string;
// Handoff-specific routing fields
fromRole?: string;
toRole?: string;
fromAgent?: string;
toAgent?: string;
}
export class Index {
private db: Database.Database;
constructor(cwd: string) {
const path = getIndexPath(cwd);
mkdirSync(dirname(path), { recursive: true });
this.db = new Database(path);
this.db.exec(`
CREATE TABLE IF NOT EXISTS entities (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
filePath TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
status TEXT,
role TEXT,
assignedTo TEXT,
tags TEXT,
fromRole TEXT,
toRole TEXT,
fromAgent TEXT,
toAgent TEXT
);
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 {
const params = {
status: null,
role: null,
assignedTo: null,
tags: null,
fromRole: null,
toRole: null,
fromAgent: null,
toAgent: null,
...entry,
};
const insert = this.db.prepare(`
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, @fromRole, @toRole, @fromAgent, @toAgent)
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,
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent
`);
insert.run(params);
const search = this.db.prepare(`
INSERT OR REPLACE INTO search (id, title, content) VALUES (@id, @title, @content)
`);
search.run(params);
}
search(query: string): Array<{ id: string; type: string; title: string }> {
const stmt = this.db.prepare(`
SELECT e.id, e.type, e.title
FROM search s
JOIN entities e ON s.id = e.id
WHERE search MATCH @query
ORDER BY rank
`);
return stmt.all({ query }) as Array<{ id: string; type: string; title: string }>;
}
list(type?: string, filters?: { status?: string; role?: string; assignedTo?: string }): IndexEntry[] {
let sql = 'SELECT * FROM entities WHERE 1=1';
const params: Record<string, string> = {};
if (type) {
sql += ' AND type = @type';
params.type = type;
}
if (filters?.status) {
sql += ' AND status = @status';
params.status = filters.status;
}
if (filters?.role) {
sql += ' AND role = @role';
params.role = filters.role;
}
if (filters?.assignedTo) {
sql += ' AND assignedTo = @assignedTo';
params.assignedTo = filters.assignedTo;
}
sql += ' ORDER BY createdAt DESC';
const stmt = this.db.prepare(sql);
return stmt.all(params) as IndexEntry[];
}
close(): void {
this.db.close();
}
}