[taskboard] add completion sync APIs
This commit is contained in:
@@ -35,6 +35,8 @@ It tracks and visualizes:
|
||||
- dispatch lifecycle states and SQLite audit history
|
||||
- OpenClaw swarm dispatch into `~/.clawdbot/active-tasks.json`
|
||||
- ZeroClaw webhook dispatch for `grizzley` and `ice`
|
||||
- task callback API for remote completion/result sync
|
||||
- OpenClaw registry sync API for swarm task state reconciliation
|
||||
- failure queue and dispatch history views
|
||||
- family-specific runtime views for OpenClaw and ZeroClaw
|
||||
- architecture documentation rendered directly from tracked config
|
||||
|
||||
7
app/api/sync/openclaw/route.ts
Normal file
7
app/api/sync/openclaw/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { syncOpenClawTasks } from "@/lib/openclaw-sync";
|
||||
|
||||
export async function POST() {
|
||||
return NextResponse.json(await syncOpenClawTasks());
|
||||
}
|
||||
30
app/api/tasks/[id]/callback/route.ts
Normal file
30
app/api/tasks/[id]/callback/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { applyTaskCallback } from "@/lib/tasks";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as {
|
||||
status?: "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
|
||||
dispatch_state?: "planned" | "assigned" | "dispatched" | "acknowledged" | "completed" | "failed";
|
||||
summary?: string | null;
|
||||
detail?: string | null;
|
||||
completed_by?: string | null;
|
||||
last_error?: string | null;
|
||||
};
|
||||
|
||||
const updated = await applyTaskCallback(numericId, payload);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
@@ -38,6 +38,18 @@ export async function PATCH(
|
||||
reasoning_effort:
|
||||
typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : undefined,
|
||||
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : undefined,
|
||||
result_summary:
|
||||
payload.result_summary === null || typeof payload.result_summary === "string"
|
||||
? (payload.result_summary as never)
|
||||
: undefined,
|
||||
result_detail:
|
||||
payload.result_detail === null || typeof payload.result_detail === "string"
|
||||
? (payload.result_detail as never)
|
||||
: undefined,
|
||||
completed_by:
|
||||
payload.completed_by === null || typeof payload.completed_by === "string"
|
||||
? (payload.completed_by as never)
|
||||
: undefined,
|
||||
priority: payload.priority as never,
|
||||
status: payload.status as never,
|
||||
last_dispatch_at:
|
||||
|
||||
@@ -347,6 +347,15 @@ export function TasksClient({
|
||||
<dd className="text-right">{task.target_channel || "n/a"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{task.result_summary ? (
|
||||
<div className="mt-3 rounded-lg border border-emerald-400/20 bg-emerald-400/5 p-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-300/80">Latest Result</p>
|
||||
<p className="mt-1 text-sm text-slate-200">{task.result_summary}</p>
|
||||
{task.result_detail ? (
|
||||
<p className="mt-1 text-xs text-slate-400">{task.result_detail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 space-y-2">
|
||||
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
|
||||
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>
|
||||
|
||||
@@ -116,6 +116,9 @@ function getDatabase() {
|
||||
await ensureColumn(database, "tasks", "preferred_agent", "TEXT");
|
||||
await ensureColumn(database, "tasks", "reasoning_effort", "TEXT");
|
||||
await ensureColumn(database, "tasks", "model_hint", "TEXT");
|
||||
await ensureColumn(database, "tasks", "result_summary", "TEXT");
|
||||
await ensureColumn(database, "tasks", "result_detail", "TEXT");
|
||||
await ensureColumn(database, "tasks", "completed_by", "TEXT");
|
||||
await ensureColumn(database, "tasks", "last_dispatch_at", "TEXT");
|
||||
await ensureColumn(database, "tasks", "acknowledged_at", "TEXT");
|
||||
await ensureColumn(database, "tasks", "last_error", "TEXT");
|
||||
|
||||
117
lib/openclaw-sync.ts
Normal file
117
lib/openclaw-sync.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import { SWARM_TASKS_FILE } from "@/lib/fleet-config";
|
||||
import { appendTaskEvent, applyTaskCallback, findTask } from "@/lib/tasks";
|
||||
|
||||
type SwarmRegistryTask = {
|
||||
id: string;
|
||||
taskboardTaskId?: number | null;
|
||||
status?: string;
|
||||
tmuxSession?: string;
|
||||
worktree?: string;
|
||||
pr?: number | null;
|
||||
note?: string | null;
|
||||
failedAt?: number | null;
|
||||
completedAt?: number | null;
|
||||
startedAt?: number | null;
|
||||
agent?: string | null;
|
||||
};
|
||||
|
||||
function readRegistry() {
|
||||
if (!fs.existsSync(SWARM_TASKS_FILE)) {
|
||||
return [] as SwarmRegistryTask[];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, "utf8")) as { tasks?: SwarmRegistryTask[] };
|
||||
return Array.isArray(parsed.tasks) ? parsed.tasks : [];
|
||||
}
|
||||
|
||||
function statusToDispatchState(status: string | undefined) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "acknowledged" as const;
|
||||
case "done":
|
||||
return "completed" as const;
|
||||
case "failed":
|
||||
return "failed" as const;
|
||||
case "queued":
|
||||
case "retrying":
|
||||
return "dispatched" as const;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function statusToTaskStatus(status: string | undefined) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "In Progress" as const;
|
||||
case "done":
|
||||
return "Done" as const;
|
||||
case "failed":
|
||||
return "Backlog" as const;
|
||||
case "queued":
|
||||
case "retrying":
|
||||
return "Todo" as const;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncOpenClawTasks() {
|
||||
const registryTasks = readRegistry();
|
||||
const results: Array<{ taskId: number; registryStatus: string; synced: boolean }> = [];
|
||||
|
||||
for (const registryTask of registryTasks) {
|
||||
if (!registryTask.taskboardTaskId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const task = await findTask(registryTask.taskboardTaskId);
|
||||
if (!task) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dispatchState = statusToDispatchState(registryTask.status);
|
||||
const taskStatus = statusToTaskStatus(registryTask.status);
|
||||
if (!dispatchState) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await applyTaskCallback(task.id, {
|
||||
status: taskStatus,
|
||||
dispatch_state: dispatchState,
|
||||
summary:
|
||||
registryTask.status === "done"
|
||||
? `OpenClaw task ${registryTask.id} completed`
|
||||
: registryTask.status === "failed"
|
||||
? `OpenClaw task ${registryTask.id} failed`
|
||||
: `OpenClaw task ${registryTask.id} ${registryTask.status}`,
|
||||
detail:
|
||||
registryTask.pr
|
||||
? `PR #${registryTask.pr} from ${registryTask.id}`
|
||||
: registryTask.note || registryTask.worktree || "",
|
||||
completed_by: registryTask.agent || "openclaw-swarm",
|
||||
last_error: registryTask.status === "failed" ? registryTask.note || "Swarm task failed" : null,
|
||||
});
|
||||
|
||||
await appendTaskEvent({
|
||||
taskId: task.id,
|
||||
assignee: task.assignee,
|
||||
family: task.family,
|
||||
host: task.target_host,
|
||||
eventType: "updated",
|
||||
state: dispatchState,
|
||||
summary: `OpenClaw sync: ${registryTask.status}`,
|
||||
detail: registryTask.worktree || "",
|
||||
});
|
||||
|
||||
results.push({
|
||||
taskId: task.id,
|
||||
registryStatus: registryTask.status || "unknown",
|
||||
synced: true,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
58
lib/tasks.ts
58
lib/tasks.ts
@@ -102,6 +102,9 @@ export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
|
||||
preferred_agent: row.preferred_agent || null,
|
||||
reasoning_effort: row.reasoning_effort || null,
|
||||
model_hint: row.model_hint || null,
|
||||
result_summary: row.result_summary || null,
|
||||
result_detail: row.result_detail || null,
|
||||
completed_by: row.completed_by || null,
|
||||
last_dispatch_at: row.last_dispatch_at || null,
|
||||
acknowledged_at: row.acknowledged_at || null,
|
||||
last_error: row.last_error || null,
|
||||
@@ -252,8 +255,9 @@ export async function createTask(input: Partial<TaskRecord>) {
|
||||
title, description, assignee, family, target_host, target_channel,
|
||||
dispatch_method, dispatch_state, template_key, repo_slug, base_branch,
|
||||
preferred_agent, reasoning_effort, model_hint, priority, status, tags,
|
||||
result_summary, result_detail, completed_by,
|
||||
last_dispatch_at, acknowledged_at, last_error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
input.title?.trim() || "",
|
||||
input.description || "",
|
||||
@@ -272,6 +276,9 @@ export async function createTask(input: Partial<TaskRecord>) {
|
||||
input.priority || "Medium",
|
||||
input.status || "Backlog",
|
||||
JSON.stringify(tags),
|
||||
normalizeNullableString(input.result_summary),
|
||||
normalizeNullableString(input.result_detail),
|
||||
normalizeNullableString(input.completed_by),
|
||||
input.last_dispatch_at || null,
|
||||
deriveAcknowledgedAt(dispatchState),
|
||||
normalizeNullableString(input.last_error),
|
||||
@@ -317,6 +324,7 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
|
||||
SET title = ?, description = ?, assignee = ?, family = ?, target_host = ?, target_channel = ?,
|
||||
dispatch_method = ?, dispatch_state = ?, template_key = ?, repo_slug = ?, base_branch = ?,
|
||||
preferred_agent = ?, reasoning_effort = ?, model_hint = ?, priority = ?, status = ?, tags = ?,
|
||||
result_summary = ?, result_detail = ?, completed_by = ?,
|
||||
last_dispatch_at = ?, acknowledged_at = ?, last_error = ?, completed_at = ?, updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
[
|
||||
@@ -337,6 +345,9 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
|
||||
input.priority ?? existing.priority,
|
||||
nextStatus,
|
||||
JSON.stringify(mergedTags),
|
||||
hasField("result_summary") ? input.result_summary ?? null : existing.result_summary,
|
||||
hasField("result_detail") ? input.result_detail ?? null : existing.result_detail,
|
||||
hasField("completed_by") ? input.completed_by ?? null : existing.completed_by,
|
||||
hasField("last_dispatch_at") ? input.last_dispatch_at ?? null : existing.last_dispatch_at,
|
||||
acknowledgedAt,
|
||||
hasField("last_error") ? input.last_error ?? null : existing.last_error,
|
||||
@@ -373,3 +384,48 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function applyTaskCallback(id: number, payload: {
|
||||
status?: TaskStatus;
|
||||
dispatch_state?: DispatchState;
|
||||
summary?: string | null;
|
||||
detail?: string | null;
|
||||
completed_by?: string | null;
|
||||
last_error?: string | null;
|
||||
}) {
|
||||
const nextStatus = payload.status ?? (payload.dispatch_state === "completed" ? "Done" : undefined);
|
||||
const updated = await updateTask(id, {
|
||||
status: nextStatus,
|
||||
dispatch_state: payload.dispatch_state,
|
||||
result_summary: payload.summary ?? undefined,
|
||||
result_detail: payload.detail ?? undefined,
|
||||
completed_by: payload.completed_by ?? undefined,
|
||||
last_error: payload.last_error ?? undefined,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventType =
|
||||
payload.dispatch_state === "failed"
|
||||
? "dispatch_failed"
|
||||
: payload.dispatch_state === "completed"
|
||||
? "dispatch_succeeded"
|
||||
: payload.dispatch_state === "acknowledged"
|
||||
? "acknowledged"
|
||||
: "updated";
|
||||
|
||||
await appendTaskEvent({
|
||||
taskId: updated.id,
|
||||
assignee: updated.assignee,
|
||||
family: updated.family,
|
||||
host: updated.target_host,
|
||||
eventType,
|
||||
state: updated.dispatch_state,
|
||||
summary: payload.summary || `${eventType.replace(/_/g, " ")} callback`,
|
||||
detail: payload.detail || "",
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
12
lib/types.ts
12
lib/types.ts
@@ -27,6 +27,9 @@ export type TaskRecord = {
|
||||
preferred_agent: string | null;
|
||||
reasoning_effort: string | null;
|
||||
model_hint: string | null;
|
||||
result_summary: string | null;
|
||||
result_detail: string | null;
|
||||
completed_by: string | null;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
tags: string[];
|
||||
@@ -78,6 +81,15 @@ export type TaskEvent = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type TaskCallbackPayload = {
|
||||
status?: TaskStatus;
|
||||
dispatch_state?: DispatchState;
|
||||
summary?: string | null;
|
||||
detail?: string | null;
|
||||
completed_by?: string | null;
|
||||
last_error?: string | null;
|
||||
};
|
||||
|
||||
export type WikiPageSummary = {
|
||||
filename: string;
|
||||
title: string;
|
||||
|
||||
Reference in New Issue
Block a user