diff --git a/src/routes/api/swarm-kanban.ts b/src/routes/api/swarm-kanban.ts index 15261d2d46..8d487eefae 100644 --- a/src/routes/api/swarm-kanban.ts +++ b/src/routes/api/swarm-kanban.ts @@ -3,16 +3,32 @@ import { json } from '@tanstack/react-start' import { z } from 'zod' import { createKanbanCard, getKanbanBackendMeta, listKanbanCards, updateKanbanCard } from '../../server/kanban-backend' +const AcceptanceCriteriaSchema = z.preprocess( + (value) => { + if (Array.isArray(value)) return value + if (typeof value === 'string') { + return value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + } + return [] + }, + z.array(z.string().trim().min(1).max(5000)).default([]), +) + const CreateCardSchema = z.object({ title: z.string().trim().min(1).max(200), spec: z.string().trim().max(5000).optional().default(''), - acceptanceCriteria: z.string().trim().max(5000).optional().default(''), + acceptanceCriteria: AcceptanceCriteriaSchema, assignedWorker: z.string().trim().max(120).optional().nullable(), reviewer: z.string().trim().max(120).optional().nullable(), - status: z.enum(['backlog', 'ready', 'running', 'review', 'blocked', 'done']).optional().default('backlog'), + status: z.enum(['backlog', 'todo', 'ready', 'running', 'review', 'blocked', 'done']).optional().default('backlog'), missionId: z.string().trim().max(200).optional().nullable(), reportPath: z.string().trim().max(500).optional().nullable(), createdBy: z.string().trim().max(120).optional().default('aurora'), + parents: z.array(z.string().trim().min(1).max(200)).optional().default([]), + idempotencyKey: z.string().trim().max(500).optional().nullable(), }) const UpdateCardSchema = CreateCardSchema.partial().extend({ @@ -40,7 +56,20 @@ export const Route = createFileRoute('/api/swarm-kanban')({ if (!parsed.success) { return json({ ok: false, error: parsed.error.issues.map((issue) => issue.message).join('; ') }, { status: 400 }) } - const card = await createKanbanCard(parsed.data) + const data = parsed.data + const card = await createKanbanCard({ + title: data.title ?? '', + spec: data.spec, + acceptanceCriteria: data.acceptanceCriteria, + assignedWorker: data.assignedWorker, + reviewer: data.reviewer, + status: data.status, + missionId: data.missionId, + reportPath: data.reportPath, + createdBy: data.createdBy, + parents: data.parents, + idempotencyKey: data.idempotencyKey, + }) return json({ ok: true, card, backend: getKanbanBackendMeta() }) }, PATCH: async ({ request }) => { diff --git a/src/screens/swarm2/swarm2-screen.test.ts b/src/screens/swarm2/swarm2-screen.test.ts index 9ab2ff642b..2c50e6bc6f 100644 --- a/src/screens/swarm2/swarm2-screen.test.ts +++ b/src/screens/swarm2/swarm2-screen.test.ts @@ -10,9 +10,7 @@ import { describe('Swarm2 surface contract', () => { it('keeps Aurora as the primary hub above wired operational worker cards', () => { expect(SWARM2_INFORMATION_HIERARCHY[0]).toContain('Status header') - expect(SWARM2_INFORMATION_HIERARCHY[1]).toContain( - 'Aurora/orchestrator hub card', - ) + expect(SWARM2_INFORMATION_HIERARCHY[1]).toContain('Orchestrator hub card') expect(SWARM2_INFORMATION_HIERARCHY[2]).toContain('Visible routing wires') expect(SWARM2_INFORMATION_HIERARCHY[3]).toContain( 'Operations-style worker node cards', diff --git a/src/server/kanban-backend.test.ts b/src/server/kanban-backend.test.ts index efe3994545..216d37ffc6 100644 --- a/src/server/kanban-backend.test.ts +++ b/src/server/kanban-backend.test.ts @@ -69,6 +69,7 @@ async function loadKanbanBackend(options?: { describe('kanban-backend', () => { it('auto-detect prefers Hermes backend when Hermes CLI and canonical storage are present', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') const sqliteCalls: Array<{ command: string; args?: string[] }> = [] const mod = await loadKanbanBackend({ @@ -114,6 +115,7 @@ describe('kanban-backend', () => { }) it('auto-detect uses Hermes storage directly when the CLI is unavailable', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') const mod = await loadKanbanBackend({ existsSync: (target) => target === '/Users/aurora/.claude/kanban.db', @@ -147,6 +149,7 @@ describe('kanban-backend', () => { }) it('resolves canonical Kanban paths from legacy profile-home env fallback too', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm5/home') vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm5/home') const mod = await loadKanbanBackend({ existsSync: (target) => target === '/Users/aurora/.claude/kanban.db', @@ -165,6 +168,7 @@ describe('kanban-backend', () => { }) it('auto-detect falls back to local when canonical Hermes storage is missing', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') const mod = await loadKanbanBackend({ existsSync: () => false, @@ -185,6 +189,7 @@ describe('kanban-backend', () => { }) it('creates and updates Hermes tasks through canonical kanban.db path', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') const sqliteCalls: string[] = [] let readCount = 0 @@ -225,4 +230,100 @@ describe('kanban-backend', () => { expect(sqliteCalls.some((call) => call.includes('insert into tasks'))).toBe(true) expect(sqliteCalls.some((call) => call.includes('update tasks set'))).toBe(true) }) + + it('projects native Kanban tasks without collapsing statuses or dependency/run metadata', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') + vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') + vi.stubEnv('CLAUDE_KANBAN_BACKEND', 'claude') + const mod = await loadKanbanBackend({ + existsSync: (target) => target === '/Users/aurora/.claude/kanban.db', + execFileSync: (command, args = []) => { + if (command === 'which' && args[0] === 'claude') throw new Error('not found') + if (command === 'sqlite3') { + return JSON.stringify([ + { + id: 't_child', + title: 'Native child task', + body: 'Use native board state', + status: 'todo', + assignee: 'temujin', + created_at: 1777527540, + updated_at: 1777527644, + parents_json: '["t_parent"]', + children_json: '["t_leaf"]', + latest_run_summary: 'patched adapter', + }, + ]) + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`) + }, + }) + + const [card] = await mod.listKanbanCards() + + expect(card).toMatchObject({ + id: 't_child', + status: 'todo', + parents: ['t_parent'], + children: ['t_leaf'], + latestRun: { summary: 'patched adapter' }, + source: 'native-kanban', + }) + }) + + it('creates native Kanban tasks with parent links and idempotency without using Workspace board files', async () => { + vi.stubEnv('HERMES_HOME', '/Users/aurora/.claude/profiles/swarm2') + vi.stubEnv('CLAUDE_HOME', '/Users/aurora/.claude/profiles/swarm2') + vi.stubEnv('CLAUDE_KANBAN_BACKEND', 'claude') + const sqliteCalls: string[] = [] + const mod = await loadKanbanBackend({ + existsSync: (target) => target === '/Users/aurora/.claude/kanban.db', + execFileSync: (command, args = []) => { + if (command === 'which' && args[0] === 'claude') throw new Error('not found') + if (command === 'sqlite3') { + sqliteCalls.push(args.join(' ')) + const sql = args[2] ?? '' + if (sql.includes('where idempotency_key =')) return '[]' + if (sql.includes('where id in')) return JSON.stringify([{ id: 't_parent', status: 'done' }]) + if (sql.includes('where id =')) { + return JSON.stringify([ + { + id: 't_created', + title: 'Native dispatch', + body: 'Dispatch body', + status: 'ready', + assignee: 'temujin', + created_at: 1777527540, + updated_at: 1777527540, + parents_json: '["t_parent"]', + children_json: '[]', + }, + ]) + } + return '[]' + } + throw new Error(`Unexpected command: ${command} ${args.join(' ')}`) + }, + }) + + const created = await mod.createKanbanCard({ + title: 'Native dispatch', + spec: 'Dispatch body', + assignedWorker: 'temujin', + status: 'ready', + parents: ['t_parent'], + idempotencyKey: 'native-dispatch-1', + } as any) + + expect(created).toMatchObject({ + id: 't_created', + status: 'ready', + parents: ['t_parent'], + source: 'native-kanban', + }) + expect(sqliteCalls.some((call) => call.includes('begin immediate') && call.includes('commit'))).toBe(true) + expect(sqliteCalls.some((call) => call.includes('task_links') && call.includes('t_parent'))).toBe(true) + expect(sqliteCalls.some((call) => call.includes('idempotency_key') && call.includes('native-dispatch-1'))).toBe(true) + expect(sqliteCalls.every((call) => !call.includes('swarm2-kanban.json'))).toBe(true) + }) }) diff --git a/src/server/kanban-backend.ts b/src/server/kanban-backend.ts index a23d292faa..c150212644 100644 --- a/src/server/kanban-backend.ts +++ b/src/server/kanban-backend.ts @@ -50,7 +50,9 @@ function mapDashboardStatusToLane( ): SwarmKanbanCard['status'] { switch ((status ?? '').toLowerCase()) { case 'triage': + return 'backlog' case 'todo': + return 'todo' case 'queued': return 'backlog' case 'ready': @@ -127,6 +129,11 @@ type ClaudeTaskRow = { assignee?: string | null created_at?: number | string | null updated_at?: number | string | null + parents_json?: string | null + children_json?: string | null + latest_run_summary?: string | null + latest_run_outcome?: string | null + latest_run_status?: string | null } type ClaudeDetection = { @@ -211,20 +218,31 @@ function runSqlite(dbPath: string, sql: string): string { }).trim() } +function claudeTaskProjection(): string { + return [ + 'tasks.id,', + 'tasks.title,', + 'tasks.body,', + 'tasks.status,', + 'tasks.assignee,', + 'tasks.created_at,', + 'coalesce(tasks.last_heartbeat_at, tasks.completed_at, tasks.started_at, tasks.created_at) as updated_at,', + "coalesce((select json_group_array(parent_id) from task_links where child_id = tasks.id), '[]') as parents_json,", + "coalesce((select json_group_array(child_id) from task_links where parent_id = tasks.id), '[]') as children_json,", + '(select summary from task_runs where task_id = tasks.id order by started_at desc, id desc limit 1) as latest_run_summary,', + '(select outcome from task_runs where task_id = tasks.id order by started_at desc, id desc limit 1) as latest_run_outcome,', + '(select status from task_runs where task_id = tasks.id order by started_at desc, id desc limit 1) as latest_run_status', + ].join(' ') +} + function readClaudeTasks(): ClaudeTaskRow[] { const detection = detectClaudeKanban() if (!detection.available) return [] const query = [ 'select', - 'id,', - 'title,', - 'body,', - 'status,', - 'assignee,', - 'created_at,', - 'coalesce(last_heartbeat_at, completed_at, started_at, created_at) as updated_at', + claudeTaskProjection(), 'from tasks', - 'order by created_at desc, id desc;', + 'order by tasks.created_at desc, tasks.id desc;', ].join(' ') const raw = runSqlite(detection.dbPath, query) const parsed = raw ? (JSON.parse(raw) as ClaudeTaskRow[]) : [] @@ -236,7 +254,7 @@ function readClaudeTask(taskId: string): ClaudeTaskRow | null { if (!detection.available) return null const raw = runSqlite( detection.dbPath, - `select id, title, body, status, assignee, created_at, coalesce(last_heartbeat_at, completed_at, started_at, created_at) as updated_at from tasks where id = ${sqliteQuote(taskId)} limit 1;`, + `select ${claudeTaskProjection()} from tasks where id = ${sqliteQuote(taskId)} limit 1;`, ) const parsed = raw ? (JSON.parse(raw) as ClaudeTaskRow[]) : [] return Array.isArray(parsed) && parsed[0] ? parsed[0] : null @@ -255,12 +273,26 @@ function normalizeTimestamp(value: unknown): number { return Date.now() } +function parseJsonStringArray(value: string | null | undefined): string[] { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map((item) => item.trim()) + : [] + } catch { + return [] + } +} + function mapClaudeStatus(status: string | null | undefined): SwarmKanbanCard['status'] { - switch ((status ?? '').toLowerCase()) { + const normalized = (status ?? '').toLowerCase() + switch (normalized) { case 'queued': - case 'todo': case 'triage': return 'backlog' + case 'todo': + return 'todo' case 'ready': return 'ready' case 'running': @@ -284,6 +316,8 @@ function mapBoardStatus(status: SwarmKanbanCard['status'] | null | undefined): s switch (status) { case 'backlog': return 'queued' + case 'todo': + return 'todo' case 'ready': return 'ready' case 'running': @@ -299,9 +333,45 @@ function mapBoardStatus(status: SwarmKanbanCard['status'] | null | undefined): s } } +function validateNativeParents(dbPath: string, parentIds: string[]): Map { + const uniqueParentIds = [...new Set(parentIds.map((parentId) => parentId.trim()).filter(Boolean))] + if (uniqueParentIds.length === 0) return new Map() + const raw = runSqlite( + dbPath, + `select id, status from tasks where id in (${uniqueParentIds.map(sqliteQuote).join(', ')});`, + ) + const parsed = raw ? (JSON.parse(raw) as Array<{ id?: string; status?: string | null }>) : [] + const statuses = new Map() + for (const row of parsed) { + if (typeof row.id === 'string') statuses.set(row.id, row.status ?? '') + } + const missing = uniqueParentIds.filter((parentId) => !statuses.has(parentId)) + if (missing.length > 0) throw new Error(`Cannot create Hermes task with missing parent(s): ${missing.join(', ')}`) + return statuses +} + +function deriveNativeCreateStatus( + requestedStatus: SwarmKanbanCard['status'] | null | undefined, + parentStatuses: Map, +): string { + const requested = mapBoardStatus(requestedStatus ?? 'backlog') + if (parentStatuses.size === 0) return requested === 'queued' ? 'todo' : requested + const allParentsDone = [...parentStatuses.values()].every((status) => ['done', 'complete', 'completed'].includes(status.toLowerCase())) + if (!allParentsDone && ['queued', 'todo', 'ready'].includes(requested)) return 'todo' + if (allParentsDone && requested === 'queued') return 'ready' + return requested +} + function claudeTaskToCard(task: ClaudeTaskRow): SwarmKanbanCard { const createdAt = normalizeTimestamp(task.created_at) const updatedAt = normalizeTimestamp(task.updated_at ?? task.created_at) + const latestRun = task.latest_run_summary || task.latest_run_outcome || task.latest_run_status + ? { + summary: task.latest_run_summary ?? undefined, + outcome: task.latest_run_outcome ?? undefined, + status: task.latest_run_status ?? undefined, + } + : undefined return { id: task.id, title: task.title, @@ -315,6 +385,10 @@ function claudeTaskToCard(task: ClaudeTaskRow): SwarmKanbanCard { createdBy: 'claude-kanban', createdAt, updatedAt, + parents: parseJsonStringArray(task.parents_json), + children: parseJsonStringArray(task.children_json), + latestRun, + source: 'native-kanban', } } @@ -361,11 +435,30 @@ const claudeBackend: KanbanBackend = { const detection = detectClaudeKanban() if (!detection.available) throw new Error(detection.reason ?? 'Hermes Kanban not detected') const nowSeconds = Math.floor(Date.now() / 1000) + const parentIds = Array.isArray(input.parents) + ? input.parents.filter((parentId): parentId is string => typeof parentId === 'string' && parentId.trim().length > 0) + : [] + const idempotencyKey = typeof input.idempotencyKey === 'string' && input.idempotencyKey.trim().length > 0 + ? input.idempotencyKey.trim() + : null + if (idempotencyKey) { + const existing = runSqlite( + detection.dbPath, + `select ${claudeTaskProjection()} from tasks where idempotency_key = ${sqliteQuote(idempotencyKey)} and status != 'archived' order by created_at desc, id desc limit 1;`, + ) + const parsed = existing ? (JSON.parse(existing) as ClaudeTaskRow[]) : [] + if (Array.isArray(parsed) && parsed[0]) return claudeTaskToCard(parsed[0]) + } + const parentStatuses = validateNativeParents(detection.dbPath, parentIds) const taskId = `t_${randomUUID().replace(/-/g, '').slice(0, 8)}` - const status = mapBoardStatus(input.status ?? 'backlog') + const status = deriveNativeCreateStatus(input.status ?? 'backlog', parentStatuses) + const linkStatements = parentIds.map( + (parentId) => `insert or ignore into task_links (parent_id, child_id) values (${sqliteQuote(parentId.trim())}, ${sqliteQuote(taskId)});`, + ) const statements = [ + 'begin immediate;', 'insert into tasks (', - 'id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path', + 'id, title, body, assignee, status, priority, created_by, created_at, workspace_kind, workspace_path, idempotency_key', ') values (', [ sqliteQuote(taskId), @@ -378,8 +471,11 @@ const claudeBackend: KanbanBackend = { String(nowSeconds), sqliteQuote('scratch'), sqliteQuote(path.join(detection.workspacePath, 'workspaces', taskId)), + idempotencyKey ? sqliteQuote(idempotencyKey) : 'NULL', ].join(', '), ');', + ...linkStatements, + 'commit;', ].join(' ') runSqlite(detection.dbPath, statements) const created = readClaudeTask(taskId) diff --git a/src/server/swarm-kanban-store.ts b/src/server/swarm-kanban-store.ts index a07c1d4da4..eeb13b095a 100644 --- a/src/server/swarm-kanban-store.ts +++ b/src/server/swarm-kanban-store.ts @@ -3,7 +3,7 @@ import * as os from 'node:os' import * as path from 'node:path' import { randomUUID } from 'node:crypto' -export const SWARM_KANBAN_LANES = ['backlog', 'ready', 'running', 'review', 'blocked', 'done'] as const +export const SWARM_KANBAN_LANES = ['backlog', 'todo', 'ready', 'running', 'review', 'blocked', 'done'] as const export type SwarmKanbanLane = (typeof SWARM_KANBAN_LANES)[number] export type SwarmKanbanCard = { @@ -13,12 +13,16 @@ export type SwarmKanbanCard = { acceptanceCriteria: string[] assignedWorker: string | null reviewer: string | null - status: SwarmKanbanLane + status: SwarmKanbanLane | string missionId: string | null reportPath: string | null createdBy: string createdAt: number updatedAt: number + parents?: string[] + children?: string[] + latestRun?: { summary?: string | null; outcome?: string | null; status?: string | null } | null + source?: string } type SwarmKanbanFile = { cards: SwarmKanbanCard[] } @@ -40,6 +44,8 @@ export type CreateSwarmKanbanCardInput = { missionId?: string | null reportPath?: string | null createdBy?: string | null + parents?: string[] + idempotencyKey?: string | null } export type UpdateSwarmKanbanCardInput = Partial> @@ -72,7 +78,6 @@ function writeKanbanFile(data: SwarmKanbanFile): void { } function normalizeStatus(value: unknown): SwarmKanbanLane { - if (value === 'todo') return 'ready' if (value === 'in_progress' || value === 'doing') return 'running' return SWARM_KANBAN_LANES.includes(value as SwarmKanbanLane) ? (value as SwarmKanbanLane) : 'backlog' }