diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index df2cad0..3f58d33 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -2,6 +2,26 @@ import { NextRequest, NextResponse } from "next/server" import { requireRole } from "@/lib/auth" import { getDatabase } from "@/lib/db" +function ensureGatewaysTable(db: ReturnType) { + db.exec(` + CREATE TABLE IF NOT EXISTS gateways ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + host TEXT NOT NULL DEFAULT '127.0.0.1', + port INTEGER NOT NULL DEFAULT 18789, + token TEXT NOT NULL DEFAULT '', + is_primary INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'unknown', + last_seen INTEGER, + latency INTEGER, + sessions_count INTEGER NOT NULL DEFAULT 0, + agents_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ) + `) +} + interface GatewayEntry { id: number name: string @@ -144,6 +164,7 @@ export async function POST(request: NextRequest) { if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() + ensureGatewaysTable(db) const gateways = db.prepare("SELECT * FROM gateways ORDER BY is_primary DESC, name ASC").all() as GatewayEntry[] // Build set of user-configured gateway hosts so the SSRF filter allows them diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index ed601ae..12b983d 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -95,6 +95,29 @@ const STATUS_COLUMN_KEYS = [ { key: 'done', titleKey: 'colDone', color: 'bg-green-500/20 text-green-400' }, ] +/** Fetch active gateway sessions for a given agent name. */ +function useAgentSessions(agentName: string | undefined) { + const [sessions, setSessions] = useState>([]) + useEffect(() => { + if (!agentName) { setSessions([]); return } + let cancelled = false + fetch('/api/sessions?include_local=1') + .then(r => r.json()) + .then(data => { + if (cancelled) return + const all = (data.sessions || []) as Array<{ key: string; id: string; agent?: string; channel?: string; label?: string; active?: boolean }> + const filtered = all.filter(s => + s.agent?.toLowerCase() === agentName.toLowerCase() || + s.key?.toLowerCase().includes(agentName.toLowerCase()) + ) + setSessions(filtered.map(s => ({ key: s.key, id: s.id, channel: s.channel, label: s.label }))) + }) + .catch(() => { if (!cancelled) setSessions([]) }) + return () => { cancelled = true } + }, [agentName]) + return sessions +} + const priorityColors: Record = { low: 'border-l-green-500', medium: 'border-l-yellow-500', @@ -1818,8 +1841,10 @@ function CreateTaskModal({ project_id: projects[0]?.id ? String(projects[0].id) : '', assigned_to: '', tags: '', + target_session: '', }) const t = useTranslations('taskBoard') + const agentSessions = useAgentSessions(formData.assigned_to || undefined) const [isRecurring, setIsRecurring] = useState(false) const [scheduleInput, setScheduleInput] = useState('') const [parsedSchedule, setParsedSchedule] = useState<{ cronExpr: string; humanReadable: string } | null>(null) @@ -1861,6 +1886,9 @@ function CreateTaskModal({ parent_task_id: null, } } + if (formData.target_session) { + metadata.target_session = formData.target_session + } try { const response = await fetch('/api/tasks', { @@ -1960,7 +1988,7 @@ function CreateTaskModal({ + {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
{ e.preventDefault() @@ -2070,6 +2120,14 @@ function EditTaskModal({ if (!formData.title.trim()) return try { + const existingMeta = task.metadata || {} + const updatedMeta = { ...existingMeta } + if (formData.target_session) { + updatedMeta.target_session = formData.target_session + } else { + delete updatedMeta.target_session + } + const response = await fetch(`/api/tasks/${task.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -2077,7 +2135,8 @@ function EditTaskModal({ ...formData, project_id: formData.project_id ? Number(formData.project_id) : undefined, tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [], - assigned_to: formData.assigned_to || undefined + assigned_to: formData.assigned_to || undefined, + metadata: updatedMeta, }) }) @@ -2182,7 +2241,7 @@ function EditTaskModal({
+ {formData.assigned_to && agentSessions.length > 0 && ( +
+ + +

Send task to an existing agent session instead of creating a new one.

+
+ )} +
= { - message: prompt, - agentId: gatewayAgentId, - idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, - deliver: false, - } - // Route to appropriate model tier based on task complexity. - // null = no override, agent uses its own configured default model. - if (dispatchModel) invokeParams.model = dispatchModel + // Check if task has a target session specified in metadata + const taskMeta = (() => { + try { + const row = db.prepare('SELECT metadata FROM tasks WHERE id = ?').get(task.id) as { metadata: string } | undefined + return row?.metadata ? JSON.parse(row.metadata) : {} + } catch { return {} } + })() + const targetSession: string | null = typeof taskMeta?.target_session === 'string' && taskMeta.target_session + ? taskMeta.target_session + : null - // Use --expect-final to block until the agent completes and returns the full - // response payload (result.payloads[0].text). The two-step agent → agent.wait - // pattern only returns lifecycle metadata and never includes the agent's text. - const finalResult = await runOpenClaw( - ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], - { timeoutMs: 125_000 } - ) - const finalPayload = parseGatewayJson(finalResult.stdout) - ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) + let agentResponse: AgentResponseParsed - const agentResponse = parseAgentResponse( - finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout - ) - if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { - agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId - } + if (targetSession) { + // Dispatch to a specific existing session via chat.send + logger.info({ taskId: task.id, targetSession, agent: task.agent_name }, 'Dispatching task to targeted session') + const sendResult = await callOpenClawGateway( + 'chat.send', + { + sessionKey: targetSession, + message: prompt, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + }, + 125_000, + ) + const status = String(sendResult?.status || '').toLowerCase() + if (status !== 'started' && status !== 'ok' && status !== 'in_flight') { + throw new Error(`chat.send to session ${targetSession} returned status: ${status}`) + } + // chat.send is fire-and-forget; we record the session but won't get inline response text + agentResponse = { + text: `Task dispatched to existing session ${targetSession}. The agent will process it within that session context.`, + sessionId: sendResult?.runId || targetSession, + } + } else { + // Step 1: Invoke via gateway (new session) + const gatewayAgentId = resolveGatewayAgentId(task) + const dispatchModel = classifyTaskModel(task) + const invokeParams: Record = { + message: prompt, + agentId: gatewayAgentId, + idempotencyKey: `task-dispatch-${task.id}-${Date.now()}`, + deliver: false, + } + // Route to appropriate model tier based on task complexity. + // null = no override, agent uses its own configured default model. + if (dispatchModel) invokeParams.model = dispatchModel + + // Use --expect-final to block until the agent completes and returns the full + // response payload (result.payloads[0].text). The two-step agent → agent.wait + // pattern only returns lifecycle metadata and never includes the agent's text. + const finalResult = await runOpenClaw( + ['gateway', 'call', 'agent', '--expect-final', '--timeout', '120000', '--params', JSON.stringify(invokeParams), '--json'], + { timeoutMs: 125_000 } + ) + const finalPayload = parseGatewayJson(finalResult.stdout) + ?? parseGatewayJson(String((finalResult as any)?.stderr || '')) + + agentResponse = parseAgentResponse( + finalPayload?.result ? JSON.stringify(finalPayload.result) : finalResult.stdout + ) + if (!agentResponse.sessionId && finalPayload?.result?.meta?.agentMeta?.sessionId) { + agentResponse.sessionId = finalPayload.result.meta.agentMeta.sessionId + } + } // end else (new session dispatch) if (!agentResponse.text) { throw new Error('Agent returned empty response') diff --git a/src/store/index.ts b/src/store/index.ts index 658343f..c2ea23e 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,6 +12,8 @@ type DashboardLayoutUpdater = string[] | null | ((current: string[] | null) => s export interface Session { id: string key: string + agent?: string + channel?: string kind: string age: string model: string