WORKFLOW-APP/app/tasks/api.ts
Dave c349998fa1 feat: Task Kanban UI + Python task worker
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
2026-08-17 18:57:02 +02:00

122 lines
3.6 KiB
TypeScript

// app/tasks/api.ts
// Browser-safe client for the /api/tasks routes.
import type { TaskStatus, TaskPriority, TaskType } from "./columns";
export interface Task {
id: string;
collectionId: string;
collectionName: string;
created: string;
updated: string;
title: string;
description: string | null;
task_type: TaskType;
payload: Record<string, unknown> | null;
status: TaskStatus;
priority: TaskPriority;
assignee: string | null;
claimed_by: string | null;
claimed_at: string | null;
started_at: string | null;
completed_at: string | null;
duration_ms: number | null;
result: unknown;
error: string | null;
log: string[] | null;
retry_count: number;
created_by: string | null;
}
export interface ListParams {
status?: TaskStatus | "all";
task_type?: TaskType;
priority?: TaskPriority;
include_done?: boolean;
limit?: number;
}
export async function listTasks(params: ListParams = {}): Promise<Task[]> {
const qs = new URLSearchParams();
if (params.status) qs.set("status", params.status);
if (params.task_type) qs.set("task_type", params.task_type);
if (params.priority) qs.set("priority", params.priority);
if (params.include_done) qs.set("include_done", "true");
if (params.limit) qs.set("limit", String(params.limit));
const res = await fetch(`/api/tasks?${qs.toString()}`, { cache: "no-store" });
if (!res.ok) throw new Error(`list failed (${res.status})`);
const data = await res.json();
return data.items as Task[];
}
export async function getTask(id: string): Promise<Task> {
const res = await fetch(`/api/tasks/${id}`, { cache: "no-store" });
if (!res.ok) throw new Error(`get failed (${res.status})`);
return res.json();
}
export interface CreateTaskInput {
title: string;
description?: string;
task_type: TaskType;
payload?: Record<string, unknown>;
status?: TaskStatus;
priority?: TaskPriority;
assignee?: string;
created_by?: string;
}
export async function createTask(input: CreateTaskInput): Promise<Task> {
const res = await fetch(`/api/tasks`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `create failed (${res.status})`);
}
return res.json();
}
export async function patchTask(
id: string,
fields: Partial<Pick<Task, "title" | "description" | "priority" | "payload" | "assignee" | "status">>,
): Promise<Task> {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fields),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `patch failed (${res.status})`);
}
return res.json();
}
export async function deleteTask(id: string): Promise<void> {
const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`delete failed (${res.status})`);
}
export async function retryTask(id: string): Promise<Task> {
const res = await fetch(`/api/tasks/${id}/retry`, { method: "POST" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `retry failed (${res.status})`);
}
const data = await res.json();
return data.task as Task;
}
export async function cancelTask(id: string): Promise<Task> {
const res = await fetch(`/api/tasks/${id}/cancel`, { method: "POST" });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error ?? `cancel failed (${res.status})`);
}
const data = await res.json();
return data.task as Task;
}