Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/routes/api/swarm-kanban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 }) => {
Expand Down
4 changes: 1 addition & 3 deletions src/screens/swarm2/swarm2-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
101 changes: 101 additions & 0 deletions src/server/kanban-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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)
})
})
Loading