diff --git a/backend/src/api.ts b/backend/src/api.ts index f15e4b0..dd72e4c 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -1423,8 +1423,9 @@ async function handleGet( } if (path === "/api/tasks") { + const summary = url.searchParams.get("mode") === "summary"; return jsonResponse( - ctx.db.get_all_tasks().map((t) => attachDependencyMetadata(ctx.db, t)), + ctx.db.get_all_tasks_with_dependencies(summary), 200, origin, ); @@ -1512,7 +1513,7 @@ async function handleGet( return jsonResponse({ csrf_token: CSRF_TOKEN }, 200, origin); if (path === "/api/health") return jsonResponse( - { status: "ok", tasks: ctx.db.get_all_tasks().length }, + { status: "ok", tasks: ctx.db.count_tasks() }, 200, origin, ); diff --git a/backend/src/db.ts b/backend/src/db.ts index 9b5dfcf..afb1588 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -106,6 +106,10 @@ export class TaskDB { this._migrate("ALTER TABLE tasks ADD COLUMN image_paths TEXT DEFAULT '[]'"); this._migrate("ALTER TABLE tasks ADD COLUMN notify_slack_channel TEXT"); this._migrate("ALTER TABLE tasks ADD COLUMN notify_telegram_chat_id TEXT"); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_tasks_status_next_run + ON tasks(status, next_run_at) + `); this.conn.run(` CREATE TABLE IF NOT EXISTS settings ( @@ -174,6 +178,10 @@ export class TaskDB { ) `); this._migrate("ALTER TABLE task_runs ADD COLUMN raw_output TEXT"); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_task_runs_task_started + ON task_runs(task_id, started_at DESC) + `); this.conn.run(` CREATE TABLE IF NOT EXISTS heartbeats ( @@ -265,6 +273,10 @@ export class TaskDB { CREATE INDEX IF NOT EXISTS idx_task_output_events_timestamp ON task_output_events(timestamp) `); + this.conn.run(` + CREATE INDEX IF NOT EXISTS idx_task_output_events_task_timestamp + ON task_output_events(task_id, timestamp DESC) + `); // DAG dependency table this.conn.run(` @@ -1286,6 +1298,73 @@ export class TaskDB { return rows.map((r) => this._deserialize_task(r)); } + /** + * Return the task list with dependency metadata using three fixed queries. + * + * The legacy list keeps the same shape as get_task() plus dependencies and + * dependents. Summary mode omits large detail-only columns and truncates the + * prompt for TaskCard rendering. + */ + get_all_tasks_with_dependencies(summary: boolean = false): Row[] { + const taskSql = summary + ? `SELECT id, title, substr(prompt, 1, 240) AS prompt_preview, + status, schedule_type, cron_expr, delay_seconds, next_run_at, + last_run_at, run_count, max_runs, tags, agent, dag_id + FROM tasks ORDER BY created_at DESC` + : "SELECT * FROM tasks ORDER BY created_at DESC"; + const rows = this.conn.query(taskSql).all() as Row[]; + if (rows.length === 0) return []; + + const taskIds = new Set(rows.map((row) => Number(row["id"]))); + const dependenciesByTask = new Map(); + const dependentsByTask = new Map(); + const dependencies = this.conn + .query( + `SELECT td.*, t.title AS depends_on_title, t.status AS depends_on_status + FROM task_dependencies td + JOIN tasks t ON t.id = td.depends_on_task_id`, + ) + .all() as Row[]; + const dependents = this.conn + .query( + `SELECT td.*, t.title AS task_title, t.status AS task_status + FROM task_dependencies td + JOIN tasks t ON t.id = td.task_id`, + ) + .all() as Row[]; + + for (const dependency of dependencies) { + const taskId = Number(dependency["task_id"]); + if (!taskIds.has(taskId)) continue; + const value = summary + ? { depends_on_task_id: dependency["depends_on_task_id"] } + : { ...dependency }; + const current = dependenciesByTask.get(taskId) ?? []; + current.push(value); + dependenciesByTask.set(taskId, current); + } + for (const dependent of dependents) { + const upstreamId = Number(dependent["depends_on_task_id"]); + if (!taskIds.has(upstreamId)) continue; + const current = dependentsByTask.get(upstreamId) ?? []; + current.push(Number(dependent["task_id"])); + dependentsByTask.set(upstreamId, current); + } + + return rows.map((row) => ({ + ...(summary ? row : this._deserialize_task(row)), + dependencies: dependenciesByTask.get(Number(row["id"])) ?? [], + dependents: dependentsByTask.get(Number(row["id"])) ?? [], + })); + } + + count_tasks(): number { + const row = this.conn + .query("SELECT COUNT(*) AS count FROM tasks") + .get() as Row; + return Number(row["count"]); + } + get_due_tasks(): Row[] { const rows = this.conn .query( diff --git a/backend/tests/api-handler.test.ts b/backend/tests/api-handler.test.ts index 82290b5..f924b8c 100644 --- a/backend/tests/api-handler.test.ts +++ b/backend/tests/api-handler.test.ts @@ -158,6 +158,77 @@ describe("api handler", () => { expect(tasks[0]["dependents"]).toEqual([]); }); + test("GET /api/tasks batches dependency metadata without per-task queries", async () => { + const upstream = db.add_task( + makeTask({ title: "Upstream", prompt: "prepare", working_dir: "." }), + ); + const downstream = db.add_task( + makeTask({ title: "Downstream", prompt: "finish", working_dir: "." }), + ); + db.add_dependency(downstream, upstream, true); + db.get_dependencies = () => { + throw new Error("per-task dependency query should not run"); + }; + db.get_dependents = () => { + throw new Error("per-task dependent query should not run"); + }; + + const tasks = await json(new Request("http://127.0.0.1:9712/api/tasks")); + + expect(tasks).toHaveLength(2); + expect( + tasks.find((task: Record) => task.id === upstream) + .dependents, + ).toEqual([downstream]); + expect( + tasks.find((task: Record) => task.id === downstream) + .dependencies[0], + ).toMatchObject({ + task_id: downstream, + depends_on_task_id: upstream, + inject_result: 1, + depends_on_title: "Upstream", + }); + expect( + tasks.find((task: Record) => task.id === upstream).prompt, + ).toBe("prepare"); + }); + + test("GET /api/tasks summary returns only board fields", async () => { + db.add_task( + makeTask({ + title: "Summary", + prompt: "x".repeat(400), + working_dir: "/private/project", + }), + ); + + const tasks = await json( + new Request("http://127.0.0.1:9712/api/tasks?mode=summary"), + ); + const task = tasks[0]; + + expect(task.prompt_preview).toHaveLength(240); + expect(task.dependencies).toEqual([]); + expect(task.dependents).toEqual([]); + expect(task).not.toHaveProperty("prompt"); + expect(task).not.toHaveProperty("working_dir"); + expect(task).not.toHaveProperty("result"); + expect(task).not.toHaveProperty("error"); + expect(task).not.toHaveProperty("prompt_images"); + }); + + test("GET /api/health counts tasks without loading task rows", async () => { + db.add_task(makeTask({ title: "Count me", prompt: "p" })); + db.get_all_tasks = () => { + throw new Error("health should use COUNT"); + }; + + const health = await json(new Request("http://127.0.0.1:9712/api/health")); + + expect(health).toEqual({ status: "ok", tasks: 1 }); + }); + test("GET task output falls back to latest persisted raw output", async () => { const created = await json( new Request("http://127.0.0.1:9712/api/tasks", { diff --git a/backend/tests/taskdb.test.ts b/backend/tests/taskdb.test.ts index a64a258..17700b5 100644 --- a/backend/tests/taskdb.test.ts +++ b/backend/tests/taskdb.test.ts @@ -49,6 +49,29 @@ describe("TaskDB", () => { expect(db.get_setting("k")).toBe("v2"); }); + test("test_task_count_and_polling_indexes_are_idempotent", () => { + db.add_task(makeTask({ title: "one", prompt: "p" })); + expect(db.count_tasks()).toBe(1); + + const dbPath = db.db_path; + db.conn.close(); + db = new TaskDB(dbPath); + + const indexNames = (table: string) => + new Set( + ( + db.conn.query(`PRAGMA index_list('${table}')`).all() as Array<{ + name: string; + }> + ).map((row) => row.name), + ); + expect(indexNames("tasks")).toContain("idx_tasks_status_next_run"); + expect(indexNames("task_runs")).toContain("idx_task_runs_task_started"); + expect(indexNames("task_output_events")).toContain( + "idx_task_output_events_task_timestamp", + ); + }); + // ── run history ──────────────────────────────────────────────────────────── test("test_run_lifecycle_and_ordering", () => { const tid = db.add_task( diff --git a/taskboard-electron/src/renderer/App.tsx b/taskboard-electron/src/renderer/App.tsx index 592c827..7bddffc 100644 --- a/taskboard-electron/src/renderer/App.tsx +++ b/taskboard-electron/src/renderer/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, type CSSProperties } from "react"; +import { memo, useState, useEffect, useCallback, useMemo, useRef, type CSSProperties } from "react"; import { CheckCircle2, GitFork, @@ -49,7 +49,13 @@ import { taskNeedsResponse, type TaskResponseRefreshResult, } from "./operatorUi.ts"; +import { + DetailRequestCoordinator, + loadLatestTaskDetail, + mergeTaskSummaryIntoDetail, +} from "./taskPollingState.ts"; import { buildExecutionSteps } from "./traceSteps.ts"; +import { fetchMainViewData, type MainView } from "./viewPolling.ts"; const API = "http://127.0.0.1:9712/api"; @@ -837,21 +843,8 @@ async function fetchWithTimeout( } // ─── API helpers ─── -async function fetchTasks() { - const res = await fetch(`${API}/tasks`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - -async function fetchTask(id) { - const res = await fetch(`${API}/tasks/${id}`); - const payload = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`); - return payload; -} - -async function fetchHeartbeats() { - const res = await fetch(`${API}/heartbeats`); +async function fetchTask(id, signal?: AbortSignal) { + const res = await fetch(`${API}/tasks/${id}`, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); } @@ -869,12 +862,6 @@ async function createTask(data) { }); } -async function fetchSkillPatterns() { - const res = await fetch(`${API}/skill-patterns`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - async function triggerSkillSweep(agent?: string) { return apiMutation("/skills/sweep", { method: "POST", @@ -907,12 +894,6 @@ async function dismissSkillPattern(id) { }); } -async function fetchSkills() { - const res = await fetch(`${API}/skills`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.json(); -} - async function setSkillEnabledApi(id, enabled) { return apiMutation(`/skills/${id}`, { method: "PUT", @@ -1603,7 +1584,8 @@ function segmentedButton(active: boolean): CSSProperties { }; } -function TaskCard({ task, onAction, onViewDetail }) { +function TaskCard({ task, onAction, onViewDetail, themeVersion }) { + void themeVersion; const [hovered, setHovered] = useState(false); const tags = task.tags ? task.tags.split(",").filter(Boolean) : []; @@ -1677,7 +1659,7 @@ function TaskCard({ task, onAction, onViewDetail }) { WebkitBoxOrient: "vertical", }} > - {task.prompt || "No prompt saved for this task."} + {task.prompt_preview || task.prompt || "No prompt saved for this task."}
@@ -1784,6 +1766,8 @@ function TaskCard({ task, onAction, onViewDetail }) { ); } +const MemoizedTaskCard = memo(TaskCard); + function ActionBtn({ icon, title, onClick, color }) { const [hovered, setHovered] = useState(false); return ( @@ -1811,7 +1795,7 @@ function ActionBtn({ icon, title, onClick, color }) { ); } -function Column({ col, tasks, onAction, onViewDetail }) { +function Column({ col, tasks, onAction, onViewDetail, themeVersion }) { const iconColor = theme[col.tone] || theme.accent; const iconBackground = theme[`${col.tone}Bg`] || theme.field; @@ -1877,7 +1861,13 @@ function Column({ col, tasks, onAction, onViewDetail }) {
{tasks.map((t) => ( - + ))} {tasks.length === 0 && (
([]); - const [activeView, setActiveView] = useState("tasks"); + const [activeView, setActiveView] = useState("tasks"); const [showNew, setShowNew] = useState(false); const [showNewHeartbeat, setShowNewHeartbeat] = useState(false); const [showSettings, setShowSettings] = useState(false); @@ -5965,6 +5955,10 @@ export default function App() { const pollGuardRef = useRef(createRequestGenerationGuard()); const heartbeatDetailId = heartbeatDetail?.id; const submittedTaskAnswersRef = useRef>({}); + const detailRequestCoordinatorRef = useRef(null); + if (detailRequestCoordinatorRef.current === null) { + detailRequestCoordinatorRef.current = new DetailRequestCoordinator(); + } // ─── Color mode ─── const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system"); @@ -6015,34 +6009,36 @@ export default function App() { const poll = useCallback(async () => { const generation = pollGuardRef.current.begin(); try { - const [taskData, heartbeatData, skillRes, skillsRes] = await Promise.all([ - fetchTasks(), - fetchHeartbeats(), - fetchSkillPatterns(), - fetchSkills(), - ]); + const data = await fetchMainViewData(activeView, API); if (!pollGuardRef.current.isCurrent(generation)) return; - const reconciled = reconcileTasksWithSubmittedAnswers( - taskData, - submittedTaskAnswersRef.current, - ); - submittedTaskAnswersRef.current = Object.fromEntries( - reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]), - ); - setTasks(reconciled.tasks); - setHeartbeats(heartbeatData); - setSkillData(skillRes); - setSkills(skillsRes.skills || []); + if (data.tasks !== undefined) { + const reconciled = reconcileTasksWithSubmittedAnswers( + data.tasks, + submittedTaskAnswersRef.current, + ); + submittedTaskAnswersRef.current = Object.fromEntries( + reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]), + ); + setTasks(reconciled.tasks); + setDetail((current) => { + if (!current) return current; + const summary = reconciled.tasks.find((task) => task.id === current.id); + return mergeTaskSummaryIntoDetail(current, summary); + }); + } + if (data.heartbeats !== undefined) setHeartbeats(data.heartbeats); + if (data.skillData !== undefined) setSkillData(data.skillData); + if (data.skills !== undefined) setSkills(data.skills); setConnected(true); setApiError(null); return true; } catch (err) { if (!pollGuardRef.current.isCurrent(generation)) return; setConnected(false); - setApiError(`Failed to fetch tasks: ${err.message}`); + setApiError(`Failed to refresh ${activeView}: ${err.message}`); return false; } - }, []); + }, [activeView]); useEffect(() => { if (!backendReady) return; @@ -6081,27 +6077,53 @@ export default function App() { fetchChannelsStatus().then((s) => setChannelsStatus(s)); }, [backendReady]); - const handleAction = async (action, id) => { - try { - if (action === "cancel") await cancelTask(id); - else if (action === "retry") await retryTask(id); - else if (action === "delete") { - await deleteTask(id); - if (detail?.id === id) setDetail(null); - } else if (action === "edit") { - const task = tasks.find((t) => t.id === id); - if (task) setEditingTask(task); - return; - } else if (action === "fork") { - const task = tasks.find((t) => t.id === id); - if (task) setForkingTask(task); - return; + useEffect(() => { + return () => detailRequestCoordinatorRef.current?.invalidate(); + }, []); + + const openTaskDetail = useCallback((task) => { + return loadLatestTaskDetail( + task.id, + detailRequestCoordinatorRef.current!, + fetchTask, + setDetail, + (error) => + setApiError( + `Failed to fetch task details: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }, []); + const closeTaskDetail = useCallback(() => { + detailRequestCoordinatorRef.current?.invalidate(); + setDetail(null); + }, []); + const switchActiveView = useCallback((view: MainView) => { + pollGuardRef.current.invalidate(); + setActiveView(view); + }, []); + + const handleAction = useCallback( + async (action, id) => { + try { + if (action === "cancel") await cancelTask(id); + else if (action === "retry") await retryTask(id); + else if (action === "delete") { + await deleteTask(id); + setDetail((current) => (current?.id === id ? null : current)); + } else if (action === "edit") { + setEditingTask(await fetchTask(id)); + return; + } else if (action === "fork") { + setForkingTask(await fetchTask(id)); + return; + } + poll(); + } catch (e) { + setApiError(`${action} failed: ${e.message}`); } - poll(); - } catch (e) { - setApiError(`${action} failed: ${e.message}`); - } - }; + }, + [poll], + ); const handleHeartbeatAction = async (action, id) => { try { @@ -6310,21 +6332,44 @@ export default function App() { ? "Search skills" : ""; - const filtered = filter - ? tasks.filter( - (t) => - t.title.toLowerCase().includes(filter.toLowerCase()) || - t.tags?.toLowerCase().includes(filter.toLowerCase()), - ) - : tasks; - - const runningCount = tasks.filter((t) => t.status === "running").length; - const queueCount = tasks.filter((t) => - ["pending", "scheduled", "blocked"].includes(t.status), - ).length; - const doneCount = tasks.filter((t) => - ["completed", "failed", "cancelled"].includes(t.status), - ).length; + const filtered = useMemo(() => { + const query = filters.tasks.trim().toLowerCase(); + if (!query) return tasks; + return tasks.filter( + (task) => + task.title.toLowerCase().includes(query) || task.tags?.toLowerCase().includes(query), + ); + }, [filters.tasks, tasks]); + const tasksByColumn = useMemo( + () => + Object.fromEntries( + COLUMNS.map((column) => [ + column.key, + filtered.filter((task) => column.statuses.includes(task.status)), + ]), + ), + [filtered], + ); + const filteredHeartbeats = useMemo(() => { + const query = filters.heartbeats.trim().toLowerCase(); + if (!query) return heartbeats; + return heartbeats.filter( + (heartbeat) => + heartbeat.name.toLowerCase().includes(query) || + heartbeat.check_prompt.toLowerCase().includes(query), + ); + }, [filters.heartbeats, heartbeats]); + const { runningCount, queueCount, doneCount } = useMemo(() => { + let running = 0; + let queued = 0; + let done = 0; + for (const task of tasks) { + if (task.status === "running") running += 1; + else if (["pending", "scheduled", "blocked"].includes(task.status)) queued += 1; + else if (["completed", "failed", "cancelled"].includes(task.status)) done += 1; + } + return { runningCount: running, queueCount: queued, doneCount: done }; + }, [tasks]); const enabledHeartbeatCount = heartbeats.filter((h) => h.enabled).length; const pausedHeartbeatCount = Math.max(heartbeats.length - enabledHeartbeatCount, 0); const heartbeatIssueCount = heartbeats.filter((h) => h.last_error).length; @@ -6575,7 +6620,7 @@ export default function App() { ].map((tab) => (
@@ -7171,14 +7217,7 @@ export default function App() { gap: 12, }} > - {(filter - ? heartbeats.filter( - (h) => - h.name.toLowerCase().includes(filter.toLowerCase()) || - h.check_prompt.toLowerCase().includes(filter.toLowerCase()), - ) - : heartbeats - ).map((h) => ( + {filteredHeartbeats.map((h) => ( t.id === detail.id) || detail} - onClose={() => setDetail(null)} + task={detail} + onClose={closeTaskDetail} onRespond={handleRespond} onResume={handleResume} /> diff --git a/taskboard-electron/src/renderer/taskPollingState.test.ts b/taskboard-electron/src/renderer/taskPollingState.test.ts new file mode 100644 index 0000000..74409d9 --- /dev/null +++ b/taskboard-electron/src/renderer/taskPollingState.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { + DetailRequestCoordinator, + loadLatestTaskDetail, + mergeTaskSummaryIntoDetail, +} from "./taskPollingState.ts"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("task polling detail state", () => { + test("summary refresh preserves full dependency metadata and detail-only fields", () => { + const dependencies = [ + { + id: 17, + task_id: 2, + depends_on_task_id: 1, + inject_result: 1, + depends_on_title: "Compile assets", + depends_on_status: "completed", + }, + ]; + const detail = { + id: 2, + title: "Ship", + status: "blocked", + prompt: "Full prompt", + result: "Full result", + dependencies, + dependents: [3, 4], + }; + const summary = { + id: 2, + title: "Ship", + status: "running", + run_count: 1, + dependencies: [{ depends_on_task_id: 1 }], + dependents: [99], + }; + + const merged = mergeTaskSummaryIntoDetail(detail, summary)!; + + expect(merged.status).toBe("running"); + expect(merged.run_count).toBe(1); + expect(merged.prompt).toBe("Full prompt"); + expect(merged.result).toBe("Full result"); + expect(merged.dependencies).toEqual(dependencies); + expect(merged.dependencies[0]).toEqual({ + id: 17, + task_id: 2, + depends_on_task_id: 1, + inject_result: 1, + depends_on_title: "Compile assets", + depends_on_status: "completed", + }); + expect(merged.dependents).toEqual([3, 4]); + }); + + test("rapid A to B selection ignores A when it resolves last", async () => { + const coordinator = new DetailRequestCoordinator(); + const requests = new Map>>>(); + const signals = new Map(); + const loaded: number[] = []; + const errors: unknown[] = []; + const fetchTask = (taskId: number, signal: AbortSignal) => { + signals.set(taskId, signal); + const request = deferred>(); + requests.set(taskId, request); + return request.promise; + }; + + const loadA = loadLatestTaskDetail( + 1, + coordinator, + fetchTask, + (task) => loaded.push(task.id), + (error) => errors.push(error), + ); + const loadB = loadLatestTaskDetail( + 2, + coordinator, + fetchTask, + (task) => loaded.push(task.id), + (error) => errors.push(error), + ); + + expect(signals.get(1)?.aborted).toBe(true); + requests.get(2)!.resolve({ id: 2 }); + await loadB; + requests.get(1)!.resolve({ id: 1 }); + await loadA; + + expect(loaded).toEqual([2]); + expect(errors).toEqual([]); + }); + + test("an error from a superseded request does not replace current UI state", async () => { + const coordinator = new DetailRequestCoordinator(); + const requestA = deferred>(); + const requestB = deferred>(); + const loaded: number[] = []; + const errors: unknown[] = []; + const fetchTask = (taskId: number) => (taskId === 1 ? requestA.promise : requestB.promise); + + const loadA = loadLatestTaskDetail( + 1, + coordinator, + fetchTask, + (task) => loaded.push(task.id), + (error) => errors.push(error), + ); + const loadB = loadLatestTaskDetail( + 2, + coordinator, + fetchTask, + (task) => loaded.push(task.id), + (error) => errors.push(error), + ); + + requestB.resolve({ id: 2 }); + await loadB; + requestA.reject(new Error("stale A failure")); + await loadA; + + expect(loaded).toEqual([2]); + expect(errors).toEqual([]); + }); + + test("invalidation prevents a pending request from updating state", async () => { + const coordinator = new DetailRequestCoordinator(); + const request = deferred>(); + const loaded: number[] = []; + const errors: unknown[] = []; + const load = loadLatestTaskDetail( + 1, + coordinator, + async () => request.promise, + (task) => loaded.push(task.id), + (error) => errors.push(error), + ); + + coordinator.invalidate(); + request.resolve({ id: 1 }); + await load; + + expect(loaded).toEqual([]); + expect(errors).toEqual([]); + }); +}); diff --git a/taskboard-electron/src/renderer/taskPollingState.ts b/taskboard-electron/src/renderer/taskPollingState.ts new file mode 100644 index 0000000..1f85376 --- /dev/null +++ b/taskboard-electron/src/renderer/taskPollingState.ts @@ -0,0 +1,79 @@ +const MUTABLE_TASK_SUMMARY_FIELDS = [ + "title", + "status", + "schedule_type", + "cron_expr", + "delay_seconds", + "next_run_at", + "last_run_at", + "run_count", + "max_runs", + "tags", + "agent", + "dag_id", +] as const; + +/** + * Refresh volatile scalar fields without replacing detail-only payloads. + * In particular, summary dependencies intentionally contain fewer fields than + * the full task response and must never overwrite full dependency metadata. + */ +export function mergeTaskSummaryIntoDetail( + detail: Record | null, + summary: Record | undefined, +): Record | null { + if (!detail || !summary || detail.id !== summary.id) return detail; + + const merged = { ...detail }; + for (const field of MUTABLE_TASK_SUMMARY_FIELDS) { + if (Object.prototype.hasOwnProperty.call(summary, field)) { + merged[field] = summary[field]; + } + } + return merged; +} + +export interface DetailRequestToken { + generation: number; + signal: AbortSignal; +} + +export class DetailRequestCoordinator { + private generation = 0; + private controller: AbortController | null = null; + + begin(): DetailRequestToken { + this.controller?.abort(); + this.controller = new AbortController(); + return { + generation: ++this.generation, + signal: this.controller.signal, + }; + } + + isCurrent(token: DetailRequestToken): boolean { + return token.generation === this.generation && !token.signal.aborted; + } + + invalidate(): void { + this.generation += 1; + this.controller?.abort(); + this.controller = null; + } +} + +export async function loadLatestTaskDetail( + taskId: number, + coordinator: DetailRequestCoordinator, + fetchTask: (taskId: number, signal: AbortSignal) => Promise>, + onLoaded: (task: Record) => void, + onError: (error: unknown) => void, +): Promise { + const token = coordinator.begin(); + try { + const task = await fetchTask(taskId, token.signal); + if (coordinator.isCurrent(token)) onLoaded(task); + } catch (error) { + if (coordinator.isCurrent(token)) onError(error); + } +} diff --git a/taskboard-electron/src/renderer/viewPolling.test.ts b/taskboard-electron/src/renderer/viewPolling.test.ts new file mode 100644 index 0000000..f045b3e --- /dev/null +++ b/taskboard-electron/src/renderer/viewPolling.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; +import { fetchMainViewData, type MainView } from "./viewPolling.ts"; + +function response(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("fetchMainViewData", () => { + test.each([ + ["home", []], + ["tasks", ["/tasks?mode=summary"]], + ["heartbeats", ["/heartbeats"]], + ["skills", ["/skill-patterns", "/skills", "/tasks?mode=summary"]], + ] as Array<[MainView, string[]]>)( + "fetches only data needed by the %s view", + async (view, expectedPaths) => { + const requested: string[] = []; + const fetchImpl = async (input: RequestInfo | URL): Promise => { + const url = new URL(String(input)); + requested.push(`${url.pathname}${url.search}`.replace("/api", "")); + if (url.pathname.endsWith("/skills")) return response({ skills: [{ id: 1 }] }); + if (url.pathname.endsWith("/skill-patterns")) return response({ patterns: [] }); + return response([]); + }; + + await fetchMainViewData(view, "http://localhost/api", fetchImpl); + + expect(requested.sort()).toEqual(expectedPaths.sort()); + }, + ); + + test("returns partial data without empty placeholders for unrequested views", async () => { + const data = await fetchMainViewData("heartbeats", "http://localhost/api", async () => + response([{ id: 7 }]), + ); + + expect(data).toEqual({ heartbeats: [{ id: 7 }] }); + expect("tasks" in data).toBe(false); + expect("skills" in data).toBe(false); + }); +}); diff --git a/taskboard-electron/src/renderer/viewPolling.ts b/taskboard-electron/src/renderer/viewPolling.ts new file mode 100644 index 0000000..f409c4a --- /dev/null +++ b/taskboard-electron/src/renderer/viewPolling.ts @@ -0,0 +1,47 @@ +export type MainView = "home" | "tasks" | "heartbeats" | "skills"; + +export interface MainViewData { + tasks?: any[]; + heartbeats?: any[]; + skillData?: any; + skills?: any[]; +} + +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +async function getJson(fetchImpl: FetchLike, url: string): Promise { + const response = await fetchImpl(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); +} + +/** + * Fetch only the data required by the visible top-level view. + * Skills also needs lightweight task summaries to label contributing tasks. + */ +export async function fetchMainViewData( + activeView: MainView, + apiBase: string, + fetchImpl: FetchLike = fetch, +): Promise { + if (activeView === "home") { + return {}; + } + if (activeView === "tasks") { + return { tasks: await getJson(fetchImpl, `${apiBase}/tasks?mode=summary`) }; + } + if (activeView === "heartbeats") { + return { heartbeats: await getJson(fetchImpl, `${apiBase}/heartbeats`) }; + } + + const [skillData, skillsResponse, tasks] = await Promise.all([ + getJson(fetchImpl, `${apiBase}/skill-patterns`), + getJson(fetchImpl, `${apiBase}/skills`), + getJson(fetchImpl, `${apiBase}/tasks?mode=summary`), + ]); + return { + skillData, + skills: skillsResponse.skills || [], + tasks, + }; +}