226 lines
6.0 KiB
TypeScript
226 lines
6.0 KiB
TypeScript
|
|
import type { Context } from 'koa'
|
||
|
|
import { readFile } from 'fs/promises'
|
||
|
|
import { resolve, normalize } from 'path'
|
||
|
|
import { homedir } from 'os'
|
||
|
|
import * as kanbanCli from '../../services/hermes/hermes-kanban'
|
||
|
|
import { searchSessionSummariesWithProfile, getSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
|
||
|
|
|
||
|
|
function getLatestRunProfile(detail: { runs: Array<{ profile: string | null }> }): string | null {
|
||
|
|
return [...detail.runs].reverse().find(run => run.profile)?.profile || null
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function list(ctx: Context) {
|
||
|
|
const { status, assignee, tenant } = ctx.query as Record<string, string | undefined>
|
||
|
|
try {
|
||
|
|
const tasks = await kanbanCli.listTasks({ status, assignee, tenant })
|
||
|
|
ctx.body = { tasks }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function get(ctx: Context) {
|
||
|
|
try {
|
||
|
|
const detail = await kanbanCli.getTask(ctx.params.id)
|
||
|
|
if (!detail) {
|
||
|
|
ctx.status = 404
|
||
|
|
ctx.body = { error: 'Task not found' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// For completed tasks, find related session from the worker's profile DB
|
||
|
|
if (detail.task.status === 'done' && detail.runs.length > 0) {
|
||
|
|
const profile = getLatestRunProfile(detail)
|
||
|
|
if (profile) {
|
||
|
|
try {
|
||
|
|
const results = await searchSessionSummariesWithProfile(detail.task.id, profile, undefined, 5)
|
||
|
|
if (results.length > 0) {
|
||
|
|
const sessionId = results[0].id
|
||
|
|
const sessionDetail = await getSessionDetailFromDbWithProfile(sessionId, profile)
|
||
|
|
if (sessionDetail) {
|
||
|
|
;(detail as any).session = {
|
||
|
|
id: sessionId,
|
||
|
|
title: sessionDetail.title,
|
||
|
|
source: sessionDetail.source,
|
||
|
|
model: sessionDetail.model,
|
||
|
|
started_at: sessionDetail.started_at,
|
||
|
|
ended_at: sessionDetail.ended_at,
|
||
|
|
messages: sessionDetail.messages,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// Session lookup is best-effort, don't fail the whole request
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.body = detail
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function create(ctx: Context) {
|
||
|
|
const { title, body, assignee, priority, tenant } = ctx.request.body as {
|
||
|
|
title?: string
|
||
|
|
body?: string
|
||
|
|
assignee?: string
|
||
|
|
priority?: number
|
||
|
|
tenant?: string
|
||
|
|
}
|
||
|
|
if (!title) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'title is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const task = await kanbanCli.createTask(title, { body, assignee, priority, tenant })
|
||
|
|
ctx.body = { task }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function complete(ctx: Context) {
|
||
|
|
const { task_ids, summary } = ctx.request.body as {
|
||
|
|
task_ids?: string[]
|
||
|
|
summary?: string
|
||
|
|
}
|
||
|
|
if (!task_ids?.length) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'task_ids is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await kanbanCli.completeTasks(task_ids, summary)
|
||
|
|
ctx.body = { ok: true }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function block(ctx: Context) {
|
||
|
|
const { reason } = ctx.request.body as { reason?: string }
|
||
|
|
if (!reason) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'reason is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await kanbanCli.blockTask(ctx.params.id, reason)
|
||
|
|
ctx.body = { ok: true }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function unblock(ctx: Context) {
|
||
|
|
const { task_ids } = ctx.request.body as { task_ids?: string[] }
|
||
|
|
if (!task_ids?.length) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'task_ids is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await kanbanCli.unblockTasks(task_ids)
|
||
|
|
ctx.body = { ok: true }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function assign(ctx: Context) {
|
||
|
|
const { profile } = ctx.request.body as { profile?: string }
|
||
|
|
if (!profile) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'profile is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await kanbanCli.assignTask(ctx.params.id, profile)
|
||
|
|
ctx.body = { ok: true }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function stats(ctx: Context) {
|
||
|
|
try {
|
||
|
|
const stats = await kanbanCli.getStats()
|
||
|
|
ctx.body = { stats }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function assignees(ctx: Context) {
|
||
|
|
try {
|
||
|
|
const assignees = await kanbanCli.getAssignees()
|
||
|
|
ctx.body = { assignees }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function readArtifact(ctx: Context) {
|
||
|
|
const filePath = ctx.query.path as string | undefined
|
||
|
|
if (!filePath) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'path is required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
|
||
|
|
const resolved = resolve(normalize(filePath))
|
||
|
|
|
||
|
|
if (!resolved.startsWith(kanbanDir)) {
|
||
|
|
ctx.status = 403
|
||
|
|
ctx.body = { error: 'Path must be within kanban workspaces' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const data = await readFile(resolved, 'utf-8')
|
||
|
|
ctx.body = { content: data, path: filePath }
|
||
|
|
} catch (err: any) {
|
||
|
|
if (err.code === 'ENOENT') {
|
||
|
|
ctx.status = 404
|
||
|
|
ctx.body = { error: 'File not found' }
|
||
|
|
} else {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function searchSessions(ctx: Context) {
|
||
|
|
const { task_id, profile, q } = ctx.query as {
|
||
|
|
task_id?: string
|
||
|
|
profile?: string
|
||
|
|
q?: string
|
||
|
|
}
|
||
|
|
if (!task_id || !profile) {
|
||
|
|
ctx.status = 400
|
||
|
|
ctx.body = { error: 'task_id and profile are required' }
|
||
|
|
return
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const searchQuery = q || task_id
|
||
|
|
const results = await searchSessionSummariesWithProfile(searchQuery, profile, undefined, 10)
|
||
|
|
ctx.body = { results }
|
||
|
|
} catch (err: any) {
|
||
|
|
ctx.status = 500
|
||
|
|
ctx.body = { error: err.message }
|
||
|
|
}
|
||
|
|
}
|