mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(logbook): bound background refresh concurrency (#106397)
* fix(logbook): bound background refresh concurrency * fix(logbook): unify refresh ownership * style(logbook): format type import * test(logbook): use typed rejection error --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
b9a837ec8d
commit
ebb1e83b64
@@ -4,9 +4,12 @@ import {
|
||||
askLogbook,
|
||||
configureLogbookPolling,
|
||||
getLogbookState,
|
||||
loadLogbook,
|
||||
loadLogbookStandup,
|
||||
runLogbookAnalysisNow,
|
||||
stopLogbookPolling,
|
||||
} from "./logbook-controller.ts";
|
||||
import type { LogbookStatusPayload } from "./logbook-types.ts";
|
||||
|
||||
function clientWithRequest(
|
||||
request: (method: string, params: unknown) => Promise<unknown>,
|
||||
@@ -22,6 +25,42 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function statusFor(day: string): LogbookStatusPayload {
|
||||
return {
|
||||
captureEnabled: true,
|
||||
capturePaused: false,
|
||||
captureIntervalSeconds: 30,
|
||||
analysisIntervalMinutes: 15,
|
||||
retentionDays: 30,
|
||||
pendingFrames: 0,
|
||||
analysisRunning: false,
|
||||
visionModelSource: "missing",
|
||||
today: day,
|
||||
todayCards: 1,
|
||||
timeZone: "UTC",
|
||||
};
|
||||
}
|
||||
|
||||
function timelineFor(day: string, title: string) {
|
||||
return {
|
||||
day,
|
||||
cards: [
|
||||
{
|
||||
id: 1,
|
||||
day,
|
||||
startMs: 1,
|
||||
endMs: 2,
|
||||
title,
|
||||
summary: "Summary",
|
||||
detail: "",
|
||||
category: "Coding",
|
||||
distractions: [],
|
||||
},
|
||||
],
|
||||
stats: { trackedMs: 1, distractionMs: 0, categories: [], apps: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Logbook controller", () => {
|
||||
const hosts: object[] = [];
|
||||
|
||||
@@ -48,6 +87,338 @@ describe("Logbook controller", () => {
|
||||
expect(secondRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets an in-flight load settle after polling stops", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const status = deferred<unknown>();
|
||||
const days = deferred<unknown>();
|
||||
const timeline = deferred<unknown>();
|
||||
const responses = new Map([
|
||||
["logbook.status", status],
|
||||
["logbook.days", days],
|
||||
["logbook.timeline", timeline],
|
||||
]);
|
||||
const request = loadLogbook(
|
||||
state,
|
||||
clientWithRequest(
|
||||
(method) =>
|
||||
responses.get(method)?.promise ?? Promise.reject(new Error(`Unexpected ${method}`)),
|
||||
),
|
||||
);
|
||||
|
||||
stopLogbookPolling(host);
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
days.resolve({ days: [] });
|
||||
timeline.resolve(timelineFor("2026-07-04", "Detached host"));
|
||||
await request;
|
||||
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Detached host");
|
||||
expect(state.pollTimer).toBeNull();
|
||||
});
|
||||
|
||||
it("does not overlap silent poll refreshes and resumes after settlement", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const status = deferred<unknown>();
|
||||
const days = deferred<unknown>();
|
||||
const timeline = deferred<unknown>();
|
||||
const firstBatch = new Map([
|
||||
["logbook.status", status],
|
||||
["logbook.days", days],
|
||||
["logbook.timeline", timeline],
|
||||
]);
|
||||
const request = vi.fn((method: string) => {
|
||||
const pending = firstBatch.get(method);
|
||||
if (pending) {
|
||||
firstBatch.delete(method);
|
||||
return pending.promise;
|
||||
}
|
||||
if (method === "logbook.status") {
|
||||
return Promise.resolve(statusFor("2026-07-04"));
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return Promise.resolve({ days: [] });
|
||||
}
|
||||
return Promise.resolve(timelineFor("2026-07-04", "Resumed poll"));
|
||||
});
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(request), true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(90_000);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
days.resolve({ days: [] });
|
||||
timeline.resolve(timelineFor("2026-07-04", "First poll"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("First poll");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(request).toHaveBeenCalledTimes(6);
|
||||
expect(request.mock.calls.filter(([method]) => method === "logbook.status")).toHaveLength(2);
|
||||
expect(request.mock.calls.filter(([method]) => method === "logbook.days")).toHaveLength(2);
|
||||
expect(request.mock.calls.filter(([method]) => method === "logbook.timeline")).toHaveLength(2);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
|
||||
});
|
||||
|
||||
it("retires a pending poll refresh when the client changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const oldStatus = deferred<unknown>();
|
||||
const oldDays = deferred<unknown>();
|
||||
const oldTimeline = deferred<unknown>();
|
||||
const oldResponses = new Map([
|
||||
["logbook.status", oldStatus],
|
||||
["logbook.days", oldDays],
|
||||
["logbook.timeline", oldTimeline],
|
||||
]);
|
||||
const oldRequest = vi.fn((method: string) => {
|
||||
const response = oldResponses.get(method);
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected old-client request: ${method}`);
|
||||
}
|
||||
return response.promise;
|
||||
});
|
||||
const newRequest = vi.fn(async (method: string) => {
|
||||
if (method === "logbook.status") {
|
||||
return statusFor("2026-07-04");
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [] };
|
||||
}
|
||||
return timelineFor("2026-07-04", "New client");
|
||||
});
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(oldRequest), true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(oldRequest).toHaveBeenCalledTimes(3);
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(newRequest), true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(newRequest).toHaveBeenCalledTimes(3);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
|
||||
oldStatus.resolve(statusFor("2026-07-04"));
|
||||
oldDays.resolve({ days: [] });
|
||||
oldTimeline.resolve(timelineFor("2026-07-04", "Retired client"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
});
|
||||
|
||||
it("shares the background refresh owner with analysis completion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const status = deferred<unknown>();
|
||||
const days = deferred<unknown>();
|
||||
const timeline = deferred<unknown>();
|
||||
const pending = new Map([
|
||||
["logbook.status", status],
|
||||
["logbook.days", days],
|
||||
["logbook.timeline", timeline],
|
||||
]);
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "logbook.analyze.now") {
|
||||
return Promise.resolve({ started: true });
|
||||
}
|
||||
const response = pending.get(method);
|
||||
if (response) {
|
||||
pending.delete(method);
|
||||
return response.promise;
|
||||
}
|
||||
if (method === "logbook.status") {
|
||||
return Promise.resolve(statusFor("2026-07-04"));
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return Promise.resolve({ days: [] });
|
||||
}
|
||||
return Promise.resolve(timelineFor("2026-07-04", "Resumed poll"));
|
||||
});
|
||||
const client = clientWithRequest(request);
|
||||
|
||||
configureLogbookPolling(state, client, true);
|
||||
await runLogbookAnalysisNow(state, client);
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
days.resolve({ days: [] });
|
||||
timeline.resolve(timelineFor("2026-07-04", "Analysis refresh"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Analysis refresh");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(request).toHaveBeenCalledTimes(7);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
|
||||
});
|
||||
|
||||
it("queues an analysis refresh behind an in-flight poll", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const status = deferred<unknown>();
|
||||
const days = deferred<unknown>();
|
||||
const timeline = deferred<unknown>();
|
||||
const pending = new Map([
|
||||
["logbook.status", status],
|
||||
["logbook.days", days],
|
||||
["logbook.timeline", timeline],
|
||||
]);
|
||||
const request = vi.fn((method: string) => {
|
||||
if (method === "logbook.analyze.now") {
|
||||
return Promise.resolve({ started: true });
|
||||
}
|
||||
const response = pending.get(method);
|
||||
if (response) {
|
||||
pending.delete(method);
|
||||
return response.promise;
|
||||
}
|
||||
if (method === "logbook.status") {
|
||||
return Promise.resolve(statusFor("2026-07-04"));
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return Promise.resolve({ days: [] });
|
||||
}
|
||||
return Promise.resolve(timelineFor("2026-07-04", "Post-analysis refresh"));
|
||||
});
|
||||
const client = clientWithRequest(request);
|
||||
|
||||
configureLogbookPolling(state, client, true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
|
||||
await runLogbookAnalysisNow(state, client);
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
days.resolve({ days: [] });
|
||||
timeline.resolve(timelineFor("2026-07-04", "Pre-analysis refresh"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(7);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Post-analysis refresh");
|
||||
});
|
||||
|
||||
it("does not let a retired analysis action affect the new client", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
state.dayPinned = true;
|
||||
const oldAnalysis = deferred<unknown>();
|
||||
const newAnalysis = deferred<unknown>();
|
||||
const oldRequest = vi.fn((method: string) => {
|
||||
if (method !== "logbook.analyze.now") {
|
||||
throw new Error(`Unexpected retired-client request: ${method}`);
|
||||
}
|
||||
return oldAnalysis.promise;
|
||||
});
|
||||
const newRequest = vi.fn(async (method: string) => {
|
||||
if (method === "logbook.analyze.now") {
|
||||
return await newAnalysis.promise;
|
||||
}
|
||||
if (method === "logbook.status") {
|
||||
return statusFor("2026-07-04");
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [] };
|
||||
}
|
||||
return timelineFor("2026-07-04", "New client");
|
||||
});
|
||||
const oldClient = clientWithRequest(oldRequest);
|
||||
const newClient = clientWithRequest(newRequest);
|
||||
|
||||
configureLogbookPolling(state, oldClient, true);
|
||||
const oldAction = runLogbookAnalysisNow(state, oldClient);
|
||||
configureLogbookPolling(state, newClient, true);
|
||||
const newAction = runLogbookAnalysisNow(state, newClient);
|
||||
expect(state.actionPending).toBe(true);
|
||||
|
||||
oldAnalysis.resolve({ started: false, reason: "Retired analysis error" });
|
||||
await oldAction;
|
||||
expect(oldRequest).toHaveBeenCalledTimes(1);
|
||||
expect(state.actionPending).toBe(true);
|
||||
expect(state.error).not.toBe("Retired analysis error");
|
||||
|
||||
newAnalysis.resolve({ started: true });
|
||||
await newAction;
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.actionPending).toBe(false);
|
||||
expect(newRequest).toHaveBeenCalledTimes(4);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
});
|
||||
|
||||
it("does not let an older day load overwrite a newer selection", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
const oldStatus = deferred<unknown>();
|
||||
const oldDays = deferred<unknown>();
|
||||
const oldTimeline = deferred<unknown>();
|
||||
const oldResponses = new Map([
|
||||
["logbook.status", oldStatus],
|
||||
["logbook.days", oldDays],
|
||||
["logbook.timeline", oldTimeline],
|
||||
]);
|
||||
const oldRequest = vi.fn((method: string) => {
|
||||
const response = oldResponses.get(method);
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
}
|
||||
return response.promise;
|
||||
});
|
||||
const newerRequest = vi.fn(async (method: string) => {
|
||||
if (method === "logbook.status") {
|
||||
return statusFor("2026-07-05");
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [{ day: "2026-07-05", cards: 1, firstMs: 1, lastMs: 2 }] };
|
||||
}
|
||||
return timelineFor("2026-07-05", "New day");
|
||||
});
|
||||
|
||||
const olderLoad = loadLogbook(state, clientWithRequest(oldRequest), { day: "2026-07-04" });
|
||||
expect(oldRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-04" });
|
||||
|
||||
await loadLogbook(state, clientWithRequest(newerRequest), { day: "2026-07-05" });
|
||||
expect(newerRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-05" });
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New day");
|
||||
|
||||
oldStatus.resolve(statusFor("2026-07-04"));
|
||||
oldDays.resolve({ days: [{ day: "2026-07-04", cards: 1, firstMs: 1, lastMs: 2 }] });
|
||||
oldTimeline.resolve(timelineFor("2026-07-04", "Old day"));
|
||||
await olderLoad;
|
||||
|
||||
expect(state.day).toBe("2026-07-05");
|
||||
expect(state.status?.today).toBe("2026-07-05");
|
||||
expect(state.days[0]?.day).toBe("2026-07-05");
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New day");
|
||||
expect(state.loading).toBe(false);
|
||||
});
|
||||
|
||||
it("discards a standup response after the selected day changes", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
|
||||
@@ -1,83 +1,12 @@
|
||||
// Control UI controller for the Logbook tab: state, gateway calls, polling.
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export type LogbookStatusPayload = {
|
||||
captureEnabled: boolean;
|
||||
capturePaused: boolean;
|
||||
captureIntervalSeconds: number;
|
||||
analysisIntervalMinutes: number;
|
||||
retentionDays: number;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
lastCaptureAtMs?: number;
|
||||
lastCaptureError?: string;
|
||||
pendingFrames: number;
|
||||
analysisRunning: boolean;
|
||||
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
|
||||
visionModel?: string;
|
||||
visionModelSource: "config" | "media-defaults" | "missing";
|
||||
today: string;
|
||||
todayCards: number;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
|
||||
|
||||
export type LogbookCardPayload = {
|
||||
id: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
appPrimary?: string;
|
||||
appSecondary?: string;
|
||||
distractions: LogbookDistractionPayload[];
|
||||
keyframeId?: number;
|
||||
};
|
||||
|
||||
type LogbookDayStatsPayload = {
|
||||
trackedMs: number;
|
||||
distractionMs: number;
|
||||
categories: Array<{ category: string; ms: number }>;
|
||||
apps: Array<{ domain: string; ms: number }>;
|
||||
};
|
||||
|
||||
type LogbookTimelinePayload = {
|
||||
day: string;
|
||||
cards: LogbookCardPayload[];
|
||||
stats: LogbookDayStatsPayload;
|
||||
};
|
||||
|
||||
type LogbookDaysPayload = {
|
||||
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
|
||||
};
|
||||
|
||||
export type LogbookUiState = {
|
||||
day: string;
|
||||
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
|
||||
dayPinned: boolean;
|
||||
status: LogbookStatusPayload | null;
|
||||
days: LogbookDaysPayload["days"];
|
||||
timeline: LogbookTimelinePayload | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
expandedCardIds: Set<number>;
|
||||
framePreviews: Map<number, string>;
|
||||
frameLoads: Set<number>;
|
||||
framePreviewFailed: Set<number>;
|
||||
standup: { day: string; text: string; updatedMs: number } | null;
|
||||
standupLoading: boolean;
|
||||
askQuestion: string;
|
||||
askAnswer: string | null;
|
||||
askLoading: boolean;
|
||||
actionPending: boolean;
|
||||
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
|
||||
pollClient: GatewayBrowserClient | null;
|
||||
requestUpdate: (() => void) | null;
|
||||
};
|
||||
import type {
|
||||
LogbookBackgroundRefresh,
|
||||
LogbookDaysPayload,
|
||||
LogbookStatusPayload,
|
||||
LogbookTimelinePayload,
|
||||
LogbookUiState,
|
||||
} from "./logbook-types.ts";
|
||||
|
||||
const FRAME_PREVIEW_CACHE_LIMIT = 48;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
@@ -117,6 +46,13 @@ export function getLogbookState(host: object): LogbookUiState {
|
||||
askAnswer: null,
|
||||
askLoading: false,
|
||||
actionPending: false,
|
||||
actionGeneration: 0,
|
||||
actionPendingGeneration: null,
|
||||
loadGeneration: 0,
|
||||
loadingGeneration: null,
|
||||
lifecycleGeneration: 0,
|
||||
backgroundRefresh: null,
|
||||
backgroundRefreshQueued: null,
|
||||
pollTimer: null,
|
||||
pollClient: null,
|
||||
requestUpdate: null,
|
||||
@@ -154,7 +90,10 @@ export async function loadLogbook(
|
||||
} else if (opts?.today) {
|
||||
state.dayPinned = false;
|
||||
}
|
||||
const generation = ++state.loadGeneration;
|
||||
const requestedDay = state.day;
|
||||
if (!opts?.silent) {
|
||||
state.loadingGeneration = generation;
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
notify(state);
|
||||
@@ -163,8 +102,11 @@ export async function loadLogbook(
|
||||
const [status, days, timeline] = await Promise.all([
|
||||
client.request<LogbookStatusPayload>("logbook.status", {}),
|
||||
client.request<LogbookDaysPayload>("logbook.days", {}),
|
||||
client.request<LogbookTimelinePayload>("logbook.timeline", { day: state.day }),
|
||||
client.request<LogbookTimelinePayload>("logbook.timeline", { day: requestedDay }),
|
||||
]);
|
||||
if (generation !== state.loadGeneration || state.day !== requestedDay) {
|
||||
return;
|
||||
}
|
||||
state.status = status;
|
||||
state.days = days.days;
|
||||
// Unpinned views follow the gateway's day: the browser clock can sit in
|
||||
@@ -172,21 +114,111 @@ export async function loadLogbook(
|
||||
// advance the default view.
|
||||
if (!state.dayPinned && status.today !== state.day) {
|
||||
resetDayView(state, status.today);
|
||||
state.timeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
|
||||
const todayTimeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
|
||||
day: status.today,
|
||||
});
|
||||
if (generation !== state.loadGeneration || state.day !== status.today) {
|
||||
return;
|
||||
}
|
||||
state.timeline = todayTimeline;
|
||||
} else {
|
||||
state.timeline = timeline;
|
||||
}
|
||||
state.error = null;
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
if (generation === state.loadGeneration) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
state.loading = false;
|
||||
notify(state);
|
||||
let shouldNotify = generation === state.loadGeneration;
|
||||
if (state.loadingGeneration === generation) {
|
||||
state.loadingGeneration = null;
|
||||
state.loading = false;
|
||||
shouldNotify = true;
|
||||
}
|
||||
if (shouldNotify) {
|
||||
notify(state);
|
||||
}
|
||||
drainQueuedLogbookRefresh(state);
|
||||
}
|
||||
}
|
||||
|
||||
function retireLogbookLoads(state: LogbookUiState): void {
|
||||
// A stopped or rebound view must not accept results from its retired client,
|
||||
// and an abandoned background request must not block the next polling epoch.
|
||||
state.loadGeneration += 1;
|
||||
state.lifecycleGeneration += 1;
|
||||
state.actionGeneration += 1;
|
||||
state.actionPendingGeneration = null;
|
||||
state.actionPending = false;
|
||||
state.loadingGeneration = null;
|
||||
state.loading = false;
|
||||
state.backgroundRefresh = null;
|
||||
state.backgroundRefreshQueued = null;
|
||||
}
|
||||
|
||||
function isLogbookActionCurrent(
|
||||
state: LogbookUiState,
|
||||
actionGeneration: number,
|
||||
refresh: LogbookBackgroundRefresh,
|
||||
): boolean {
|
||||
return actionGeneration === state.actionGeneration && isLogbookRefreshCurrent(state, refresh);
|
||||
}
|
||||
|
||||
function isLogbookRefreshCurrent(
|
||||
state: LogbookUiState,
|
||||
refresh: LogbookBackgroundRefresh,
|
||||
): boolean {
|
||||
return (
|
||||
refresh.lifecycleGeneration === state.lifecycleGeneration &&
|
||||
(state.pollClient === null || state.pollClient === refresh.client)
|
||||
);
|
||||
}
|
||||
|
||||
function drainQueuedLogbookRefresh(state: LogbookUiState): void {
|
||||
if (state.loading || state.backgroundRefresh) {
|
||||
return;
|
||||
}
|
||||
const queued = state.backgroundRefreshQueued;
|
||||
state.backgroundRefreshQueued = null;
|
||||
if (!queued || !isLogbookRefreshCurrent(state, queued)) {
|
||||
return;
|
||||
}
|
||||
void refreshLogbookSilently(state, queued.client, {
|
||||
lifecycleGeneration: queued.lifecycleGeneration,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
function refreshLogbookSilently(
|
||||
state: LogbookUiState,
|
||||
client: GatewayBrowserClient,
|
||||
opts?: { lifecycleGeneration?: number; required?: boolean },
|
||||
): Promise<void> {
|
||||
const refreshRequest = {
|
||||
client,
|
||||
lifecycleGeneration: opts?.lifecycleGeneration ?? state.lifecycleGeneration,
|
||||
};
|
||||
if (!isLogbookRefreshCurrent(state, refreshRequest)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (state.loading || state.backgroundRefresh) {
|
||||
if (opts?.required) {
|
||||
state.backgroundRefreshQueued = refreshRequest;
|
||||
}
|
||||
return state.backgroundRefresh ?? Promise.resolve();
|
||||
}
|
||||
const refresh = loadLogbook(state, client, { silent: true });
|
||||
state.backgroundRefresh = refresh;
|
||||
void refresh.finally(() => {
|
||||
if (state.backgroundRefresh === refresh) {
|
||||
state.backgroundRefresh = null;
|
||||
}
|
||||
drainQueuedLogbookRefresh(state);
|
||||
});
|
||||
return refresh;
|
||||
}
|
||||
|
||||
/** Stops background polling; wired into tab-switch and disconnect cleanup. */
|
||||
export function stopLogbookPolling(host: object): void {
|
||||
const state = logbookStates.get(host);
|
||||
@@ -196,6 +228,8 @@ export function stopLogbookPolling(host: object): void {
|
||||
}
|
||||
if (state) {
|
||||
state.pollClient = null;
|
||||
// PluginPage replaces the host before calling stop. Let detached-host loads
|
||||
// settle; host identity keeps their results out of the replacement view.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +244,7 @@ export function configureLogbookPolling(
|
||||
state.pollTimer = null;
|
||||
}
|
||||
state.pollClient = null;
|
||||
retireLogbookLoads(state);
|
||||
return;
|
||||
}
|
||||
if (state.pollTimer && state.pollClient === client) {
|
||||
@@ -218,10 +253,12 @@ export function configureLogbookPolling(
|
||||
if (state.pollTimer) {
|
||||
clearInterval(state.pollTimer);
|
||||
}
|
||||
retireLogbookLoads(state);
|
||||
state.pollClient = client;
|
||||
state.pollTimer = setInterval(() => {
|
||||
// Silent refresh keeps the timeline current while analysis batches land.
|
||||
void loadLogbook(state, client, { silent: true });
|
||||
// All background refresh sources share one owner so slow gateway responses
|
||||
// cannot stack another status/days/timeline batch on every interval.
|
||||
void refreshLogbookSilently(state, client);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -268,15 +305,26 @@ export async function setLogbookCapturePaused(
|
||||
if (!client || state.actionPending) {
|
||||
return;
|
||||
}
|
||||
const actionGeneration = ++state.actionGeneration;
|
||||
state.actionPendingGeneration = actionGeneration;
|
||||
state.actionPending = true;
|
||||
notify(state);
|
||||
const refresh = { client, lifecycleGeneration: state.lifecycleGeneration };
|
||||
try {
|
||||
state.status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
|
||||
const status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
state.status = status;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
if (state.actionPendingGeneration === actionGeneration) {
|
||||
state.actionPendingGeneration = null;
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,22 +335,39 @@ export async function runLogbookAnalysisNow(
|
||||
if (!client || state.actionPending) {
|
||||
return;
|
||||
}
|
||||
const actionGeneration = ++state.actionGeneration;
|
||||
state.actionPendingGeneration = actionGeneration;
|
||||
state.actionPending = true;
|
||||
notify(state);
|
||||
const refresh = { client, lifecycleGeneration: state.lifecycleGeneration };
|
||||
try {
|
||||
const result = await client.request<{ started: boolean; reason?: string }>(
|
||||
"logbook.analyze.now",
|
||||
{},
|
||||
);
|
||||
if (!result.started && result.reason) {
|
||||
if (
|
||||
isLogbookActionCurrent(state, actionGeneration, refresh) &&
|
||||
!result.started &&
|
||||
result.reason
|
||||
) {
|
||||
state.error = result.reason;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
void loadLogbook(state, client, { silent: true });
|
||||
if (state.actionPendingGeneration === actionGeneration) {
|
||||
state.actionPendingGeneration = null;
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
}
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
void refreshLogbookSilently(state, client, {
|
||||
lifecycleGeneration: refresh.lifecycleGeneration,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export type LogbookStatusPayload = {
|
||||
captureEnabled: boolean;
|
||||
capturePaused: boolean;
|
||||
captureIntervalSeconds: number;
|
||||
analysisIntervalMinutes: number;
|
||||
retentionDays: number;
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
lastCaptureAtMs?: number;
|
||||
lastCaptureError?: string;
|
||||
pendingFrames: number;
|
||||
analysisRunning: boolean;
|
||||
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
|
||||
visionModel?: string;
|
||||
visionModelSource: "config" | "media-defaults" | "missing";
|
||||
today: string;
|
||||
todayCards: number;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
|
||||
|
||||
export type LogbookCardPayload = {
|
||||
id: number;
|
||||
day: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
category: string;
|
||||
appPrimary?: string;
|
||||
appSecondary?: string;
|
||||
distractions: LogbookDistractionPayload[];
|
||||
keyframeId?: number;
|
||||
};
|
||||
|
||||
type LogbookDayStatsPayload = {
|
||||
trackedMs: number;
|
||||
distractionMs: number;
|
||||
categories: Array<{ category: string; ms: number }>;
|
||||
apps: Array<{ domain: string; ms: number }>;
|
||||
};
|
||||
|
||||
export type LogbookTimelinePayload = {
|
||||
day: string;
|
||||
cards: LogbookCardPayload[];
|
||||
stats: LogbookDayStatsPayload;
|
||||
};
|
||||
|
||||
export type LogbookDaysPayload = {
|
||||
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
|
||||
};
|
||||
|
||||
export type LogbookBackgroundRefresh = {
|
||||
client: GatewayBrowserClient;
|
||||
lifecycleGeneration: number;
|
||||
};
|
||||
|
||||
export type LogbookUiState = {
|
||||
day: string;
|
||||
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
|
||||
dayPinned: boolean;
|
||||
status: LogbookStatusPayload | null;
|
||||
days: LogbookDaysPayload["days"];
|
||||
timeline: LogbookTimelinePayload | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
expandedCardIds: Set<number>;
|
||||
framePreviews: Map<number, string>;
|
||||
frameLoads: Set<number>;
|
||||
framePreviewFailed: Set<number>;
|
||||
standup: { day: string; text: string; updatedMs: number } | null;
|
||||
standupLoading: boolean;
|
||||
askQuestion: string;
|
||||
askAnswer: string | null;
|
||||
askLoading: boolean;
|
||||
actionPending: boolean;
|
||||
actionGeneration: number;
|
||||
actionPendingGeneration: number | null;
|
||||
// Every load advances result ownership; foreground loading state has its own
|
||||
// owner so a superseded request cannot clear a newer spinner.
|
||||
loadGeneration: number;
|
||||
loadingGeneration: number | null;
|
||||
lifecycleGeneration: number;
|
||||
backgroundRefresh: Promise<void> | null;
|
||||
backgroundRefreshQueued: LogbookBackgroundRefresh | null;
|
||||
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
|
||||
pollClient: GatewayBrowserClient | null;
|
||||
requestUpdate: (() => void) | null;
|
||||
};
|
||||
@@ -18,10 +18,8 @@ import {
|
||||
runLogbookAnalysisNow,
|
||||
setLogbookCapturePaused,
|
||||
shiftDay,
|
||||
type LogbookCardPayload,
|
||||
type LogbookStatusPayload,
|
||||
type LogbookUiState,
|
||||
} from "./logbook-controller.ts";
|
||||
import type { LogbookCardPayload, LogbookStatusPayload, LogbookUiState } from "./logbook-types.ts";
|
||||
|
||||
type LogbookProps = {
|
||||
host: object;
|
||||
|
||||
Reference in New Issue
Block a user