Skip to content

Commit 3451c54

Browse files
Preserve task detail state during polling
Co-authored-by: DanielWalnut <hetaoBackend@users.noreply.github.com>
1 parent a3a2693 commit 3451c54

3 files changed

Lines changed: 263 additions & 15 deletions

File tree

taskboard-electron/src/renderer/App.tsx

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import {
3535
isWeixinQrImageSource,
3636
mergeChannelsStatus,
3737
} from "./channelsSettings.ts";
38+
import {
39+
DetailRequestCoordinator,
40+
loadLatestTaskDetail,
41+
mergeTaskSummaryIntoDetail,
42+
} from "./taskPollingState.ts";
3843
import { buildExecutionSteps } from "./traceSteps.ts";
3944
import { fetchMainViewData, type MainView } from "./viewPolling.ts";
4045

@@ -824,8 +829,8 @@ async function fetchWithTimeout(
824829
}
825830

826831
// ─── API helpers ───
827-
async function fetchTask(id) {
828-
const res = await fetch(`${API}/tasks/${id}`);
832+
async function fetchTask(id, signal?: AbortSignal) {
833+
const res = await fetch(`${API}/tasks/${id}`, { signal });
829834
if (!res.ok) throw new Error(`HTTP ${res.status}`);
830835
return res.json();
831836
}
@@ -5814,7 +5819,10 @@ export default function App() {
58145819
const [forkingTask, setForkingTask] = useState<any>(null);
58155820
const [editingHeartbeat, setEditingHeartbeat] = useState<any>(null);
58165821
const pollRequestIdRef = useRef(0);
5817-
const detailRequestIdRef = useRef(0);
5822+
const detailRequestCoordinatorRef = useRef<DetailRequestCoordinator | null>(null);
5823+
if (detailRequestCoordinatorRef.current === null) {
5824+
detailRequestCoordinatorRef.current = new DetailRequestCoordinator();
5825+
}
58185826

58195827
// ─── Color mode ───
58205828
const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system");
@@ -5872,7 +5880,7 @@ export default function App() {
58725880
setDetail((current) => {
58735881
if (!current) return current;
58745882
const summary = data.tasks?.find((task) => task.id === current.id);
5875-
return summary ? { ...current, ...summary } : current;
5883+
return mergeTaskSummaryIntoDetail(current, summary);
58765884
});
58775885
}
58785886
if (data.heartbeats !== undefined) setHeartbeats(data.heartbeats);
@@ -5904,19 +5912,24 @@ export default function App() {
59045912
fetchChannelsStatus().then((s) => setChannelsStatus(s));
59055913
}, [backendReady]);
59065914

5907-
const openTaskDetail = useCallback(async (task) => {
5908-
const requestId = ++detailRequestIdRef.current;
5909-
try {
5910-
const fullTask = await fetchTask(task.id);
5911-
if (requestId === detailRequestIdRef.current) setDetail(fullTask);
5912-
} catch (e) {
5913-
if (requestId === detailRequestIdRef.current) {
5914-
setApiError(`Failed to fetch task details: ${e.message}`);
5915-
}
5916-
}
5915+
useEffect(() => {
5916+
return () => detailRequestCoordinatorRef.current?.invalidate();
5917+
}, []);
5918+
5919+
const openTaskDetail = useCallback((task) => {
5920+
return loadLatestTaskDetail(
5921+
task.id,
5922+
detailRequestCoordinatorRef.current!,
5923+
fetchTask,
5924+
setDetail,
5925+
(error) =>
5926+
setApiError(
5927+
`Failed to fetch task details: ${error instanceof Error ? error.message : String(error)}`,
5928+
),
5929+
);
59175930
}, []);
59185931
const closeTaskDetail = useCallback(() => {
5919-
detailRequestIdRef.current += 1;
5932+
detailRequestCoordinatorRef.current?.invalidate();
59205933
setDetail(null);
59215934
}, []);
59225935
const switchActiveView = useCallback((view: MainView) => {
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
DetailRequestCoordinator,
4+
loadLatestTaskDetail,
5+
mergeTaskSummaryIntoDetail,
6+
} from "./taskPollingState.ts";
7+
8+
function deferred<T>() {
9+
let resolve!: (value: T) => void;
10+
let reject!: (error: unknown) => void;
11+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
12+
resolve = resolvePromise;
13+
reject = rejectPromise;
14+
});
15+
return { promise, resolve, reject };
16+
}
17+
18+
describe("task polling detail state", () => {
19+
test("summary refresh preserves full dependency metadata and detail-only fields", () => {
20+
const dependencies = [
21+
{
22+
id: 17,
23+
task_id: 2,
24+
depends_on_task_id: 1,
25+
inject_result: 1,
26+
depends_on_title: "Compile assets",
27+
depends_on_status: "completed",
28+
},
29+
];
30+
const detail = {
31+
id: 2,
32+
title: "Ship",
33+
status: "blocked",
34+
prompt: "Full prompt",
35+
result: "Full result",
36+
dependencies,
37+
dependents: [3, 4],
38+
};
39+
const summary = {
40+
id: 2,
41+
title: "Ship",
42+
status: "running",
43+
run_count: 1,
44+
dependencies: [{ depends_on_task_id: 1 }],
45+
dependents: [99],
46+
};
47+
48+
const merged = mergeTaskSummaryIntoDetail(detail, summary)!;
49+
50+
expect(merged.status).toBe("running");
51+
expect(merged.run_count).toBe(1);
52+
expect(merged.prompt).toBe("Full prompt");
53+
expect(merged.result).toBe("Full result");
54+
expect(merged.dependencies).toEqual(dependencies);
55+
expect(merged.dependencies[0]).toEqual({
56+
id: 17,
57+
task_id: 2,
58+
depends_on_task_id: 1,
59+
inject_result: 1,
60+
depends_on_title: "Compile assets",
61+
depends_on_status: "completed",
62+
});
63+
expect(merged.dependents).toEqual([3, 4]);
64+
});
65+
66+
test("rapid A to B selection ignores A when it resolves last", async () => {
67+
const coordinator = new DetailRequestCoordinator();
68+
const requests = new Map<number, ReturnType<typeof deferred<Record<string, any>>>>();
69+
const signals = new Map<number, AbortSignal>();
70+
const loaded: number[] = [];
71+
const errors: unknown[] = [];
72+
const fetchTask = (taskId: number, signal: AbortSignal) => {
73+
signals.set(taskId, signal);
74+
const request = deferred<Record<string, any>>();
75+
requests.set(taskId, request);
76+
return request.promise;
77+
};
78+
79+
const loadA = loadLatestTaskDetail(
80+
1,
81+
coordinator,
82+
fetchTask,
83+
(task) => loaded.push(task.id),
84+
(error) => errors.push(error),
85+
);
86+
const loadB = loadLatestTaskDetail(
87+
2,
88+
coordinator,
89+
fetchTask,
90+
(task) => loaded.push(task.id),
91+
(error) => errors.push(error),
92+
);
93+
94+
expect(signals.get(1)?.aborted).toBe(true);
95+
requests.get(2)!.resolve({ id: 2 });
96+
await loadB;
97+
requests.get(1)!.resolve({ id: 1 });
98+
await loadA;
99+
100+
expect(loaded).toEqual([2]);
101+
expect(errors).toEqual([]);
102+
});
103+
104+
test("an error from a superseded request does not replace current UI state", async () => {
105+
const coordinator = new DetailRequestCoordinator();
106+
const requestA = deferred<Record<string, any>>();
107+
const requestB = deferred<Record<string, any>>();
108+
const loaded: number[] = [];
109+
const errors: unknown[] = [];
110+
const fetchTask = (taskId: number) => (taskId === 1 ? requestA.promise : requestB.promise);
111+
112+
const loadA = loadLatestTaskDetail(
113+
1,
114+
coordinator,
115+
fetchTask,
116+
(task) => loaded.push(task.id),
117+
(error) => errors.push(error),
118+
);
119+
const loadB = loadLatestTaskDetail(
120+
2,
121+
coordinator,
122+
fetchTask,
123+
(task) => loaded.push(task.id),
124+
(error) => errors.push(error),
125+
);
126+
127+
requestB.resolve({ id: 2 });
128+
await loadB;
129+
requestA.reject(new Error("stale A failure"));
130+
await loadA;
131+
132+
expect(loaded).toEqual([2]);
133+
expect(errors).toEqual([]);
134+
});
135+
136+
test("invalidation prevents a pending request from updating state", async () => {
137+
const coordinator = new DetailRequestCoordinator();
138+
const request = deferred<Record<string, any>>();
139+
const loaded: number[] = [];
140+
const errors: unknown[] = [];
141+
const load = loadLatestTaskDetail(
142+
1,
143+
coordinator,
144+
async () => request.promise,
145+
(task) => loaded.push(task.id),
146+
(error) => errors.push(error),
147+
);
148+
149+
coordinator.invalidate();
150+
request.resolve({ id: 1 });
151+
await load;
152+
153+
expect(loaded).toEqual([]);
154+
expect(errors).toEqual([]);
155+
});
156+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
const MUTABLE_TASK_SUMMARY_FIELDS = [
2+
"title",
3+
"status",
4+
"schedule_type",
5+
"cron_expr",
6+
"delay_seconds",
7+
"next_run_at",
8+
"last_run_at",
9+
"run_count",
10+
"max_runs",
11+
"tags",
12+
"agent",
13+
"dag_id",
14+
] as const;
15+
16+
/**
17+
* Refresh volatile scalar fields without replacing detail-only payloads.
18+
* In particular, summary dependencies intentionally contain fewer fields than
19+
* the full task response and must never overwrite full dependency metadata.
20+
*/
21+
export function mergeTaskSummaryIntoDetail(
22+
detail: Record<string, any> | null,
23+
summary: Record<string, any> | undefined,
24+
): Record<string, any> | null {
25+
if (!detail || !summary || detail.id !== summary.id) return detail;
26+
27+
const merged = { ...detail };
28+
for (const field of MUTABLE_TASK_SUMMARY_FIELDS) {
29+
if (Object.prototype.hasOwnProperty.call(summary, field)) {
30+
merged[field] = summary[field];
31+
}
32+
}
33+
return merged;
34+
}
35+
36+
export interface DetailRequestToken {
37+
generation: number;
38+
signal: AbortSignal;
39+
}
40+
41+
export class DetailRequestCoordinator {
42+
private generation = 0;
43+
private controller: AbortController | null = null;
44+
45+
begin(): DetailRequestToken {
46+
this.controller?.abort();
47+
this.controller = new AbortController();
48+
return {
49+
generation: ++this.generation,
50+
signal: this.controller.signal,
51+
};
52+
}
53+
54+
isCurrent(token: DetailRequestToken): boolean {
55+
return token.generation === this.generation && !token.signal.aborted;
56+
}
57+
58+
invalidate(): void {
59+
this.generation += 1;
60+
this.controller?.abort();
61+
this.controller = null;
62+
}
63+
}
64+
65+
export async function loadLatestTaskDetail(
66+
taskId: number,
67+
coordinator: DetailRequestCoordinator,
68+
fetchTask: (taskId: number, signal: AbortSignal) => Promise<Record<string, any>>,
69+
onLoaded: (task: Record<string, any>) => void,
70+
onError: (error: unknown) => void,
71+
): Promise<void> {
72+
const token = coordinator.begin();
73+
try {
74+
const task = await fetchTask(taskId, token.signal);
75+
if (coordinator.isCurrent(token)) onLoaded(task);
76+
} catch (error) {
77+
if (coordinator.isCurrent(token)) onError(error);
78+
}
79+
}

0 commit comments

Comments
 (0)