[taskboard] add completion sync APIs

This commit is contained in:
2026-03-07 12:53:22 -08:00
parent e8e79c7b4c
commit 73da5ae6d2
9 changed files with 249 additions and 1 deletions

View File

@@ -35,6 +35,8 @@ It tracks and visualizes:
- dispatch lifecycle states and SQLite audit history - dispatch lifecycle states and SQLite audit history
- OpenClaw swarm dispatch into `~/.clawdbot/active-tasks.json` - OpenClaw swarm dispatch into `~/.clawdbot/active-tasks.json`
- ZeroClaw webhook dispatch for `grizzley` and `ice` - 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 - failure queue and dispatch history views
- family-specific runtime views for OpenClaw and ZeroClaw - family-specific runtime views for OpenClaw and ZeroClaw
- architecture documentation rendered directly from tracked config - architecture documentation rendered directly from tracked config

View 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());
}

View 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);
}

View File

@@ -38,6 +38,18 @@ export async function PATCH(
reasoning_effort: reasoning_effort:
typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : undefined, typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : undefined,
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : 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, priority: payload.priority as never,
status: payload.status as never, status: payload.status as never,
last_dispatch_at: last_dispatch_at:

View File

@@ -347,6 +347,15 @@ export function TasksClient({
<dd className="text-right">{task.target_channel || "n/a"}</dd> <dd className="text-right">{task.target_channel || "n/a"}</dd>
</div> </div>
</dl> </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"> <div className="mt-4 space-y-2">
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? ( {task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}> <Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>

View File

@@ -116,6 +116,9 @@ function getDatabase() {
await ensureColumn(database, "tasks", "preferred_agent", "TEXT"); await ensureColumn(database, "tasks", "preferred_agent", "TEXT");
await ensureColumn(database, "tasks", "reasoning_effort", "TEXT"); await ensureColumn(database, "tasks", "reasoning_effort", "TEXT");
await ensureColumn(database, "tasks", "model_hint", "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", "last_dispatch_at", "TEXT");
await ensureColumn(database, "tasks", "acknowledged_at", "TEXT"); await ensureColumn(database, "tasks", "acknowledged_at", "TEXT");
await ensureColumn(database, "tasks", "last_error", "TEXT"); await ensureColumn(database, "tasks", "last_error", "TEXT");

117
lib/openclaw-sync.ts Normal file
View 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;
}

View File

@@ -102,6 +102,9 @@ export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
preferred_agent: row.preferred_agent || null, preferred_agent: row.preferred_agent || null,
reasoning_effort: row.reasoning_effort || null, reasoning_effort: row.reasoning_effort || null,
model_hint: row.model_hint || 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, last_dispatch_at: row.last_dispatch_at || null,
acknowledged_at: row.acknowledged_at || null, acknowledged_at: row.acknowledged_at || null,
last_error: row.last_error || 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, title, description, assignee, family, target_host, target_channel,
dispatch_method, dispatch_state, template_key, repo_slug, base_branch, dispatch_method, dispatch_state, template_key, repo_slug, base_branch,
preferred_agent, reasoning_effort, model_hint, priority, status, tags, preferred_agent, reasoning_effort, model_hint, priority, status, tags,
result_summary, result_detail, completed_by,
last_dispatch_at, acknowledged_at, last_error last_dispatch_at, acknowledged_at, last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
input.title?.trim() || "", input.title?.trim() || "",
input.description || "", input.description || "",
@@ -272,6 +276,9 @@ export async function createTask(input: Partial<TaskRecord>) {
input.priority || "Medium", input.priority || "Medium",
input.status || "Backlog", input.status || "Backlog",
JSON.stringify(tags), JSON.stringify(tags),
normalizeNullableString(input.result_summary),
normalizeNullableString(input.result_detail),
normalizeNullableString(input.completed_by),
input.last_dispatch_at || null, input.last_dispatch_at || null,
deriveAcknowledgedAt(dispatchState), deriveAcknowledgedAt(dispatchState),
normalizeNullableString(input.last_error), 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 = ?, SET title = ?, description = ?, assignee = ?, family = ?, target_host = ?, target_channel = ?,
dispatch_method = ?, dispatch_state = ?, template_key = ?, repo_slug = ?, base_branch = ?, dispatch_method = ?, dispatch_state = ?, template_key = ?, repo_slug = ?, base_branch = ?,
preferred_agent = ?, reasoning_effort = ?, model_hint = ?, priority = ?, status = ?, tags = ?, 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') last_dispatch_at = ?, acknowledged_at = ?, last_error = ?, completed_at = ?, updated_at = datetime('now')
WHERE id = ?`, WHERE id = ?`,
[ [
@@ -337,6 +345,9 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
input.priority ?? existing.priority, input.priority ?? existing.priority,
nextStatus, nextStatus,
JSON.stringify(mergedTags), 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, hasField("last_dispatch_at") ? input.last_dispatch_at ?? null : existing.last_dispatch_at,
acknowledgedAt, acknowledgedAt,
hasField("last_error") ? input.last_error ?? null : existing.last_error, 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; 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;
}

View File

@@ -27,6 +27,9 @@ export type TaskRecord = {
preferred_agent: string | null; preferred_agent: string | null;
reasoning_effort: string | null; reasoning_effort: string | null;
model_hint: string | null; model_hint: string | null;
result_summary: string | null;
result_detail: string | null;
completed_by: string | null;
priority: TaskPriority; priority: TaskPriority;
status: TaskStatus; status: TaskStatus;
tags: string[]; tags: string[];
@@ -78,6 +81,15 @@ export type TaskEvent = {
created_at: string; 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 = { export type WikiPageSummary = {
filename: string; filename: string;
title: string; title: string;