Adds the visible front-end and the worker back-end for the agent
task system. The PocketBase schema, API routes, and ToastProvider
were already in the previous commit; this commit completes the
picture.
UI
- app/tasks/{page,KanbanBoard,TaskCard,CardModal,TaskDetail,api,columns}
Full Kanban with six columns (Backlog, Ready, In Progress,
Blocked, Done, Failed), drag-and-drop between columns, quick-add
chips, type/priority filters, task detail panel with retry/cancel/
delete, 5s polling, transition toasts.
- components/NavBar.tsx: 'Tasks' link in primary nav.
- app/bizportal-2/BizPortal2Explorer.tsx: 'Send to Kanban' button
on the result view — dispatches the current search to the agent
queue as a 'ready' task.
Back-end
- scripts/task_worker.py: PocketBase polling worker with two
lanes. Deterministic handles bizportal_search/reserve, pdf_generate,
email_send. Custom lane writes to /tmp/agent_inbox/ for the
LLM agent to pick up. Atomic claim via PB filter, auto-retry up
to 3x, structured log on each task.
- scripts/bizportal_service.py: bugfix — _is_logged_in was using
a non-CSS selector so session-recovery was a no-op. Also made
the login flow re-runnable mid-session (handles BizPortal's
~20-minute session timeout transparently).
- scripts/init_pb_schema.py: duration_ms no longer capped at 0.
Verified end-to-end:
- CK search via worker: 1 company, 3 directors, ~10s
- ID search via worker: 4 companies, 7 total directors, ~34s
- Auto-retry + status transitions + toast fires
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
// app/api/tasks/route.ts
|
|
// GET /api/tasks — list with filters
|
|
// POST /api/tasks — create a task
|
|
//
|
|
// Server-side proxy to PocketBase. The browser calls these routes
|
|
// over the existing /api/* contract; PB_TOKEN never leaves the
|
|
// server.
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { fetchCollection, createRecord, PBRecord } from "@/lib/pb";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const COLLECTION = "tasks";
|
|
|
|
const VALID_STATUS = ["backlog", "ready", "in_progress", "blocked", "done", "failed"] as const;
|
|
const VALID_PRIORITY = ["low", "normal", "high", "urgent"] as const;
|
|
const VALID_TASK_TYPE = ["bizportal_search", "bizportal_reserve", "pdf_generate", "email_send", "custom"] as const;
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const url = new URL(req.url);
|
|
const status = url.searchParams.get("status");
|
|
const taskType = url.searchParams.get("task_type");
|
|
const priority = url.searchParams.get("priority");
|
|
const assignee = url.searchParams.get("assignee");
|
|
const includeDone = url.searchParams.get("include_done") === "true";
|
|
const limit = parseInt(url.searchParams.get("limit") || "200", 10);
|
|
|
|
const filters: string[] = [];
|
|
if (status && status !== "all") {
|
|
if (!(VALID_STATUS as readonly string[]).includes(status)) {
|
|
return NextResponse.json({ error: `invalid status: ${status}` }, { status: 400 });
|
|
}
|
|
filters.push(`status="${status}"`);
|
|
} else if (!includeDone) {
|
|
// Default: don't pull done/failed tasks older than 200 unless asked
|
|
filters.push(`(status!="done" && status!="failed")`);
|
|
}
|
|
if (taskType) {
|
|
if (!(VALID_TASK_TYPE as readonly string[]).includes(taskType)) {
|
|
return NextResponse.json({ error: `invalid task_type: ${taskType}` }, { status: 400 });
|
|
}
|
|
filters.push(`task_type="${taskType}"`);
|
|
}
|
|
if (priority) {
|
|
if (!(VALID_PRIORITY as readonly string[]).includes(priority)) {
|
|
return NextResponse.json({ error: `invalid priority: ${priority}` }, { status: 400 });
|
|
}
|
|
filters.push(`priority="${priority}"`);
|
|
}
|
|
if (assignee) {
|
|
filters.push(`assignee="${assignee.replace(/"/g, '\\"')}"`);
|
|
}
|
|
|
|
try {
|
|
const result = await fetchCollection<PBRecord>(COLLECTION, {
|
|
page: 1,
|
|
perPage: Math.min(limit, 500),
|
|
// PB on this instance doesn't support sort on the auto-managed
|
|
// `created` column. Use priority (desc) then id (desc) — id is
|
|
// a nanoid prefix so newer tasks come last when sorted desc, and
|
|
// the worker can poll deterministically.
|
|
sort: "-priority,-id",
|
|
filter: filters.length > 0 ? filters.join(" && ") : undefined,
|
|
});
|
|
return NextResponse.json(result);
|
|
} catch (err: any) {
|
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let body: any;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
|
|
}
|
|
|
|
const { title, description, task_type, payload, status, priority, assignee, created_by } = body ?? {};
|
|
|
|
if (!title || typeof title !== "string") {
|
|
return NextResponse.json({ error: "title is required" }, { status: 400 });
|
|
}
|
|
if (!task_type || !(VALID_TASK_TYPE as readonly string[]).includes(task_type)) {
|
|
return NextResponse.json({ error: `task_type must be one of: ${VALID_TASK_TYPE.join(", ")}` }, { status: 400 });
|
|
}
|
|
const finalStatus = status || "backlog";
|
|
if (!(VALID_STATUS as readonly string[]).includes(finalStatus)) {
|
|
return NextResponse.json({ error: `invalid status: ${finalStatus}` }, { status: 400 });
|
|
}
|
|
const finalPriority = priority || "normal";
|
|
if (!(VALID_PRIORITY as readonly string[]).includes(finalPriority)) {
|
|
return NextResponse.json({ error: `invalid priority: ${finalPriority}` }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const created = await createRecord<PBRecord>(COLLECTION, {
|
|
title,
|
|
description: description || null,
|
|
task_type,
|
|
payload: payload || {},
|
|
status: finalStatus,
|
|
priority: finalPriority,
|
|
assignee: assignee || "agent",
|
|
retry_count: 0,
|
|
created_by: created_by || "user",
|
|
});
|
|
return NextResponse.json(created, { status: 201 });
|
|
} catch (err: any) {
|
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
|
}
|
|
}
|