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
+83
View File
@@ -30,6 +30,11 @@ interface AttachmentsRequestBody {
folder?: unknown;
}
const MAX_NOTIFICATION_SESSION_ID_LENGTH = 512;
const MAX_NOTIFICATION_CWD_LENGTH = 32 * 1024;
const MAX_NOTIFICATION_DAEMON_ID_LENGTH = 512;
const MAX_NOTIFICATION_ID_LENGTH = 1024;
export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRouteService, eventHub: SessionEventHub, prefix = ""): void {
app.get<{ Querystring: SessionQuery }>(`${prefix}/sessions`, async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
@@ -49,6 +54,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.get(`${prefix}/sessions/notifications`, async (_request, reply) => {
try {
return await sessions.notificationCatalog();
} catch (error) {
return reply.code(400).send({ error: errorMessage(error) });
}
});
app.post<{ Body: SessionCleanupRequest | undefined }>(`${prefix}/sessions/cleanup/preview`, async (request, reply) => {
try {
return await sessions.cleanupPreview(normalizeSessionCleanupRequest(optionalRecord(request.body)));
@@ -81,6 +94,41 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.get<{ Params: { sessionId: string }; Querystring: SessionQuery }>(`${prefix}/sessions/:sessionId/notifications`, async (request, reply) => {
try {
return await sessions.notificationInbox(notificationRefFromQuery(request.params.sessionId, request.query));
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: Record<string, unknown> | undefined }>(`${prefix}/sessions/:sessionId/notifications/dismiss`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const ref = notificationRefFromBody(request.params.sessionId, body);
return await sessions.dismissNotification(ref, {
daemonInstanceId: requireNonEmptyBoundedString(body["daemonInstanceId"], "daemonInstanceId", MAX_NOTIFICATION_DAEMON_ID_LENGTH),
notificationId: requireNonEmptyBoundedString(body["notificationId"], "notificationId", MAX_NOTIFICATION_ID_LENGTH),
});
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: Record<string, unknown> | undefined }>(`${prefix}/sessions/:sessionId/notifications/dismiss-all`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const ref = notificationRefFromBody(request.params.sessionId, body);
return await sessions.dismissAllNotifications(ref, {
daemonInstanceId: requireNonEmptyBoundedString(body["daemonInstanceId"], "daemonInstanceId", MAX_NOTIFICATION_DAEMON_ID_LENGTH),
throughOrder: requireNonNegativeSafeInteger(body["throughOrder"], "throughOrder"),
throughOverflowWatermark: requireNonNegativeSafeInteger(body["throughOverflowWatermark"], "throughOverflowWatermark"),
});
} catch (error) {
return reply.code(notificationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
try {
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
@@ -341,6 +389,23 @@ function parseBulkMutationRef(value: unknown): SessionBulkMutationRef {
return { id, cwd: normalizeRequestCwd(cwd) };
}
function notificationRefFromQuery(id: string, query: SessionQuery): { id: string; cwd: string } {
const cwd = requireNonEmptyBoundedString(query.cwd, "cwd", MAX_NOTIFICATION_CWD_LENGTH);
return notificationRef(id, cwd);
}
function notificationRefFromBody(id: string, body: Record<string, unknown>): { id: string; cwd: string } {
const cwd = requireNonEmptyBoundedString(body["cwd"], "cwd", MAX_NOTIFICATION_CWD_LENGTH);
return notificationRef(id, cwd);
}
function notificationRef(id: string, cwd: string): { id: string; cwd: string } {
return {
id: requireNonEmptyBoundedString(id, "sessionId", MAX_NOTIFICATION_SESSION_ID_LENGTH),
cwd: normalizeRequestCwd(cwd),
};
}
function sessionLookupFromQuery(id: string, query: SessionQuery): SessionLookup {
return sessionLookupFromCwd(id, query.cwd);
}
@@ -374,6 +439,20 @@ function requireString(record: Record<string, unknown>, field: string): string {
return value;
}
function requireNonEmptyBoundedString(value: unknown, field: string, maxLength: number): string {
if (typeof value !== "string") throw new Error(`${field} field must be a string`);
if (value === "") throw new Error(`${field} field must not be empty`);
if (value.length > maxLength) throw new Error(`${field} field is too long`);
return value;
}
function requireNonNegativeSafeInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`${field} field must be a non-negative safe integer`);
}
return value;
}
function requireThinkingLevel(value: unknown): string {
if (typeof value !== "string" || value === "") throw new Error("level field is invalid");
return value;
@@ -397,6 +476,10 @@ function mutationErrorStatus(error: unknown): 400 | 404 {
return isSessionNotFoundError(error) ? 404 : 400;
}
function notificationErrorStatus(error: unknown): 400 | 404 {
return isSessionNotFoundError(error) ? 404 : 400;
}
function isSessionNotFoundError(error: unknown): boolean {
const message = errorMessage(error);
return message === "Session not found" || message === "Archived session not found";