Archived
feat(sessions): add daemon notification inbox protocol
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SESSION_NOTIFICATION_LIMIT,
|
||||
SESSION_NOTIFICATION_MESSAGE_BYTES,
|
||||
SessionNotificationStore,
|
||||
truncateSessionNotificationMessage,
|
||||
} from "./sessionNotificationStore.js";
|
||||
|
||||
const identity = { sessionId: "session-1", cwd: "/workspace" };
|
||||
|
||||
function testStore() {
|
||||
let tick = 0;
|
||||
return new SessionNotificationStore({
|
||||
daemonInstanceId: "daemon-test",
|
||||
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
|
||||
});
|
||||
}
|
||||
|
||||
function register(store: SessionNotificationStore) {
|
||||
return store.registerSession(identity.sessionId, identity.cwd).generation;
|
||||
}
|
||||
|
||||
describe("SessionNotificationStore", () => {
|
||||
it("keeps duplicate calls distinct, newest-first, and recomputes severity", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
|
||||
const first = store.addNotification(generation, "same", undefined).notification;
|
||||
const second = store.addNotification(generation, "same", "warning").notification;
|
||||
const third = store.addNotification(generation, "same", "error").notification;
|
||||
const unknown = store.addNotification(generation, "unknown severity", "fatal").notification;
|
||||
|
||||
expect([first?.id, second?.id, third?.id, unknown?.id]).toEqual([
|
||||
"daemon-test:1",
|
||||
"daemon-test:2",
|
||||
"daemon-test:3",
|
||||
"daemon-test:4",
|
||||
]);
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({
|
||||
summary: { retainedCount: 4, discardedCount: 0, highestSeverity: "error", inboxRevision: 4 },
|
||||
notifications: [
|
||||
{ id: "daemon-test:4", severity: "info" },
|
||||
{ id: "daemon-test:3", severity: "error" },
|
||||
{ id: "daemon-test:2", severity: "warning" },
|
||||
{ id: "daemon-test:1", severity: "info" },
|
||||
],
|
||||
dismissThrough: { order: 4, overflowWatermark: 0 },
|
||||
});
|
||||
|
||||
store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, third?.id ?? "");
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary.highestSeverity).toBe("warning");
|
||||
});
|
||||
|
||||
it("truncates UTF-8 only between code points and marks exact overflow", () => {
|
||||
const exact = "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES);
|
||||
expect(truncateSessionNotificationMessage(exact)).toEqual({ message: exact, truncated: false });
|
||||
|
||||
const astral = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1)}😀tail`;
|
||||
const astralResult = truncateSessionNotificationMessage(astral);
|
||||
expect(astralResult).toEqual({ message: "a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 1), truncated: true });
|
||||
expect(new TextEncoder().encode(astralResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES - 1);
|
||||
|
||||
const bidi = `${"a".repeat(SESSION_NOTIFICATION_MESSAGE_BYTES - 3)}\u202etail`;
|
||||
const bidiResult = truncateSessionNotificationMessage(bidi);
|
||||
expect(bidiResult.message.endsWith("\u202e")).toBe(true);
|
||||
expect(new TextEncoder().encode(bidiResult.message).byteLength).toBe(SESSION_NOTIFICATION_MESSAGE_BYTES);
|
||||
expect(bidiResult.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("retains exactly the newest 100 and reports exact overflow", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
|
||||
for (let index = 1; index <= 105; index += 1) store.addNotification(generation, `message ${String(index)}`, "info");
|
||||
|
||||
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
expect(snapshot.notifications).toHaveLength(SESSION_NOTIFICATION_LIMIT);
|
||||
expect(snapshot.notifications[0]).toMatchObject({ id: "daemon-test:105", message: "message 105" });
|
||||
expect(snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:6", message: "message 6" });
|
||||
expect(snapshot.summary).toMatchObject({ retainedCount: 100, discardedCount: 5 });
|
||||
expect(snapshot.dismissThrough).toEqual({ order: 105, overflowWatermark: 5 });
|
||||
});
|
||||
|
||||
it("makes individual dismissal idempotent and clears overflow with the final retained entry", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info");
|
||||
|
||||
const firstSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
const firstId = firstSnapshot.notifications[0]?.id ?? "";
|
||||
const firstDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId);
|
||||
const afterFirstRevision = firstDismiss.snapshot.summary.inboxRevision;
|
||||
const duplicateDismiss = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, firstId);
|
||||
expect(duplicateDismiss.mutations).toEqual([]);
|
||||
expect(duplicateDismiss.snapshot.summary.inboxRevision).toBe(afterFirstRevision);
|
||||
expect(duplicateDismiss.snapshot.summary.discardedCount).toBe(1);
|
||||
|
||||
for (const notification of duplicateDismiss.snapshot.notifications) {
|
||||
store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, notification.id);
|
||||
}
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).summary).toMatchObject({ retainedCount: 0, discardedCount: 0 });
|
||||
expect(store.catalogSnapshot().sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("dismisses only through captured order and overflow cutoffs", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(generation, String(index), "info");
|
||||
const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
|
||||
const later = store.addNotification(generation, "later", "error").notification;
|
||||
const result = store.dismissAll(
|
||||
identity.sessionId,
|
||||
identity.cwd,
|
||||
store.daemonInstanceId,
|
||||
clicked.dismissThrough.order,
|
||||
clicked.dismissThrough.overflowWatermark,
|
||||
);
|
||||
|
||||
expect(result.snapshot.notifications).toEqual([later]);
|
||||
expect(result.snapshot.summary).toMatchObject({ retainedCount: 1, discardedCount: 1, highestSeverity: "error" });
|
||||
const revision = result.snapshot.summary.inboxRevision;
|
||||
const replay = store.dismissAll(
|
||||
identity.sessionId,
|
||||
identity.cwd,
|
||||
store.daemonInstanceId,
|
||||
clicked.dismissThrough.order,
|
||||
clicked.dismissThrough.overflowWatermark,
|
||||
);
|
||||
expect(replay.mutations).toEqual([]);
|
||||
expect(replay.snapshot.summary.inboxRevision).toBe(revision);
|
||||
});
|
||||
|
||||
it("requests resync when dismissal reveals an entry hidden by the replacement projection cap", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
for (let index = 1; index <= 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, identity);
|
||||
const added = store.addNotification(candidate, "candidate", "warning");
|
||||
const candidateId = added.notification?.id ?? "";
|
||||
expect(added.mutations[0]?.inboxEvent.delta).toMatchObject({ kind: "added", evictedNotificationId: "daemon-test:1" });
|
||||
|
||||
const result = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, candidateId);
|
||||
|
||||
expect(result.mutations[0]?.inboxEvent.delta).toEqual({ kind: "resync" });
|
||||
expect(result.snapshot.notifications).toHaveLength(100);
|
||||
expect(result.snapshot.notifications.at(-1)).toMatchObject({ id: "daemon-test:1", message: "old 1" });
|
||||
expect(result.snapshot.summary.discardedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps dismiss-all deltas bounded to the public 100-entry projection", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, identity);
|
||||
for (let index = 0; index < 100; index += 1) store.addNotification(candidate, `candidate ${String(index)}`, "warning");
|
||||
const clicked = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
|
||||
const result = store.dismissAll(
|
||||
identity.sessionId,
|
||||
identity.cwd,
|
||||
store.daemonInstanceId,
|
||||
clicked.dismissThrough.order,
|
||||
clicked.dismissThrough.overflowWatermark,
|
||||
);
|
||||
|
||||
const delta = result.mutations[0]?.inboxEvent.delta;
|
||||
expect(delta?.kind).toBe("dismissed");
|
||||
expect(delta?.kind === "dismissed" ? delta.notificationIds : []).toHaveLength(100);
|
||||
expect(result.snapshot.summary.retainedCount).toBe(0);
|
||||
store.abortReplacement(candidate);
|
||||
});
|
||||
|
||||
it("treats stale daemon and unknown notification identifiers as no-ops", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
store.addNotification(generation, "keep", "warning");
|
||||
const before = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
|
||||
const stale = store.dismissAll(identity.sessionId, identity.cwd, "old-daemon", Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
|
||||
const unknown = store.dismissNotification(identity.sessionId, identity.cwd, store.daemonInstanceId, "missing");
|
||||
|
||||
expect(stale.mutations).toEqual([]);
|
||||
expect(unknown.mutations).toEqual([]);
|
||||
expect(unknown.snapshot).toEqual(before);
|
||||
});
|
||||
|
||||
it("advances catalog and inbox revisions only for visible mutations and emits zero cleanup", () => {
|
||||
const store = testStore();
|
||||
const generation = register(store);
|
||||
expect(store.catalogSnapshot()).toMatchObject({ daemonInstanceId: "daemon-test", catalogRevision: 0, sessions: [] });
|
||||
|
||||
const added = store.addNotification(generation, "notice", "info").mutations[0];
|
||||
expect(added).toMatchObject({
|
||||
sessionId: "session-1",
|
||||
inboxEvent: { type: "notifications.inbox", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 }, delta: { kind: "added" } },
|
||||
summaryEvent: { type: "notifications.summary", catalogRevision: 1, summary: { inboxRevision: 1, retainedCount: 1 } },
|
||||
});
|
||||
|
||||
const cleared = store.clearSession(identity.sessionId, "archive");
|
||||
expect(cleared).toHaveLength(1);
|
||||
expect(cleared[0]).toMatchObject({
|
||||
inboxEvent: { catalogRevision: 2, summary: { inboxRevision: 2, retainedCount: 0, discardedCount: 0 }, delta: { kind: "cleared", reason: "archive" } },
|
||||
summaryEvent: { catalogRevision: 2, summary: { retainedCount: 0, discardedCount: 0 } },
|
||||
});
|
||||
expect(store.catalogSnapshot()).toMatchObject({ catalogRevision: 2, sessions: [] });
|
||||
expect(store.addNotification(generation, "stale", "error")).toEqual({ mutations: [] });
|
||||
});
|
||||
|
||||
it("keeps inbox revisions and overflow watermarks monotonic across same-daemon reopen", () => {
|
||||
const store = testStore();
|
||||
const firstGeneration = register(store);
|
||||
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(firstGeneration, `first ${String(index)}`, "info");
|
||||
const oldSnapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
store.clearSession(identity.sessionId, "runtime-close");
|
||||
|
||||
const reopenedGeneration = register(store);
|
||||
const reopenedEmpty = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
expect(reopenedEmpty.summary.inboxRevision).toBeGreaterThan(oldSnapshot.summary.inboxRevision);
|
||||
expect(reopenedEmpty.dismissThrough.overflowWatermark).toBe(oldSnapshot.dismissThrough.overflowWatermark);
|
||||
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(reopenedGeneration, `second ${String(index)}`, "warning");
|
||||
const beforeReplay = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
|
||||
const replay = store.dismissAll(
|
||||
identity.sessionId,
|
||||
identity.cwd,
|
||||
store.daemonInstanceId,
|
||||
oldSnapshot.dismissThrough.order,
|
||||
oldSnapshot.dismissThrough.overflowWatermark,
|
||||
);
|
||||
|
||||
expect(replay.mutations).toEqual([]);
|
||||
expect(replay.snapshot).toEqual(beforeReplay);
|
||||
expect(replay.snapshot.summary.discardedCount).toBe(1);
|
||||
expect(replay.snapshot.dismissThrough.overflowWatermark).toBeGreaterThan(oldSnapshot.dismissThrough.overflowWatermark);
|
||||
});
|
||||
|
||||
it("commits replacement notifications while dropping the old generation and overflow", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
for (let index = 0; index <= SESSION_NOTIFICATION_LIMIT; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "warning");
|
||||
|
||||
const candidate = store.beginReplacement(oldGeneration, identity);
|
||||
const replacementOneResult = store.addNotification(candidate, "replacement one", "info");
|
||||
const replacementOne = replacementOneResult.notification;
|
||||
const replacementTwo = store.addNotification(candidate, "replacement two", "error").notification;
|
||||
expect(replacementOneResult.mutations[0]?.inboxEvent.delta).toMatchObject({
|
||||
kind: "added",
|
||||
evictedNotificationId: "daemon-test:2",
|
||||
});
|
||||
expect(store.addNotification(oldGeneration, "stale shutdown callback", "error")).toEqual({ mutations: [] });
|
||||
|
||||
const mutations = store.commitReplacement(candidate);
|
||||
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" });
|
||||
expect(snapshot.notifications.map((notification) => notification.id)).toEqual([replacementTwo?.id, replacementOne?.id]);
|
||||
expect(snapshot.summary).toMatchObject({ retainedCount: 2, discardedCount: 0, highestSeverity: "error" });
|
||||
expect(store.addNotification(oldGeneration, "stale", "info")).toEqual({ mutations: [] });
|
||||
});
|
||||
|
||||
it("aborts replacement without cleanup and keeps the 100-entry bound", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
for (let index = 0; index < 100; index += 1) store.addNotification(oldGeneration, `old ${String(index)}`, "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, identity);
|
||||
const replacementIds: string[] = [];
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
const notification = store.addNotification(candidate, `candidate ${String(index)}`, "warning").notification;
|
||||
if (notification !== undefined) replacementIds.push(notification.id);
|
||||
}
|
||||
|
||||
const mutations = store.abortReplacement(candidate);
|
||||
const snapshot = store.inboxSnapshot(identity.sessionId, identity.cwd);
|
||||
expect(mutations.at(-1)?.inboxEvent.delta).toEqual({ kind: "resync" });
|
||||
expect(snapshot.notifications).toHaveLength(100);
|
||||
expect(snapshot.notifications.every((notification) => replacementIds.includes(notification.id))).toBe(true);
|
||||
expect(snapshot.summary.discardedCount).toBe(100);
|
||||
expect(store.addNotification(candidate, "stale candidate", "error")).toEqual({ mutations: [] });
|
||||
|
||||
const afterAbort = store.addNotification(oldGeneration, "old runtime recovered", "error");
|
||||
expect(afterAbort.notification).toBeDefined();
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd)).toMatchObject({
|
||||
summary: { retainedCount: 100, discardedCount: 101, highestSeverity: "error" },
|
||||
});
|
||||
});
|
||||
|
||||
it("can keep a rotated candidate binding active after aborting cleanup", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
store.addNotification(oldGeneration, "old", "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, identity);
|
||||
store.addNotification(candidate, "candidate", "warning");
|
||||
|
||||
store.abortReplacement(candidate, "candidate");
|
||||
|
||||
expect(store.addNotification(oldGeneration, "stale old runner", "error")).toEqual({ mutations: [] });
|
||||
expect(store.addNotification(candidate, "current runner", "error").notification).toMatchObject({ message: "current runner", severity: "error" });
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.message)).toEqual([
|
||||
"current runner",
|
||||
"candidate",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves preserved calls to the active changed-id candidate after aborting cleanup", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
store.addNotification(oldGeneration, "old", "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd });
|
||||
store.addNotification(candidate, "candidate", "warning");
|
||||
|
||||
const mutations = store.abortReplacement(candidate, "candidate");
|
||||
|
||||
expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([
|
||||
["session-1", "cleared"],
|
||||
["session-2", "resync"],
|
||||
]);
|
||||
expect(() => store.inboxSnapshot("session-1", identity.cwd)).toThrow("Session not found");
|
||||
expect(store.inboxSnapshot("session-2", identity.cwd).notifications.map((notification) => notification.message)).toEqual(["candidate", "old"]);
|
||||
expect(store.addNotification(candidate, "after failure", "error").notification).toMatchObject({ message: "after failure" });
|
||||
});
|
||||
|
||||
it("retags a failed changed-id replacement back to the prior inbox", () => {
|
||||
const store = testStore();
|
||||
const oldGeneration = register(store);
|
||||
store.addNotification(oldGeneration, "old", "info");
|
||||
const candidate = store.beginReplacement(oldGeneration, { sessionId: "session-2", cwd: identity.cwd });
|
||||
const replacement = store.addNotification(candidate, "replacement", "error").notification;
|
||||
|
||||
const mutations = store.abortReplacement(candidate);
|
||||
|
||||
expect(mutations.map((mutation) => [mutation.sessionId, mutation.inboxEvent.delta.kind])).toEqual([
|
||||
["session-2", "cleared"],
|
||||
["session-1", "resync"],
|
||||
]);
|
||||
expect(() => store.inboxSnapshot("session-2", identity.cwd)).toThrow("Session not found");
|
||||
expect(store.inboxSnapshot(identity.sessionId, identity.cwd).notifications.map((notification) => notification.id)).toContain(replacement?.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user