feat(sessions): add daemon notification inbox protocol

This commit is contained in:
Federico Jaramillo Martinez
2026-07-19 01:28:45 +02:00
parent d77ca66f28
commit 6e09df8329
21 changed files with 2290 additions and 59 deletions
@@ -0,0 +1,597 @@
import { randomUUID } from "node:crypto";
import {
SESSION_NOTIFICATION_LIMIT,
SESSION_NOTIFICATION_MESSAGE_BYTES,
type SessionNotification,
type SessionNotificationCatalogSnapshot,
type SessionNotificationClearReason,
type SessionNotificationInboxDelta,
type SessionNotificationInboxEvent,
type SessionNotificationInboxSnapshot,
type SessionNotificationSeverity,
type SessionNotificationSummary,
type SessionNotificationSummaryEvent,
} from "../../shared/apiTypes.js";
export { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES } from "../../shared/apiTypes.js";
export type SessionNotificationGeneration = symbol;
export interface SessionNotificationMutation {
sessionId: string;
inboxEvent: SessionNotificationInboxEvent;
summaryEvent: SessionNotificationSummaryEvent;
}
export interface SessionNotificationRegistration {
generation: SessionNotificationGeneration;
mutations: SessionNotificationMutation[];
}
export interface SessionNotificationAddResult {
notification?: SessionNotification;
mutations: SessionNotificationMutation[];
}
export interface SessionNotificationSnapshotResult {
snapshot: SessionNotificationInboxSnapshot;
mutations: SessionNotificationMutation[];
}
interface NotificationBucket {
generation: SessionNotificationGeneration;
entries: SessionNotification[];
discardedCount: number;
}
interface NotificationProjection {
sessionId: string;
cwd: string;
inboxRevision: number;
overflowWatermark: number;
buckets: NotificationBucket[];
}
interface NotificationReplacement {
generation: SessionNotificationGeneration;
projection: NotificationProjection;
bucket: NotificationBucket;
}
interface NotificationRuntimeState {
activeGeneration: SessionNotificationGeneration;
activeProjection: NotificationProjection;
activeBucket: NotificationBucket;
candidate?: NotificationReplacement;
}
interface GenerationBinding {
state: NotificationRuntimeState;
role: "active" | "candidate";
}
export interface SessionNotificationStoreOptions {
daemonInstanceId?: string;
now?: () => Date;
}
/**
* Daemon-owned, bounded, in-memory notification state.
*
* The store deliberately knows nothing about Fastify, Pi session persistence,
* sockets, or browser state. Runtime generations are opaque capabilities: once
* a generation is replaced or cleared, stale extension callbacks become no-ops.
*/
export class SessionNotificationStore {
readonly daemonInstanceId: string;
private readonly now: () => Date;
private readonly statesBySessionId = new Map<string, NotificationRuntimeState>();
private readonly bindings = new Map<SessionNotificationGeneration, GenerationBinding>();
private readonly lastInboxRevisionBySessionId = new Map<string, number>();
private readonly lastOverflowWatermarkBySessionId = new Map<string, number>();
private catalogRevision = 0;
private nextOrder = 0;
constructor(options: SessionNotificationStoreOptions = {}) {
this.daemonInstanceId = options.daemonInstanceId ?? randomUUID();
this.now = options.now ?? (() => new Date());
}
registerSession(sessionId: string, cwd: string): SessionNotificationRegistration {
requireIdentity(sessionId, cwd);
const mutations = this.clearSession(sessionId, "replacement");
const generation = Symbol(`notifications:${sessionId}`);
const bucket = emptyBucket(generation);
const projection: NotificationProjection = {
sessionId,
cwd,
inboxRevision: this.lastInboxRevisionBySessionId.get(sessionId) ?? 0,
overflowWatermark: this.lastOverflowWatermarkBySessionId.get(sessionId) ?? 0,
buckets: [bucket],
};
const state: NotificationRuntimeState = {
activeGeneration: generation,
activeProjection: projection,
activeBucket: bucket,
};
this.statesBySessionId.set(sessionId, state);
this.bindings.set(generation, { state, role: "active" });
return { generation, mutations };
}
currentGeneration(sessionId: string, cwd: string): SessionNotificationGeneration | undefined {
const state = this.statesBySessionId.get(sessionId);
if (state?.activeProjection.sessionId !== sessionId || state.activeProjection.cwd !== cwd || state.candidate !== undefined) return undefined;
return state.activeGeneration;
}
beginReplacement(
activeGeneration: SessionNotificationGeneration,
target: { sessionId: string; cwd: string },
): SessionNotificationGeneration {
requireIdentity(target.sessionId, target.cwd);
const binding = this.bindings.get(activeGeneration);
if (binding?.role !== "active") throw new Error("Notification runtime generation is no longer active");
const state = binding.state;
if (state.candidate !== undefined) throw new Error("Notification runtime replacement is already in progress");
const sameIdentity = state.activeProjection.sessionId === target.sessionId && state.activeProjection.cwd === target.cwd;
let projection: NotificationProjection;
if (sameIdentity) {
projection = state.activeProjection;
} else {
const existing = this.statesBySessionId.get(target.sessionId);
if (existing !== undefined && existing !== state) throw new Error("Notification target session is already registered");
projection = {
sessionId: target.sessionId,
cwd: target.cwd,
inboxRevision: this.lastInboxRevisionBySessionId.get(target.sessionId) ?? 0,
overflowWatermark: this.lastOverflowWatermarkBySessionId.get(target.sessionId) ?? 0,
buckets: [],
};
this.statesBySessionId.set(target.sessionId, state);
}
const generation = Symbol(`notifications:${target.sessionId}:candidate`);
const bucket = emptyBucket(generation);
projection.buckets.push(bucket);
state.candidate = { generation, projection, bucket };
this.bindings.set(generation, { state, role: "candidate" });
return generation;
}
beginReplacementForSession(sessionId: string, cwd: string): SessionNotificationGeneration | undefined {
const generation = this.currentGeneration(sessionId, cwd);
return generation === undefined ? undefined : this.beginReplacement(generation, { sessionId, cwd });
}
commitReplacement(candidateGeneration: SessionNotificationGeneration): SessionNotificationMutation[] {
const binding = this.requireCandidate(candidateGeneration);
const state = binding.state;
const candidate = state.candidate;
if (candidate === undefined) return [];
const oldProjection = state.activeProjection;
const sameProjection = oldProjection === candidate.projection;
const before = sameProjection ? projectionFingerprint(oldProjection) : undefined;
const mutations: SessionNotificationMutation[] = [];
this.bindings.delete(state.activeGeneration);
if (sameProjection) {
oldProjection.buckets = [candidate.bucket];
} else {
mutations.push(...this.clearProjection(oldProjection, "replacement"));
this.statesBySessionId.delete(oldProjection.sessionId);
}
state.activeGeneration = candidate.generation;
state.activeProjection = candidate.projection;
state.activeBucket = candidate.bucket;
delete state.candidate;
this.bindings.set(candidate.generation, { state, role: "active" });
this.statesBySessionId.set(candidate.projection.sessionId, state);
if (sameProjection && before !== projectionFingerprint(candidate.projection)) {
mutations.push(this.mutation(candidate.projection, { kind: "resync" }));
}
return mutations;
}
abortReplacement(
candidateGeneration: SessionNotificationGeneration,
survivingGeneration: "prior" | "candidate" = "prior",
): SessionNotificationMutation[] {
const binding = this.requireCandidate(candidateGeneration);
const state = binding.state;
const candidate = state.candidate;
if (candidate === undefined) return [];
const oldProjection = state.activeProjection;
const oldGeneration = state.activeGeneration;
const oldBucket = state.activeBucket;
const candidateProjection = candidate.projection;
const sameProjection = oldProjection === candidateProjection;
const targetProjection = sameProjection || survivingGeneration === "prior" ? oldProjection : candidateProjection;
const before = projectionFingerprint(targetProjection);
const oldEntries = [...oldBucket.entries];
const candidateEntries = [...candidate.bucket.entries];
const oldDiscardedCount = oldBucket.discardedCount;
const candidateDiscardedCount = candidate.bucket.discardedCount;
const mutations: SessionNotificationMutation[] = [];
if (!sameProjection) {
const sourceProjection = survivingGeneration === "candidate" ? oldProjection : candidateProjection;
mutations.push(...this.clearProjection(sourceProjection, "replacement"));
this.statesBySessionId.delete(sourceProjection.sessionId);
const transferredDiscardedCount = survivingGeneration === "candidate" ? oldDiscardedCount : candidateDiscardedCount;
targetProjection.overflowWatermark = addSafe(
targetProjection.overflowWatermark,
transferredDiscardedCount,
"Notification overflow watermark exhausted",
);
}
const merged = [...oldEntries, ...candidateEntries].sort((left, right) => left.order - right.order);
const overflow = Math.max(0, merged.length - SESSION_NOTIFICATION_LIMIT);
const targetGeneration = survivingGeneration === "candidate" ? candidate.generation : oldGeneration;
const targetBucket = survivingGeneration === "candidate" ? candidate.bucket : oldBucket;
targetBucket.entries = overflow === 0 ? merged : merged.slice(overflow);
targetBucket.discardedCount = addSafe(oldDiscardedCount, candidateDiscardedCount, "Notification discarded count exhausted");
addDiscardedCount(overflow, targetProjection, targetBucket);
targetProjection.buckets = [targetBucket];
this.bindings.delete(survivingGeneration === "candidate" ? oldGeneration : candidate.generation);
state.activeGeneration = targetGeneration;
state.activeProjection = targetProjection;
state.activeBucket = targetBucket;
delete state.candidate;
this.statesBySessionId.set(targetProjection.sessionId, state);
this.bindings.set(targetGeneration, { state, role: "active" });
if (before !== projectionFingerprint(targetProjection)) {
mutations.push(this.mutation(targetProjection, { kind: "resync" }));
}
return mutations;
}
addNotification(
generation: SessionNotificationGeneration,
message: string,
severity: unknown,
): SessionNotificationAddResult {
const binding = this.bindings.get(generation);
if (binding === undefined) return { mutations: [] };
const { state } = binding;
// Once the replacement runner is bound, old callbacks are stale. Suppressing
// them also keeps generation overflow ordered as a bounded suffix.
if (binding.role === "active" && state.candidate !== undefined) return { mutations: [] };
const projection = binding.role === "candidate" ? state.candidate?.projection : state.activeProjection;
const bucket = binding.role === "candidate" ? state.candidate?.bucket : state.activeBucket;
if (projection === undefined || bucket === undefined) return { mutations: [] };
const previouslyRetained = retainedEntries(projection);
const order = incrementSafe(this.nextOrder, "Notification order exhausted");
this.nextOrder = order;
const truncatedMessage = truncateSessionNotificationMessage(message);
const notification: SessionNotification = Object.freeze({
id: `${this.daemonInstanceId}:${String(order)}`,
message: truncatedMessage.message,
truncated: truncatedMessage.truncated,
severity: normalizeSeverity(severity),
receivedAt: this.now().toISOString(),
order,
});
bucket.entries.push(notification);
const bucketEviction = bucket.entries.length > SESSION_NOTIFICATION_LIMIT ? bucket.entries.shift() : undefined;
if (bucketEviction !== undefined) addDiscardedCount(1, projection, bucket);
const retainedIds = new Set(retainedEntries(projection).map((entry) => entry.id));
const projectionEviction = previouslyRetained.find((entry) => !retainedIds.has(entry.id));
const delta: SessionNotificationInboxDelta = {
kind: "added",
notification,
...(projectionEviction === undefined ? {} : { evictedNotificationId: projectionEviction.id }),
};
return { notification, mutations: [this.mutation(projection, delta)] };
}
catalogSnapshot(): SessionNotificationCatalogSnapshot {
const sessions = uniqueProjections(this.statesBySessionId.values())
.map((projection) => this.summary(projection))
.filter((summary) => summary.retainedCount > 0 || summary.discardedCount > 0);
return {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
sessions,
};
}
inboxSnapshot(sessionId: string, cwd: string): SessionNotificationInboxSnapshot {
return this.snapshot(this.requireProjection(sessionId, cwd));
}
dismissNotification(
sessionId: string,
cwd: string,
daemonInstanceId: string,
notificationId: string,
): SessionNotificationSnapshotResult {
const projection = this.requireProjection(sessionId, cwd);
if (daemonInstanceId !== this.daemonInstanceId) return { snapshot: this.snapshot(projection), mutations: [] };
const previouslyRetained = retainedEntries(projection);
const previouslyRetainedIds = new Set(previouslyRetained.map((entry) => entry.id));
const before = projectionFingerprint(projection);
let dismissed = false;
for (const bucket of projection.buckets) {
const index = bucket.entries.findIndex((entry) => entry.id === notificationId);
if (index === -1) continue;
bucket.entries.splice(index, 1);
dismissed = true;
break;
}
if (!dismissed) return { snapshot: this.snapshot(projection), mutations: [] };
if (projection.buckets.every((bucket) => bucket.entries.length === 0)) clearDiscardedCount(projection);
const newlyRevealed = retainedEntries(projection).some((entry) => !previouslyRetainedIds.has(entry.id));
const after = projectionFingerprint(projection);
if (!previouslyRetainedIds.has(notificationId) && before === after) {
return { snapshot: this.snapshot(projection), mutations: [] };
}
const delta: SessionNotificationInboxDelta = newlyRevealed
? { kind: "resync" }
: { kind: "dismissed", notificationIds: [notificationId] };
const mutation = this.mutation(projection, delta);
return { snapshot: this.snapshot(projection), mutations: [mutation] };
}
dismissAll(
sessionId: string,
cwd: string,
daemonInstanceId: string,
throughOrder: number,
throughOverflowWatermark: number,
): SessionNotificationSnapshotResult {
const projection = this.requireProjection(sessionId, cwd);
if (daemonInstanceId !== this.daemonInstanceId) return { snapshot: this.snapshot(projection), mutations: [] };
const visibleIds = new Set(retainedEntries(projection).map((entry) => entry.id));
const dismissedIds: string[] = [];
for (const bucket of projection.buckets) {
bucket.entries = bucket.entries.filter((entry) => {
if (entry.order > throughOrder) return true;
if (visibleIds.has(entry.id)) dismissedIds.push(entry.id);
return false;
});
}
const acknowledgedOverflow = acknowledgeDiscardedThrough(projection, throughOverflowWatermark);
if (dismissedIds.length === 0 && acknowledgedOverflow === 0) return { snapshot: this.snapshot(projection), mutations: [] };
const mutation = this.mutation(projection, { kind: "dismissed", notificationIds: dismissedIds });
return { snapshot: this.snapshot(projection), mutations: [mutation] };
}
clearGeneration(generation: SessionNotificationGeneration, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const binding = this.bindings.get(generation);
return binding === undefined ? [] : this.clearSession(binding.state.activeProjection.sessionId, reason);
}
clearSessionIdentity(sessionId: string, cwd: string, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) return [];
const projection = state.activeProjection.sessionId === sessionId ? state.activeProjection : state.candidate?.projection;
if (projection?.sessionId !== sessionId) return [];
if (projection.cwd !== cwd) throw new Error("Session cwd mismatch");
return this.clearSession(sessionId, reason);
}
clearSession(sessionId: string, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) return [];
const projections = state.candidate === undefined
? [state.activeProjection]
: uniqueProjectionList([state.activeProjection, state.candidate.projection]);
const mutations = projections.flatMap((projection) => this.clearProjection(projection, reason));
this.statesBySessionId.delete(state.activeProjection.sessionId);
if (state.candidate !== undefined) {
this.statesBySessionId.delete(state.candidate.projection.sessionId);
this.bindings.delete(state.candidate.generation);
}
this.bindings.delete(state.activeGeneration);
return mutations;
}
clearAll(reason: SessionNotificationClearReason = "service-dispose"): SessionNotificationMutation[] {
const states = new Set(this.statesBySessionId.values());
const mutations: SessionNotificationMutation[] = [];
for (const state of states) mutations.push(...this.clearSession(state.activeProjection.sessionId, reason));
this.statesBySessionId.clear();
this.bindings.clear();
this.lastInboxRevisionBySessionId.clear();
this.lastOverflowWatermarkBySessionId.clear();
return mutations;
}
private requireCandidate(candidateGeneration: SessionNotificationGeneration): GenerationBinding {
const binding = this.bindings.get(candidateGeneration);
if (binding?.role !== "candidate") throw new Error("Notification replacement generation is no longer active");
return binding;
}
private requireProjection(sessionId: string, cwd: string): NotificationProjection {
const state = this.statesBySessionId.get(sessionId);
if (state === undefined) throw new Error("Session not found");
const projection = state.activeProjection.sessionId === sessionId
? state.activeProjection
: state.candidate?.projection.sessionId === sessionId
? state.candidate.projection
: undefined;
if (projection === undefined) throw new Error("Session not found");
if (projection.cwd !== cwd) throw new Error("Session cwd mismatch");
return projection;
}
private clearProjection(projection: NotificationProjection, reason: SessionNotificationClearReason): SessionNotificationMutation[] {
const wasVisible = retainedEntries(projection).length > 0 || discardedCount(projection) > 0;
for (const bucket of projection.buckets) {
bucket.entries = [];
bucket.discardedCount = 0;
}
return wasVisible ? [this.mutation(projection, { kind: "cleared", reason })] : [];
}
private mutation(projection: NotificationProjection, delta: SessionNotificationInboxDelta): SessionNotificationMutation {
projection.inboxRevision = incrementSafe(projection.inboxRevision, "Notification inbox revision exhausted");
this.lastInboxRevisionBySessionId.set(projection.sessionId, projection.inboxRevision);
this.lastOverflowWatermarkBySessionId.set(projection.sessionId, projection.overflowWatermark);
this.catalogRevision = incrementSafe(this.catalogRevision, "Notification catalog revision exhausted");
const summary = this.summary(projection);
const common = {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
summary,
};
return {
sessionId: projection.sessionId,
inboxEvent: { type: "notifications.inbox", ...common, dismissThrough: dismissThrough(projection), delta },
summaryEvent: { type: "notifications.summary", ...common },
};
}
private summary(projection: NotificationProjection): SessionNotificationSummary {
const notifications = retainedEntries(projection);
const highestSeverity = highestSeverityOf(notifications);
return {
sessionId: projection.sessionId,
cwd: projection.cwd,
inboxRevision: projection.inboxRevision,
retainedCount: notifications.length,
discardedCount: discardedCount(projection),
...(highestSeverity === undefined ? {} : { highestSeverity }),
};
}
private snapshot(projection: NotificationProjection): SessionNotificationInboxSnapshot {
const notifications = retainedEntries(projection).reverse();
return {
daemonInstanceId: this.daemonInstanceId,
catalogRevision: this.catalogRevision,
summary: this.summary(projection),
notifications,
dismissThrough: dismissThrough(projection),
};
}
}
export function truncateSessionNotificationMessage(
message: string,
maxBytes = SESSION_NOTIFICATION_MESSAGE_BYTES,
): { message: string; truncated: boolean } {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new Error("maxBytes must be a non-negative safe integer");
const encoder = new TextEncoder();
if (encoder.encode(message).byteLength <= maxBytes) return { message, truncated: false };
let bytes = 0;
let truncated = "";
for (const codePoint of message) {
const codePointBytes = encoder.encode(codePoint).byteLength;
if (bytes + codePointBytes > maxBytes) break;
truncated += codePoint;
bytes += codePointBytes;
}
return { message: truncated, truncated: true };
}
function emptyBucket(generation: SessionNotificationGeneration): NotificationBucket {
return { generation, entries: [], discardedCount: 0 };
}
function normalizeSeverity(value: unknown): SessionNotificationSeverity {
return value === "warning" || value === "error" ? value : "info";
}
function retainedEntries(projection: NotificationProjection): SessionNotification[] {
return projection.buckets
.flatMap((bucket) => bucket.entries)
.sort((left, right) => left.order - right.order)
.slice(-SESSION_NOTIFICATION_LIMIT);
}
function discardedCount(projection: NotificationProjection): number {
return projection.buckets.reduce((total, bucket) => total + bucket.discardedCount, 0);
}
function highestSeverityOf(notifications: readonly SessionNotification[]): SessionNotificationSeverity | undefined {
let highest: SessionNotificationSeverity | undefined;
for (const notification of notifications) {
if (notification.severity === "error") return "error";
if (notification.severity === "warning") highest = "warning";
else highest ??= "info";
}
return highest;
}
function addDiscardedCount(count: number, projection: NotificationProjection, bucket: NotificationBucket): void {
if (count === 0) return;
projection.overflowWatermark = addSafe(projection.overflowWatermark, count, "Notification overflow watermark exhausted");
bucket.discardedCount = addSafe(bucket.discardedCount, count, "Notification discarded count exhausted");
}
function clearDiscardedCount(projection: NotificationProjection): void {
for (const bucket of projection.buckets) bucket.discardedCount = 0;
}
function acknowledgeDiscardedThrough(projection: NotificationProjection, throughWatermark: number): number {
const count = discardedCount(projection);
if (count === 0) return 0;
const firstWatermark = projection.overflowWatermark - count + 1;
const acknowledged = Math.max(0, Math.min(count, throughWatermark - firstWatermark + 1));
let remaining = acknowledged;
for (const bucket of projection.buckets) {
if (remaining === 0) break;
const removed = Math.min(bucket.discardedCount, remaining);
bucket.discardedCount -= removed;
remaining -= removed;
}
return acknowledged;
}
function dismissThrough(projection: NotificationProjection): { order: number; overflowWatermark: number } {
const entries = retainedEntries(projection);
return {
order: entries.at(-1)?.order ?? 0,
overflowWatermark: projection.overflowWatermark,
};
}
function projectionFingerprint(projection: NotificationProjection): string {
return JSON.stringify({
ids: retainedEntries(projection).map((entry) => entry.id),
discardedCount: discardedCount(projection),
});
}
function uniqueProjections(states: Iterable<NotificationRuntimeState>): NotificationProjection[] {
const projections: NotificationProjection[] = [];
for (const state of new Set(states)) {
projections.push(state.activeProjection);
if (state.candidate !== undefined && state.candidate.projection !== state.activeProjection) projections.push(state.candidate.projection);
}
return projections;
}
function uniqueProjectionList(projections: NotificationProjection[]): NotificationProjection[] {
return [...new Set(projections)];
}
function requireIdentity(sessionId: string, cwd: string): void {
if (sessionId === "") throw new Error("sessionId must not be empty");
if (cwd === "") throw new Error("cwd must not be empty");
}
function incrementSafe(value: number, message: string): number {
return addSafe(value, 1, message);
}
function addSafe(value: number, increment: number, message: string): number {
const next = value + increment;
if (!Number.isSafeInteger(next)) throw new Error(message);
return next;
}