mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
refactor: simplify recent async ownership fixes (#108164)
* fix(device-pair): preserve concurrent notify state Co-authored-by: Alix-007 <li.long15@xydigit.com> * refactor(ui): simplify logbook refresh ownership Co-authored-by: Alix-007 <li.long15@xydigit.com> * fix(qa-lab): bound multipass retry window Co-authored-by: Alix-007 <li.long15@xydigit.com> * chore: keep release notes in PR body Co-authored-by: Alix-007 <li.long15@xydigit.com> * fix(ui): keep logbook controller state private Co-authored-by: Alix-007 <li.long15@xydigit.com> * fix(ui): retire logbook client epochs Co-authored-by: Alix-007 <li.long15@xydigit.com> * fix(ui): retire inactive logbook ownership Co-authored-by: Alix-007 <li.long15@xydigit.com> * fix(device-pair): require atomic notify state Co-authored-by: Alix-007 <li.long15@xydigit.com> --------- Co-authored-by: Alix-007 <li.long15@xydigit.com>
This commit is contained in:
co-authored by
Alix-007
parent
c57d1af958
commit
84c7941f1e
@@ -703,13 +703,14 @@ two-party event loops that do not go through the shared inbound reply runner.
|
||||
await store.register("key-1", { value: "hello" });
|
||||
const claimed = await store.registerIfAbsent("dedupe-key", { value: "first" });
|
||||
const value = await store.lookup("key-1");
|
||||
await store.deleteIf?.("key-1", (current) => current.value === "hello");
|
||||
await store.consume("key-1");
|
||||
await store.clear();
|
||||
```
|
||||
|
||||
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Use `registerIfAbsent(...)` for atomic dedupe claims: it returns `true` when the key was missing or expired and registered, or `false` when a live value already exists without overwriting its value, creation time, or TTL. Limits: `maxEntries` per namespace, 50,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry. By default, a write at either row limit sheds the oldest live rows from the namespace being written; sibling namespaces are not evicted for that write, and the write still fails if the namespace cannot free enough rows. Set `overflowPolicy: "reject-new"` for durable ownership records that must never be evicted: new keys fail at either limit, while existing keys remain updateable.
|
||||
Keyed stores survive restarts and are isolated by the runtime-bound plugin id. Use `registerIfAbsent(...)` for atomic dedupe claims: it returns `true` when the key was missing or expired and registered, or `false` when a live value already exists without overwriting its value, creation time, or TTL. Use `deleteIf(...)` when cleanup must remove only the value previously observed; its synchronous predicate and deletion run in one SQLite transaction. Limits: `maxEntries` per namespace, 50,000 live rows per plugin, JSON values under 64KB, and optional TTL expiry. By default, a write at either row limit sheds the oldest live rows from the namespace being written; sibling namespaces are not evicted for that write, and the write still fails if the namespace cannot free enough rows. Set `overflowPolicy: "reject-new"` for durable ownership records that must never be evicted: new keys fail at either limit, while existing keys remain updateable.
|
||||
|
||||
`openSyncKeyedStore<T>(...)` returns the same store shape with synchronous methods (`register`, `registerIfAbsent`, `lookup`, `consume`, `clear` all return values directly instead of promises) for callers that cannot await.
|
||||
`openSyncKeyedStore<T>(...)` returns the same store shape with synchronous methods (`register`, `registerIfAbsent`, `deleteIf`, `lookup`, `consume`, `clear` all return values directly instead of promises) for callers that cannot await.
|
||||
|
||||
`openChannelIngressQueue<TPayload>(...)` opens a persisted ingress queue scoped to the calling plugin, for buffering inbound events that need at-least-once processing across restarts. When stale-claim recovery uses `shouldRecover`, also provide `shouldRecoverCorrupt` if corrupt claimed payloads should be quarantined: its payload-independent claim identity lets the plugin preserve live owner and lane policy before the queue tombstones the row.
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export type NotifySubscription = {
|
||||
messageThreadId?: string | number;
|
||||
mode: "persistent" | "once";
|
||||
addedAtMs: number;
|
||||
/** Unique for new arms; absent only on subscriptions imported from legacy state. */
|
||||
armId?: string;
|
||||
};
|
||||
|
||||
export type NotifySeenRequest = {
|
||||
|
||||
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { listDevicePairing as listDevicePairingFn } from "openclaw/plugin-sdk/device-bootstrap";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
@@ -67,12 +70,15 @@ describe("device-pair notify persistence", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function createApi(sendText?: ReturnType<typeof vi.fn>) {
|
||||
function createApi(
|
||||
sendText?: ReturnType<typeof vi.fn>,
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) => PluginStateKeyedStore<T> = openStore,
|
||||
) {
|
||||
return createTestPluginApi({
|
||||
runtime: {
|
||||
state: {
|
||||
resolveStateDir: () => stateDir,
|
||||
openKeyedStore: openStore,
|
||||
openKeyedStore,
|
||||
},
|
||||
channel: {
|
||||
outbound: {
|
||||
@@ -194,6 +200,219 @@ describe("device-pair notify persistence", () => {
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("preserves subscriber changes made while a notification is in flight", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const firstSend = createDeferred<unknown>();
|
||||
const sendText = vi.fn(() => firstSend.promise);
|
||||
const api = createApi(sendText);
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "old-chat" },
|
||||
action: "on",
|
||||
});
|
||||
listDevicePairingMock.mockResolvedValueOnce({ pending: [], paired: [] }).mockResolvedValue({
|
||||
pending: [
|
||||
{
|
||||
requestId: "request-1",
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
ts: 2_000,
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
const service = createPairingNotifierService(api);
|
||||
|
||||
await service.start({} as never);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "old-chat" },
|
||||
action: "off",
|
||||
});
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "new-chat" },
|
||||
action: "on",
|
||||
});
|
||||
firstSend.resolve({ channel: "telegram", to: "old-chat" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(openSubscriberStore().entries()).resolves.toMatchObject([
|
||||
{
|
||||
key: notifySubscriberStoreKey({ to: "new-chat" }),
|
||||
value: { to: "new-chat", mode: "persistent" },
|
||||
},
|
||||
]);
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("preserves a one-shot subscription re-armed during its delivery", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const firstSend = createDeferred<unknown>();
|
||||
const sendText = vi.fn(() => firstSend.promise);
|
||||
const api = createApi(sendText);
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "chat-123" },
|
||||
action: "once",
|
||||
});
|
||||
listDevicePairingMock.mockResolvedValueOnce({ pending: [], paired: [] }).mockResolvedValue({
|
||||
pending: [
|
||||
{
|
||||
requestId: "request-1",
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
ts: 2_000,
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
const service = createPairingNotifierService(api);
|
||||
|
||||
await service.start({} as never);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "chat-123" },
|
||||
action: "once",
|
||||
});
|
||||
firstSend.resolve({ channel: "telegram", to: "chat-123" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect(
|
||||
openSubscriberStore().lookup(notifySubscriberStoreKey({ to: "chat-123" })),
|
||||
).resolves.toMatchObject({
|
||||
to: "chat-123",
|
||||
mode: "once",
|
||||
addedAtMs: 11_000,
|
||||
});
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("rejects missing conditional-delete support before one-shot delivery", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const subscriber: NotifySubscription = {
|
||||
to: "chat-123",
|
||||
mode: "once",
|
||||
addedAtMs: 1_000,
|
||||
armId: "arm-1",
|
||||
};
|
||||
await openSubscriberStore().register(notifySubscriberStoreKey(subscriber), subscriber);
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [
|
||||
{
|
||||
requestId: "request-1",
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
ts: 2_000,
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
const sendText = vi.fn(async () => ({ channel: "telegram", to: "chat-123" }));
|
||||
const api = createApi(sendText, <T>(options: OpenKeyedStoreOptions) => {
|
||||
const { deleteIf: _deleteIf, ...store } = openStore<T>(options);
|
||||
return store;
|
||||
});
|
||||
const service = createPairingNotifierService(api);
|
||||
|
||||
await service.start({} as never);
|
||||
|
||||
expect(sendText).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
openSubscriberStore().lookup(notifySubscriberStoreKey(subscriber)),
|
||||
).resolves.toEqual(subscriber);
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("keeps the request boundary at the current millisecond when re-armed", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const sendText = vi.fn(async () => ({ channel: "telegram", to: "chat-123" }));
|
||||
const api = createApi(sendText);
|
||||
const command = {
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "chat-123" },
|
||||
action: "once" as const,
|
||||
};
|
||||
|
||||
await handleNotifyCommand(command);
|
||||
const key = notifySubscriberStoreKey({ to: "chat-123" });
|
||||
const first = await openSubscriberStore().lookup(key);
|
||||
await handleNotifyCommand(command);
|
||||
const second = await openSubscriberStore().lookup(key);
|
||||
|
||||
expect(first).toMatchObject({ addedAtMs: 1_000, armId: expect.any(String) });
|
||||
expect(second).toMatchObject({ addedAtMs: 1_000, armId: expect.any(String) });
|
||||
expect(second?.armId).not.toBe(first?.armId);
|
||||
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [
|
||||
{
|
||||
requestId: "request-same-ms",
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
ts: 1_000,
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
const service = createPairingNotifierService(api);
|
||||
await service.start({} as never);
|
||||
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: expect.stringContaining("ID: request-same-ms") }),
|
||||
);
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("delivers a one-shot subscription to only the first new request", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
const sendText = vi.fn(async () => ({ channel: "telegram", to: "chat-123" }));
|
||||
const api = createApi(sendText);
|
||||
await handleNotifyCommand({
|
||||
api,
|
||||
ctx: { channel: "telegram", senderId: "chat-123" },
|
||||
action: "once",
|
||||
});
|
||||
listDevicePairingMock.mockResolvedValue({
|
||||
pending: [
|
||||
{
|
||||
requestId: "request-1",
|
||||
deviceId: "device-1",
|
||||
publicKey: "public-key-1",
|
||||
ts: 1_001,
|
||||
},
|
||||
{
|
||||
requestId: "request-2",
|
||||
deviceId: "device-2",
|
||||
publicKey: "public-key-2",
|
||||
ts: 1_002,
|
||||
},
|
||||
],
|
||||
paired: [],
|
||||
});
|
||||
const service = createPairingNotifierService(api);
|
||||
|
||||
await service.start({} as never);
|
||||
|
||||
expect(sendText).toHaveBeenCalledTimes(1);
|
||||
expect(sendText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ text: expect.stringContaining("ID: request-1") }),
|
||||
);
|
||||
await expect(openSubscriberStore().entries()).resolves.toStrictEqual([]);
|
||||
await service.stop?.({} as never);
|
||||
});
|
||||
|
||||
it("matches persisted telegram thread ids across number and string roundtrips", async () => {
|
||||
const subscriber: NotifySubscription = {
|
||||
to: "chat-123",
|
||||
|
||||
+110
-131
@@ -1,4 +1,5 @@
|
||||
// Device Pair plugin module implements notify behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { OpenClawPluginService } from "openclaw/plugin-sdk/core";
|
||||
import { listDevicePairing } from "openclaw/plugin-sdk/device-bootstrap";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
@@ -24,11 +25,6 @@ const NOTIFY_POLL_INTERVAL_MS = 10_000;
|
||||
// Keep one module-owned poll so the replacement service cannot race state or delivery.
|
||||
let notifyPollInFlight: Promise<void> | null = null;
|
||||
|
||||
type NotifyStateFile = {
|
||||
subscribers: NotifySubscription[];
|
||||
notifiedRequestIds: Record<string, number>;
|
||||
};
|
||||
|
||||
type PendingPairingRequest = {
|
||||
requestId: string;
|
||||
deviceId: string;
|
||||
@@ -83,13 +79,21 @@ export function formatPendingRequests(pending: PendingPairingRequest[]): string
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function openNotifySubscriberStore(
|
||||
api: OpenClawPluginApi,
|
||||
): PluginStateKeyedStore<NotifySubscription> {
|
||||
return api.runtime.state.openKeyedStore<NotifySubscription>({
|
||||
type NotifySubscriberStore = PluginStateKeyedStore<NotifySubscription> & {
|
||||
deleteIf: NonNullable<PluginStateKeyedStore<NotifySubscription>["deleteIf"]>;
|
||||
};
|
||||
|
||||
function openNotifySubscriberStore(api: OpenClawPluginApi): NotifySubscriberStore {
|
||||
const store = api.runtime.state.openKeyedStore<NotifySubscription>({
|
||||
namespace: DEVICE_PAIR_NOTIFY_SUBSCRIBER_NAMESPACE,
|
||||
maxEntries: DEVICE_PAIR_NOTIFY_SUBSCRIBER_MAX_ENTRIES,
|
||||
});
|
||||
if (!store.deleteIf) {
|
||||
throw new Error(
|
||||
"device-pair notify requires a runtime with atomic plugin state conditional delete support",
|
||||
);
|
||||
}
|
||||
return store as NotifySubscriberStore;
|
||||
}
|
||||
|
||||
function openNotifySeenRequestStore(
|
||||
@@ -102,63 +106,6 @@ function openNotifySeenRequestStore(
|
||||
});
|
||||
}
|
||||
|
||||
async function readNotifyState(api: OpenClawPluginApi): Promise<NotifyStateFile> {
|
||||
const subscriberStore = openNotifySubscriberStore(api);
|
||||
const seenRequestStore = openNotifySeenRequestStore(api);
|
||||
const [subscriberEntries, seenRequestEntries] = await Promise.all([
|
||||
subscriberStore.entries(),
|
||||
seenRequestStore.entries(),
|
||||
]);
|
||||
|
||||
const subscribers = subscriberEntries
|
||||
.map((entry) => entry.value)
|
||||
.toSorted((a, b) => a.addedAtMs - b.addedAtMs);
|
||||
const notifiedRequestIds: Record<string, number> = {};
|
||||
for (const entry of seenRequestEntries) {
|
||||
const requestId = normalizeOptionalString(entry.value.requestId);
|
||||
const notifiedAtMs = entry.value.notifiedAtMs;
|
||||
if (!requestId || !Number.isFinite(notifiedAtMs) || notifiedAtMs <= 0) {
|
||||
continue;
|
||||
}
|
||||
notifiedRequestIds[requestId] = Math.trunc(notifiedAtMs);
|
||||
}
|
||||
|
||||
return { subscribers, notifiedRequestIds };
|
||||
}
|
||||
|
||||
async function writeNotifyState(api: OpenClawPluginApi, state: NotifyStateFile): Promise<void> {
|
||||
const subscriberStore = openNotifySubscriberStore(api);
|
||||
const nextSubscribers = new Map(
|
||||
state.subscribers.map((subscriber) => [notifySubscriberStoreKey(subscriber), subscriber]),
|
||||
);
|
||||
for (const entry of await subscriberStore.entries()) {
|
||||
if (!nextSubscribers.has(entry.key)) {
|
||||
await subscriberStore.delete(entry.key);
|
||||
}
|
||||
}
|
||||
for (const [key, subscriber] of nextSubscribers) {
|
||||
await subscriberStore.register(key, subscriber);
|
||||
}
|
||||
|
||||
const seenRequestStore = openNotifySeenRequestStore(api);
|
||||
const nextSeenRequests = new Map(
|
||||
Object.entries(state.notifiedRequestIds).map(([requestId, notifiedAtMs]) => [
|
||||
notifyRequestStoreKey(requestId),
|
||||
{ requestId, notifiedAtMs },
|
||||
]),
|
||||
);
|
||||
for (const entry of await seenRequestStore.entries()) {
|
||||
if (!nextSeenRequests.has(entry.key)) {
|
||||
await seenRequestStore.delete(entry.key);
|
||||
}
|
||||
}
|
||||
for (const [key, value] of nextSeenRequests) {
|
||||
await seenRequestStore.register(key, value, {
|
||||
ttlMs: DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type NotifyTarget = {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
@@ -187,30 +134,51 @@ function resolveNotifyTarget(ctx: {
|
||||
};
|
||||
}
|
||||
|
||||
function upsertNotifySubscriber(
|
||||
subscribers: NotifySubscription[],
|
||||
function nextNotifySubscription(
|
||||
target: NotifyTarget,
|
||||
mode: NotifySubscription["mode"],
|
||||
): boolean {
|
||||
const key = notifySubscriberKey(target);
|
||||
const index = subscribers.findIndex((entry) => notifySubscriberKey(entry) === key);
|
||||
const next: NotifySubscription = {
|
||||
): NotifySubscription {
|
||||
return {
|
||||
...target,
|
||||
mode,
|
||||
addedAtMs: Date.now(),
|
||||
armId: randomUUID(),
|
||||
};
|
||||
if (index === -1) {
|
||||
subscribers.push(next);
|
||||
return true;
|
||||
}
|
||||
const existing = subscribers[index];
|
||||
if (existing?.mode === mode) {
|
||||
}
|
||||
|
||||
async function registerNotifySubscriber(params: {
|
||||
api: OpenClawPluginApi;
|
||||
target: NotifyTarget;
|
||||
mode: NotifySubscription["mode"];
|
||||
refresh: boolean;
|
||||
}): Promise<boolean> {
|
||||
const store = openNotifySubscriberStore(params.api);
|
||||
const key = notifySubscriberStoreKey(params.target);
|
||||
const current = await store.lookup(key);
|
||||
if (!params.refresh && current?.mode === params.mode) {
|
||||
return false;
|
||||
}
|
||||
subscribers[index] = next;
|
||||
await store.register(key, nextNotifySubscription(params.target, params.mode));
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSameNotifySubscription(
|
||||
current: NotifySubscription,
|
||||
expected: NotifySubscription,
|
||||
): boolean {
|
||||
if (expected.armId) {
|
||||
return current.armId === expected.armId;
|
||||
}
|
||||
// Doctor-imported legacy subscriptions have no arm id. Their original
|
||||
// fields remain the exact generation until a new arm replaces the row.
|
||||
return (
|
||||
current.armId === undefined &&
|
||||
current.mode === expected.mode &&
|
||||
current.addedAtMs === expected.addedAtMs &&
|
||||
notifySubscriberKey(current) === notifySubscriberKey(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function buildPairingRequestNotificationText(request: PendingPairingRequest): string {
|
||||
const label = normalizeOptionalString(request.displayName) || request.deviceId;
|
||||
const platform = normalizeOptionalString(request.platform);
|
||||
@@ -289,30 +257,49 @@ async function notifySubscriber(params: {
|
||||
}
|
||||
|
||||
async function notifyPendingPairingRequests(params: { api: OpenClawPluginApi }): Promise<void> {
|
||||
const state = await readNotifyState(params.api);
|
||||
const pairing = await listDevicePairing();
|
||||
const subscriberStore = openNotifySubscriberStore(params.api);
|
||||
const seenRequestStore = openNotifySeenRequestStore(params.api);
|
||||
const [subscriberEntries, seenRequestEntries, pairing] = await Promise.all([
|
||||
subscriberStore.entries(),
|
||||
seenRequestStore.entries(),
|
||||
listDevicePairing(),
|
||||
]);
|
||||
const subscribers = subscriberEntries.toSorted((a, b) => a.value.addedAtMs - b.value.addedAtMs);
|
||||
const pending: PendingPairingRequest[] = pairing.pending;
|
||||
const now = Date.now();
|
||||
const pendingIds = new Set(pending.map((entry) => entry.requestId));
|
||||
let changed = false;
|
||||
const notifiedRequestIds = new Set<string>();
|
||||
|
||||
for (const [requestId, ts] of Object.entries(state.notifiedRequestIds)) {
|
||||
if (!pendingIds.has(requestId) || now - ts > DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS) {
|
||||
delete state.notifiedRequestIds[requestId];
|
||||
changed = true;
|
||||
for (const entry of seenRequestEntries) {
|
||||
const requestId = normalizeOptionalString(entry.value.requestId);
|
||||
const notifiedAtMs = entry.value.notifiedAtMs;
|
||||
if (
|
||||
!requestId ||
|
||||
!Number.isFinite(notifiedAtMs) ||
|
||||
notifiedAtMs <= 0 ||
|
||||
!pendingIds.has(requestId) ||
|
||||
now - notifiedAtMs > DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS
|
||||
) {
|
||||
await seenRequestStore.delete(entry.key);
|
||||
continue;
|
||||
}
|
||||
notifiedRequestIds.add(requestId);
|
||||
}
|
||||
|
||||
if (state.subscribers.length > 0) {
|
||||
const oneShotDelivered = new Set<string>();
|
||||
if (subscribers.length > 0) {
|
||||
const deliveredOneShots = new Set<string>();
|
||||
for (const request of pending) {
|
||||
if (state.notifiedRequestIds[request.requestId]) {
|
||||
if (notifiedRequestIds.has(request.requestId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = buildPairingRequestNotificationText(request);
|
||||
let delivered = false;
|
||||
for (const subscriber of state.subscribers) {
|
||||
for (const entry of subscribers) {
|
||||
const subscriber = entry.value;
|
||||
if (subscriber.mode === "once" && deliveredOneShots.has(entry.key)) {
|
||||
continue;
|
||||
}
|
||||
if (!shouldNotifySubscriberForRequest(subscriber, request)) {
|
||||
continue;
|
||||
}
|
||||
@@ -323,28 +310,24 @@ async function notifyPendingPairingRequests(params: { api: OpenClawPluginApi }):
|
||||
});
|
||||
delivered = delivered || sent;
|
||||
if (sent && subscriber.mode === "once") {
|
||||
oneShotDelivered.add(notifySubscriberKey(subscriber));
|
||||
deliveredOneShots.add(entry.key);
|
||||
// Delivery is fallible and uncancellable. Delete only the exact arm
|
||||
// that was sent so an overlapping re-arm remains subscribed.
|
||||
await subscriberStore.deleteIf(entry.key, (current) =>
|
||||
isSameNotifySubscription(current, subscriber),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (delivered) {
|
||||
state.notifiedRequestIds[request.requestId] = now;
|
||||
changed = true;
|
||||
await seenRequestStore.register(
|
||||
notifyRequestStoreKey(request.requestId),
|
||||
{ requestId: request.requestId, notifiedAtMs: now },
|
||||
{ ttlMs: DEVICE_PAIR_NOTIFY_MAX_SEEN_AGE_MS },
|
||||
);
|
||||
notifiedRequestIds.add(request.requestId);
|
||||
}
|
||||
}
|
||||
if (oneShotDelivered.size > 0) {
|
||||
const initialCount = state.subscribers.length;
|
||||
state.subscribers = state.subscribers.filter(
|
||||
(subscriber) => !oneShotDelivered.has(notifySubscriberKey(subscriber)),
|
||||
);
|
||||
if (state.subscribers.length !== initialCount) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeNotifyState(params.api, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,16 +362,12 @@ export async function armPairNotifyOnce(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = await readNotifyState(params.api);
|
||||
let changed = false;
|
||||
|
||||
if (upsertNotifySubscriber(state.subscribers, target, "once")) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
await writeNotifyState(params.api, state);
|
||||
}
|
||||
await registerNotifySubscriber({
|
||||
api: params.api,
|
||||
target,
|
||||
mode: "once",
|
||||
refresh: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -413,14 +392,16 @@ export async function handleNotifyCommand(params: {
|
||||
return { text: "Could not resolve Telegram target for this chat." };
|
||||
}
|
||||
|
||||
const state = await readNotifyState(params.api);
|
||||
const targetKey = notifySubscriberKey(target);
|
||||
const current = state.subscribers.find((entry) => notifySubscriberKey(entry) === targetKey);
|
||||
const subscriberStore = openNotifySubscriberStore(params.api);
|
||||
const targetStoreKey = notifySubscriberStoreKey(target);
|
||||
|
||||
if (params.action === "on" || params.action === "enable") {
|
||||
if (upsertNotifySubscriber(state.subscribers, target, "persistent")) {
|
||||
await writeNotifyState(params.api, state);
|
||||
}
|
||||
await registerNotifySubscriber({
|
||||
api: params.api,
|
||||
target,
|
||||
mode: "persistent",
|
||||
refresh: false,
|
||||
});
|
||||
return {
|
||||
text:
|
||||
"✅ Pair request notifications enabled for this Telegram chat.\n" +
|
||||
@@ -429,13 +410,7 @@ export async function handleNotifyCommand(params: {
|
||||
}
|
||||
|
||||
if (params.action === "off" || params.action === "disable") {
|
||||
const currentIndex = state.subscribers.findIndex(
|
||||
(entry) => notifySubscriberKey(entry) === targetKey,
|
||||
);
|
||||
if (currentIndex !== -1) {
|
||||
state.subscribers.splice(currentIndex, 1);
|
||||
await writeNotifyState(params.api, state);
|
||||
}
|
||||
await subscriberStore.delete(targetStoreKey);
|
||||
return { text: "✅ Pair request notifications disabled for this Telegram chat." };
|
||||
}
|
||||
|
||||
@@ -452,14 +427,18 @@ export async function handleNotifyCommand(params: {
|
||||
}
|
||||
|
||||
if (params.action === "status" || params.action === "") {
|
||||
const pending = await listDevicePairing();
|
||||
const [current, subscribers, pending] = await Promise.all([
|
||||
subscriberStore.lookup(targetStoreKey),
|
||||
subscriberStore.entries(),
|
||||
listDevicePairing(),
|
||||
]);
|
||||
const enabled = Boolean(current);
|
||||
const mode = current?.mode ?? "off";
|
||||
return {
|
||||
text: [
|
||||
`Pair request notifications: ${enabled ? "enabled" : "disabled"} for this chat.`,
|
||||
`Mode: ${mode}`,
|
||||
`Subscribers: ${state.subscribers.length}`,
|
||||
`Subscribers: ${subscribers.length}`,
|
||||
`Pending requests: ${pending.pending.length}`,
|
||||
"",
|
||||
"Use /pair notify on|off|once",
|
||||
|
||||
@@ -142,10 +142,10 @@ describe("qa multipass runtime", () => {
|
||||
expect(script).toContain("pnpm build");
|
||||
expect(script).toContain("corepack prepare 'pnpm@");
|
||||
expect(script).toContain(
|
||||
'curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 "${base_url}/SHASUMS256.txt" -o "${node_tmp_dir}/SHASUMS256.txt"',
|
||||
'curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 --retry-max-time 120 "${base_url}/SHASUMS256.txt" -o "${node_tmp_dir}/SHASUMS256.txt"',
|
||||
);
|
||||
expect(script).toContain(
|
||||
'curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 "${base_url}/${tarball_name}" -o "${node_tmp_dir}/${tarball_name}"',
|
||||
'curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 --retry-max-time 120 "${base_url}/${tarball_name}" -o "${node_tmp_dir}/${tarball_name}"',
|
||||
);
|
||||
expect(script).toContain("'pnpm' 'openclaw' 'qa' 'suite' '--transport' 'qa-channel'");
|
||||
expect(script).toContain("'--provider-mode' 'live-frontier'");
|
||||
|
||||
@@ -410,10 +410,10 @@ function renderQaMultipassGuestScript(
|
||||
' node_tmp_dir="$(mktemp -d)"',
|
||||
" trap 'rm -rf \"${node_tmp_dir}\"' RETURN",
|
||||
' base_url="https://nodejs.org/dist/latest-v22.x"',
|
||||
' curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 "${base_url}/SHASUMS256.txt" -o "${node_tmp_dir}/SHASUMS256.txt" >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
' curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 --retry-max-time 120 "${base_url}/SHASUMS256.txt" -o "${node_tmp_dir}/SHASUMS256.txt" >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
' tarball_name="$(awk \'/linux-\'"${node_arch}"\'\\.tar\\.xz$/ { print $2; exit }\' "${node_tmp_dir}/SHASUMS256.txt")"',
|
||||
' [ -n "${tarball_name}" ] || { echo "unable to resolve node tarball for ${node_arch}" >&2; return 1; }',
|
||||
' curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 "${base_url}/${tarball_name}" -o "${node_tmp_dir}/${tarball_name}" >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
' curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 --retry-max-time 120 "${base_url}/${tarball_name}" -o "${node_tmp_dir}/${tarball_name}" >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
' (cd "${node_tmp_dir}" && grep " ${tarball_name}$" SHASUMS256.txt | sha256sum -c -) >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
' extract_dir="${tarball_name%.tar.xz}"',
|
||||
' sudo mkdir -p /usr/local/lib/nodejs >>"$BOOTSTRAP_LOG" 2>&1',
|
||||
|
||||
@@ -792,6 +792,40 @@ export function pluginStateDelete(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginStateDeleteIf(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
predicate: (current: unknown) => boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): boolean {
|
||||
try {
|
||||
return runWriteTransaction(
|
||||
"delete",
|
||||
({ db }) => {
|
||||
const row = selectPluginStateEntry(db, {
|
||||
pluginId: params.pluginId,
|
||||
namespace: params.namespace,
|
||||
key: params.key,
|
||||
now: Date.now(),
|
||||
});
|
||||
if (!row || !params.predicate(parseStoredJson(row.value_json, "delete"))) {
|
||||
return false;
|
||||
}
|
||||
return deletePluginStateEntry(db, params) > 0;
|
||||
},
|
||||
envOptions(params.env),
|
||||
);
|
||||
} catch (error) {
|
||||
throw wrapPluginStateError(
|
||||
error,
|
||||
"delete",
|
||||
"PLUGIN_STATE_WRITE_FAILED",
|
||||
"Failed to conditionally delete plugin state entry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pluginStateEntries(params: {
|
||||
pluginId: string;
|
||||
namespace: string;
|
||||
|
||||
@@ -253,6 +253,24 @@ describe("plugin state keyed store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes an entry only when the current value matches", async () => {
|
||||
await withPluginStateTestState(async () => {
|
||||
const store = createPluginStateKeyedStore<{ version: number }>("device-pair", {
|
||||
namespace: "notify-subscribers",
|
||||
maxEntries: 10,
|
||||
});
|
||||
await store.register("chat", { version: 1 });
|
||||
if (!store.deleteIf) {
|
||||
throw new Error("plugin state conditional delete unavailable");
|
||||
}
|
||||
|
||||
await expect(store.deleteIf("chat", (current) => current.version === 2)).resolves.toBe(false);
|
||||
await expect(store.lookup("chat")).resolves.toEqual({ version: 1 });
|
||||
await expect(store.deleteIf("chat", (current) => current.version === 1)).resolves.toBe(true);
|
||||
await expect(store.lookup("chat")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("registerIfAbsent keeps plugin and namespace claims isolated", async () => {
|
||||
await withPluginStateTestState(async () => {
|
||||
const discordA = createPluginStateKeyedStore<{ owner: string }>("discord", {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
pluginStateClear,
|
||||
pluginStateConsume,
|
||||
pluginStateDelete,
|
||||
pluginStateDeleteIf,
|
||||
pluginStateEntries,
|
||||
pluginStateLookup,
|
||||
pluginStateRegister,
|
||||
@@ -320,6 +321,16 @@ function createKeyedStoreForPluginId<T>(
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
},
|
||||
async deleteIf(key, predicate) {
|
||||
const normalizedKey = validateKey(key, "delete");
|
||||
return pluginStateDeleteIf({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: normalizedKey,
|
||||
predicate: (current) => predicate(current as T),
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
},
|
||||
async lookup(key) {
|
||||
const normalizedKey = validateKey(key, "lookup");
|
||||
return pluginStateLookup({
|
||||
@@ -420,6 +431,16 @@ function createSyncKeyedStoreForPluginId<T>(
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
},
|
||||
deleteIf(key, predicate) {
|
||||
const normalizedKey = validateKey(key, "delete");
|
||||
return pluginStateDeleteIf({
|
||||
pluginId,
|
||||
namespace,
|
||||
key: normalizedKey,
|
||||
predicate: (current) => predicate(current as T),
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
},
|
||||
lookup(key) {
|
||||
const normalizedKey = validateKey(key, "lookup");
|
||||
return pluginStateLookup({
|
||||
|
||||
@@ -16,6 +16,8 @@ export type PluginStateKeyedStore<T> = {
|
||||
updateValue: (current: T | undefined) => T | undefined,
|
||||
opts?: { ttlMs?: number },
|
||||
) => Promise<boolean>;
|
||||
/** Atomically deletes an existing entry when its current value matches. */
|
||||
deleteIf?: (key: string, predicate: (current: T) => boolean) => Promise<boolean>;
|
||||
lookup(key: string): Promise<T | undefined>;
|
||||
consume(key: string): Promise<T | undefined>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
@@ -32,6 +34,8 @@ export type PluginStateSyncKeyedStore<T> = {
|
||||
updateValue: (current: T | undefined) => T | undefined,
|
||||
opts?: { ttlMs?: number },
|
||||
) => boolean;
|
||||
/** Atomically deletes an existing entry when its current value matches. */
|
||||
deleteIf?: (key: string, predicate: (current: T) => boolean) => boolean;
|
||||
lookup(key: string): T | undefined;
|
||||
consume(key: string): T | undefined;
|
||||
delete(key: string): boolean;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
loadLogbook,
|
||||
loadLogbookStandup,
|
||||
runLogbookAnalysisNow,
|
||||
setLogbookCapturePaused,
|
||||
stopLogbookPolling,
|
||||
} from "./logbook-controller.ts";
|
||||
import type { LogbookStatusPayload } from "./logbook-types.ts";
|
||||
@@ -71,22 +72,6 @@ describe("Logbook controller", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rebinds polling when the gateway client changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
const firstRequest = vi.fn(async () => ({}));
|
||||
const secondRequest = vi.fn(async () => ({}));
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(firstRequest), true);
|
||||
configureLogbookPolling(state, clientWithRequest(secondRequest), true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(firstRequest).not.toHaveBeenCalled();
|
||||
expect(secondRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets an in-flight load settle after polling stops", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
@@ -101,13 +86,12 @@ describe("Logbook controller", () => {
|
||||
["logbook.days", days],
|
||||
["logbook.timeline", timeline],
|
||||
]);
|
||||
const request = loadLogbook(
|
||||
state,
|
||||
clientWithRequest(
|
||||
(method) =>
|
||||
responses.get(method)?.promise ?? Promise.reject(new Error(`Unexpected ${method}`)),
|
||||
),
|
||||
const client = clientWithRequest(
|
||||
(method) =>
|
||||
responses.get(method)?.promise ?? Promise.reject(new Error(`Unexpected ${method}`)),
|
||||
);
|
||||
configureLogbookPolling(state, client, true);
|
||||
const request = loadLogbook(state, client);
|
||||
|
||||
stopLogbookPolling(host);
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
@@ -170,53 +154,52 @@ describe("Logbook controller", () => {
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
|
||||
});
|
||||
|
||||
it("retires a pending poll refresh when the client changes", async () => {
|
||||
it("retires silent refresh ownership while polling is inactive", 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 staleStatus = deferred<unknown>();
|
||||
const staleDays = deferred<unknown>();
|
||||
const staleTimeline = deferred<unknown>();
|
||||
const staleBatch = new Map([
|
||||
["logbook.status", staleStatus],
|
||||
["logbook.days", staleDays],
|
||||
["logbook.timeline", staleTimeline],
|
||||
]);
|
||||
const oldRequest = vi.fn((method: string) => {
|
||||
const response = oldResponses.get(method);
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected old-client request: ${method}`);
|
||||
const request = vi.fn((method: string) => {
|
||||
const stale = staleBatch.get(method);
|
||||
if (stale) {
|
||||
staleBatch.delete(method);
|
||||
return stale.promise;
|
||||
}
|
||||
return response.promise;
|
||||
});
|
||||
const newRequest = vi.fn(async (method: string) => {
|
||||
if (method === "logbook.status") {
|
||||
return statusFor("2026-07-04");
|
||||
return Promise.resolve(statusFor("2026-07-04"));
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [] };
|
||||
return Promise.resolve({ days: [] });
|
||||
}
|
||||
return timelineFor("2026-07-04", "New client");
|
||||
return Promise.resolve(timelineFor("2026-07-04", "Reactivated poll"));
|
||||
});
|
||||
const client = clientWithRequest(request);
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(oldRequest), true);
|
||||
configureLogbookPolling(state, client, true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(oldRequest).toHaveBeenCalledTimes(3);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
|
||||
configureLogbookPolling(state, clientWithRequest(newRequest), true);
|
||||
configureLogbookPolling(state, null, false);
|
||||
configureLogbookPolling(state, client, true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(newRequest).toHaveBeenCalledTimes(3);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
expect(request).toHaveBeenCalledTimes(6);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Reactivated poll");
|
||||
|
||||
oldStatus.resolve(statusFor("2026-07-04"));
|
||||
oldDays.resolve({ days: [] });
|
||||
oldTimeline.resolve(timelineFor("2026-07-04", "Retired client"));
|
||||
staleStatus.resolve(statusFor("2026-07-04"));
|
||||
staleDays.resolve({ days: [] });
|
||||
staleTimeline.resolve(timelineFor("2026-07-04", "Inactive poll"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Reactivated poll");
|
||||
});
|
||||
|
||||
it("shares the background refresh owner with analysis completion", async () => {
|
||||
@@ -271,6 +254,67 @@ describe("Logbook controller", () => {
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
|
||||
});
|
||||
|
||||
it("retires action ownership when the polling client changes", async () => {
|
||||
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 oldClient = clientWithRequest(() => oldAnalysis.promise);
|
||||
const newClient = clientWithRequest((method) => {
|
||||
if (method === "logbook.analyze.now") {
|
||||
return newAnalysis.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", "New client"));
|
||||
});
|
||||
|
||||
configureLogbookPolling(state, oldClient, true);
|
||||
const oldRequest = runLogbookAnalysisNow(state, oldClient);
|
||||
expect(state.actionPending).toBe(true);
|
||||
|
||||
configureLogbookPolling(state, newClient, true);
|
||||
expect(state.actionPending).toBe(false);
|
||||
const newRequest = runLogbookAnalysisNow(state, newClient);
|
||||
expect(state.actionPending).toBe(true);
|
||||
|
||||
oldAnalysis.resolve({ started: true });
|
||||
await oldRequest;
|
||||
expect(state.actionPending).toBe(true);
|
||||
|
||||
newAnalysis.resolve({ started: true });
|
||||
await newRequest;
|
||||
expect(state.actionPending).toBe(false);
|
||||
});
|
||||
|
||||
it("discards a capture result from a retired polling client", async () => {
|
||||
const host = {};
|
||||
hosts.push(host);
|
||||
const state = getLogbookState(host);
|
||||
const oldStatus = deferred<unknown>();
|
||||
const oldClient = clientWithRequest(() => oldStatus.promise);
|
||||
const newStatus = { ...statusFor("2026-07-05"), capturePaused: true };
|
||||
const newClient = clientWithRequest(() => Promise.resolve(newStatus));
|
||||
|
||||
configureLogbookPolling(state, oldClient, true);
|
||||
const oldRequest = setLogbookCapturePaused(state, oldClient, true);
|
||||
configureLogbookPolling(state, newClient, true);
|
||||
await setLogbookCapturePaused(state, newClient, true);
|
||||
expect(state.status).toEqual(newStatus);
|
||||
|
||||
oldStatus.resolve(statusFor("2026-07-04"));
|
||||
await oldRequest;
|
||||
expect(state.status).toEqual(newStatus);
|
||||
expect(state.actionPending).toBe(false);
|
||||
});
|
||||
|
||||
it("queues an analysis refresh behind an in-flight poll", async () => {
|
||||
vi.useFakeTimers();
|
||||
const host = {};
|
||||
@@ -321,54 +365,48 @@ describe("Logbook controller", () => {
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Post-analysis refresh");
|
||||
});
|
||||
|
||||
it("does not let a retired analysis action affect the new client", async () => {
|
||||
it("drops a queued analysis refresh when polling stops", 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) => {
|
||||
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 await newAnalysis.promise;
|
||||
return Promise.resolve({ started: true });
|
||||
}
|
||||
if (method === "logbook.status") {
|
||||
return statusFor("2026-07-04");
|
||||
const response = pending.get(method);
|
||||
if (!response) {
|
||||
throw new Error(`Unexpected refresh request: ${method}`);
|
||||
}
|
||||
if (method === "logbook.days") {
|
||||
return { days: [] };
|
||||
}
|
||||
return timelineFor("2026-07-04", "New client");
|
||||
pending.delete(method);
|
||||
return response.promise;
|
||||
});
|
||||
const oldClient = clientWithRequest(oldRequest);
|
||||
const newClient = clientWithRequest(newRequest);
|
||||
const client = clientWithRequest(request);
|
||||
|
||||
configureLogbookPolling(state, oldClient, true);
|
||||
const oldAction = runLogbookAnalysisNow(state, oldClient);
|
||||
configureLogbookPolling(state, newClient, true);
|
||||
const newAction = runLogbookAnalysisNow(state, newClient);
|
||||
expect(state.actionPending).toBe(true);
|
||||
configureLogbookPolling(state, client, true);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
await runLogbookAnalysisNow(state, client);
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
|
||||
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;
|
||||
stopLogbookPolling(host);
|
||||
status.resolve(statusFor("2026-07-04"));
|
||||
days.resolve({ days: [] });
|
||||
timeline.resolve(timelineFor("2026-07-04", "Detached host"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.actionPending).toBe(false);
|
||||
expect(newRequest).toHaveBeenCalledTimes(4);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New client");
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
expect(state.timeline?.cards[0]?.title).toBe("Detached host");
|
||||
});
|
||||
|
||||
it("does not let an older day load overwrite a newer selection", async () => {
|
||||
@@ -400,10 +438,14 @@ describe("Logbook controller", () => {
|
||||
return timelineFor("2026-07-05", "New day");
|
||||
});
|
||||
|
||||
const olderLoad = loadLogbook(state, clientWithRequest(oldRequest), { day: "2026-07-04" });
|
||||
const oldClient = clientWithRequest(oldRequest);
|
||||
const newClient = clientWithRequest(newerRequest);
|
||||
configureLogbookPolling(state, oldClient, true);
|
||||
const olderLoad = loadLogbook(state, oldClient, { day: "2026-07-04" });
|
||||
expect(oldRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-04" });
|
||||
|
||||
await loadLogbook(state, clientWithRequest(newerRequest), { day: "2026-07-05" });
|
||||
configureLogbookPolling(state, newClient, true);
|
||||
await loadLogbook(state, newClient, { day: "2026-07-05" });
|
||||
expect(newerRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-05" });
|
||||
expect(state.timeline?.cards[0]?.title).toBe("New day");
|
||||
|
||||
@@ -425,11 +467,9 @@ describe("Logbook controller", () => {
|
||||
const state = getLogbookState(host);
|
||||
state.day = "2026-07-04";
|
||||
const pending = deferred<unknown>();
|
||||
const request = loadLogbookStandup(
|
||||
state,
|
||||
clientWithRequest(() => pending.promise),
|
||||
false,
|
||||
);
|
||||
const client = clientWithRequest(() => pending.promise);
|
||||
configureLogbookPolling(state, client, true);
|
||||
const request = loadLogbookStandup(state, client, false);
|
||||
|
||||
state.day = "2026-07-05";
|
||||
pending.resolve({ day: "2026-07-04", text: "Old day", updatedMs: 1 });
|
||||
@@ -445,10 +485,9 @@ describe("Logbook controller", () => {
|
||||
state.day = "2026-07-04";
|
||||
state.askQuestion = "What did I do?";
|
||||
const pending = deferred<unknown>();
|
||||
const request = askLogbook(
|
||||
state,
|
||||
clientWithRequest(() => pending.promise),
|
||||
);
|
||||
const client = clientWithRequest(() => pending.promise);
|
||||
configureLogbookPolling(state, client, true);
|
||||
const request = askLogbook(state, client);
|
||||
|
||||
state.day = "2026-07-05";
|
||||
pending.resolve({ answer: "Old day" });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Control UI controller for the Logbook tab: state, gateway calls, polling.
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type {
|
||||
LogbookBackgroundRefresh,
|
||||
LogbookDaysPayload,
|
||||
LogbookStatusPayload,
|
||||
LogbookTimelinePayload,
|
||||
@@ -11,7 +10,22 @@ import type {
|
||||
const FRAME_PREVIEW_CACHE_LIMIT = 48;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
const logbookStates = new WeakMap<object, LogbookUiState>();
|
||||
type LogbookControllerState = LogbookUiState & {
|
||||
// Client identity is the controller epoch. Rebinding retires every async
|
||||
// owner so an old gateway cannot mutate or block the replacement view.
|
||||
client: GatewayBrowserClient | null;
|
||||
clientGeneration: number;
|
||||
// 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;
|
||||
backgroundRefresh: Promise<void> | null;
|
||||
backgroundRefreshQueued: boolean;
|
||||
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
|
||||
pollClient: GatewayBrowserClient | null;
|
||||
};
|
||||
|
||||
const logbookStates = new WeakMap<object, LogbookControllerState>();
|
||||
|
||||
export function localDayKey(date = new Date()): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -25,7 +39,7 @@ export function shiftDay(day: string, deltaDays: number): string {
|
||||
return localDayKey(base);
|
||||
}
|
||||
|
||||
export function getLogbookState(host: object): LogbookUiState {
|
||||
export function getLogbookState(host: object): LogbookControllerState {
|
||||
let state = logbookStates.get(host);
|
||||
if (!state) {
|
||||
state = {
|
||||
@@ -46,13 +60,12 @@ export function getLogbookState(host: object): LogbookUiState {
|
||||
askAnswer: null,
|
||||
askLoading: false,
|
||||
actionPending: false,
|
||||
actionGeneration: 0,
|
||||
actionPendingGeneration: null,
|
||||
client: null,
|
||||
clientGeneration: 0,
|
||||
loadGeneration: 0,
|
||||
loadingGeneration: null,
|
||||
lifecycleGeneration: 0,
|
||||
backgroundRefresh: null,
|
||||
backgroundRefreshQueued: null,
|
||||
backgroundRefreshQueued: false,
|
||||
pollTimer: null,
|
||||
pollClient: null,
|
||||
requestUpdate: null,
|
||||
@@ -66,7 +79,39 @@ function notify(state: LogbookUiState): void {
|
||||
state.requestUpdate?.();
|
||||
}
|
||||
|
||||
function resetDayView(state: LogbookUiState, day: string): void {
|
||||
function ownsClient(
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient,
|
||||
generation: number,
|
||||
): boolean {
|
||||
return state.client === client && state.clientGeneration === generation;
|
||||
}
|
||||
|
||||
function currentClientGeneration(
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): number | null {
|
||||
return client && state.client === client ? state.clientGeneration : null;
|
||||
}
|
||||
|
||||
function bindClient(state: LogbookControllerState, client: GatewayBrowserClient | null): void {
|
||||
if (state.client === client) {
|
||||
return;
|
||||
}
|
||||
state.client = client;
|
||||
state.clientGeneration += 1;
|
||||
state.loadGeneration += 1;
|
||||
state.loadingGeneration = null;
|
||||
state.loading = false;
|
||||
state.backgroundRefresh = null;
|
||||
state.backgroundRefreshQueued = false;
|
||||
state.actionPending = false;
|
||||
state.standupLoading = false;
|
||||
state.askLoading = false;
|
||||
state.frameLoads = new Set();
|
||||
}
|
||||
|
||||
function resetDayView(state: LogbookControllerState, day: string): void {
|
||||
state.day = day;
|
||||
state.timeline = null;
|
||||
state.standup = null;
|
||||
@@ -75,11 +120,12 @@ function resetDayView(state: LogbookUiState, day: string): void {
|
||||
}
|
||||
|
||||
export async function loadLogbook(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
opts?: { day?: string; today?: boolean; silent?: boolean },
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (!client || clientGeneration === null) {
|
||||
return;
|
||||
}
|
||||
if (opts?.day) {
|
||||
@@ -104,7 +150,11 @@ export async function loadLogbook(
|
||||
client.request<LogbookDaysPayload>("logbook.days", {}),
|
||||
client.request<LogbookTimelinePayload>("logbook.timeline", { day: requestedDay }),
|
||||
]);
|
||||
if (generation !== state.loadGeneration || state.day !== requestedDay) {
|
||||
if (
|
||||
!ownsClient(state, client, clientGeneration) ||
|
||||
generation !== state.loadGeneration ||
|
||||
state.day !== requestedDay
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.status = status;
|
||||
@@ -117,7 +167,11 @@ export async function loadLogbook(
|
||||
const todayTimeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
|
||||
day: status.today,
|
||||
});
|
||||
if (generation !== state.loadGeneration || state.day !== status.today) {
|
||||
if (
|
||||
!ownsClient(state, client, clientGeneration) ||
|
||||
generation !== state.loadGeneration ||
|
||||
state.day !== status.today
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.timeline = todayTimeline;
|
||||
@@ -126,11 +180,12 @@ export async function loadLogbook(
|
||||
}
|
||||
state.error = null;
|
||||
} catch (err) {
|
||||
if (generation === state.loadGeneration) {
|
||||
if (ownsClient(state, client, clientGeneration) && generation === state.loadGeneration) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
let shouldNotify = generation === state.loadGeneration;
|
||||
let shouldNotify =
|
||||
ownsClient(state, client, clientGeneration) && generation === state.loadGeneration;
|
||||
if (state.loadingGeneration === generation) {
|
||||
state.loadingGeneration = null;
|
||||
state.loading = false;
|
||||
@@ -143,68 +198,29 @@ export async function loadLogbook(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
function drainQueuedLogbookRefresh(state: LogbookControllerState): void {
|
||||
if (!state.backgroundRefreshQueued || state.loading || state.backgroundRefresh) {
|
||||
return;
|
||||
}
|
||||
const queued = state.backgroundRefreshQueued;
|
||||
state.backgroundRefreshQueued = null;
|
||||
if (!queued || !isLogbookRefreshCurrent(state, queued)) {
|
||||
state.backgroundRefreshQueued = false;
|
||||
const client = state.pollClient;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void refreshLogbookSilently(state, queued.client, {
|
||||
lifecycleGeneration: queued.lifecycleGeneration,
|
||||
required: true,
|
||||
});
|
||||
void refreshLogbookSilently(state, client, { required: true });
|
||||
}
|
||||
|
||||
function refreshLogbookSilently(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient,
|
||||
opts?: { lifecycleGeneration?: number; required?: boolean },
|
||||
opts?: { required?: boolean },
|
||||
): Promise<void> {
|
||||
const refreshRequest = {
|
||||
client,
|
||||
lifecycleGeneration: opts?.lifecycleGeneration ?? state.lifecycleGeneration,
|
||||
};
|
||||
if (!isLogbookRefreshCurrent(state, refreshRequest)) {
|
||||
if (state.pollClient !== client) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (state.loading || state.backgroundRefresh) {
|
||||
if (opts?.required) {
|
||||
state.backgroundRefreshQueued = refreshRequest;
|
||||
state.backgroundRefreshQueued = true;
|
||||
}
|
||||
return state.backgroundRefresh ?? Promise.resolve();
|
||||
}
|
||||
@@ -228,13 +244,14 @@ export function stopLogbookPolling(host: object): void {
|
||||
}
|
||||
if (state) {
|
||||
state.pollClient = null;
|
||||
// PluginPage replaces the host before calling stop. Let detached-host loads
|
||||
state.backgroundRefreshQueued = false;
|
||||
// PluginPage retires this host immediately after stop returns. Let its loads
|
||||
// settle; host identity keeps their results out of the replacement view.
|
||||
}
|
||||
}
|
||||
|
||||
export function configureLogbookPolling(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
active: boolean,
|
||||
): void {
|
||||
@@ -244,7 +261,10 @@ export function configureLogbookPolling(
|
||||
state.pollTimer = null;
|
||||
}
|
||||
state.pollClient = null;
|
||||
retireLogbookLoads(state);
|
||||
state.backgroundRefreshQueued = false;
|
||||
// Unlike stopLogbookPolling's detached-host path, this state can render
|
||||
// again after reconnect. Retire every old async owner before reuse.
|
||||
bindClient(state, null);
|
||||
return;
|
||||
}
|
||||
if (state.pollTimer && state.pollClient === client) {
|
||||
@@ -253,7 +273,7 @@ export function configureLogbookPolling(
|
||||
if (state.pollTimer) {
|
||||
clearInterval(state.pollTimer);
|
||||
}
|
||||
retireLogbookLoads(state);
|
||||
bindClient(state, client);
|
||||
state.pollClient = client;
|
||||
state.pollTimer = setInterval(() => {
|
||||
// All background refresh sources share one owner so slow gateway responses
|
||||
@@ -263,12 +283,14 @@ export function configureLogbookPolling(
|
||||
}
|
||||
|
||||
export async function loadLogbookFramePreview(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
frameId: number,
|
||||
): Promise<void> {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (
|
||||
!client ||
|
||||
clientGeneration === null ||
|
||||
state.framePreviews.has(frameId) ||
|
||||
state.frameLoads.has(frameId) ||
|
||||
state.framePreviewFailed.has(frameId)
|
||||
@@ -280,6 +302,9 @@ export async function loadLogbookFramePreview(
|
||||
const payload = await client.request<{ base64: string; format: string }>("logbook.frame", {
|
||||
frameId,
|
||||
});
|
||||
if (!ownsClient(state, client, clientGeneration)) {
|
||||
return;
|
||||
}
|
||||
if (state.framePreviews.size >= FRAME_PREVIEW_CACHE_LIMIT) {
|
||||
const oldest = state.framePreviews.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
@@ -290,38 +315,39 @@ export async function loadLogbookFramePreview(
|
||||
} catch {
|
||||
// Preview loads are cosmetic, but a missing frame (e.g. pruned by
|
||||
// retention) must not re-fetch on every render, so remember the failure.
|
||||
state.framePreviewFailed.add(frameId);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.framePreviewFailed.add(frameId);
|
||||
}
|
||||
} finally {
|
||||
state.frameLoads.delete(frameId);
|
||||
notify(state);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.frameLoads.delete(frameId);
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function setLogbookCapturePaused(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
paused: boolean,
|
||||
): Promise<void> {
|
||||
if (!client || state.actionPending) {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (!client || clientGeneration === null || state.actionPending) {
|
||||
return;
|
||||
}
|
||||
const actionGeneration = ++state.actionGeneration;
|
||||
state.actionPendingGeneration = actionGeneration;
|
||||
state.actionPending = true;
|
||||
notify(state);
|
||||
const refresh = { client, lifecycleGeneration: state.lifecycleGeneration };
|
||||
try {
|
||||
const status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.status = status;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
if (state.actionPendingGeneration === actionGeneration) {
|
||||
state.actionPendingGeneration = null;
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
}
|
||||
@@ -329,54 +355,43 @@ export async function setLogbookCapturePaused(
|
||||
}
|
||||
|
||||
export async function runLogbookAnalysisNow(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): Promise<void> {
|
||||
if (!client || state.actionPending) {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (!client || clientGeneration === null || 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 (
|
||||
isLogbookActionCurrent(state, actionGeneration, refresh) &&
|
||||
!result.started &&
|
||||
result.reason
|
||||
) {
|
||||
if (ownsClient(state, client, clientGeneration) && !result.started && result.reason) {
|
||||
state.error = result.reason;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
if (state.actionPendingGeneration === actionGeneration) {
|
||||
state.actionPendingGeneration = null;
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.actionPending = false;
|
||||
notify(state);
|
||||
}
|
||||
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
|
||||
void refreshLogbookSilently(state, client, {
|
||||
lifecycleGeneration: refresh.lifecycleGeneration,
|
||||
required: true,
|
||||
});
|
||||
void refreshLogbookSilently(state, client, { required: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLogbookStandup(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
refresh: boolean,
|
||||
): Promise<void> {
|
||||
if (!client || state.standupLoading) {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (!client || clientGeneration === null || state.standupLoading) {
|
||||
return;
|
||||
}
|
||||
state.standupLoading = true;
|
||||
@@ -387,23 +402,28 @@ export async function loadLogbookStandup(
|
||||
"logbook.standup",
|
||||
{ day: requestedDay, refresh },
|
||||
);
|
||||
if (state.day === requestedDay) {
|
||||
if (ownsClient(state, client, clientGeneration) && state.day === requestedDay) {
|
||||
state.standup = standup;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
state.standupLoading = false;
|
||||
notify(state);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.standupLoading = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function askLogbook(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): Promise<void> {
|
||||
const question = state.askQuestion.trim();
|
||||
if (!client || state.askLoading || question.length === 0) {
|
||||
const clientGeneration = currentClientGeneration(state, client);
|
||||
if (!client || clientGeneration === null || state.askLoading || question.length === 0) {
|
||||
return;
|
||||
}
|
||||
state.askLoading = true;
|
||||
@@ -415,13 +435,17 @@ export async function askLogbook(
|
||||
day: requestedDay,
|
||||
question,
|
||||
});
|
||||
if (state.day === requestedDay) {
|
||||
if (ownsClient(state, client, clientGeneration) && state.day === requestedDay) {
|
||||
state.askAnswer = payload.answer;
|
||||
}
|
||||
} catch (err) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.error = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
} finally {
|
||||
state.askLoading = false;
|
||||
notify(state);
|
||||
if (ownsClient(state, client, clientGeneration)) {
|
||||
state.askLoading = false;
|
||||
notify(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export type LogbookStatusPayload = {
|
||||
captureEnabled: boolean;
|
||||
capturePaused: boolean;
|
||||
@@ -54,11 +52,6 @@ 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. */
|
||||
@@ -78,16 +71,5 @@ export type LogbookUiState = {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ type LogbookProps = {
|
||||
onRequestUpdate?: () => void;
|
||||
};
|
||||
|
||||
type LogbookControllerState = ReturnType<typeof getLogbookState>;
|
||||
|
||||
function formatClock(ms: number, timeZone: string): string {
|
||||
return formatTimeMs(ms, { hour: "2-digit", minute: "2-digit", timeZone }, "");
|
||||
}
|
||||
@@ -95,7 +97,7 @@ function renderStatusChips(status: LogbookStatusPayload): TemplateResult {
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
state: LogbookUiState,
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
card: LogbookCardPayload,
|
||||
timeZone: string,
|
||||
@@ -246,7 +248,10 @@ function renderStats(state: LogbookUiState): TemplateResult | typeof nothing {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStandup(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
|
||||
function renderStandup(
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<section class="card logbook-side__card">
|
||||
<div class="logbook-side__card-header">
|
||||
@@ -273,7 +278,10 @@ function renderStandup(state: LogbookUiState, client: GatewayBrowserClient | nul
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAsk(state: LogbookUiState, client: GatewayBrowserClient | null): TemplateResult {
|
||||
function renderAsk(
|
||||
state: LogbookControllerState,
|
||||
client: GatewayBrowserClient | null,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<section class="card logbook-side__card">
|
||||
<div class="card-title">${t("logbook.ask.title")}</div>
|
||||
|
||||
Reference in New Issue
Block a user