[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

@@ -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
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,
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;
}

View File

@@ -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;