feat(agent): add authenticated durable uploads to backend and web

This commit is contained in:
2026-10-14 17:25:52 -07:00
parent 4cbab487d0
commit 8b125c13a5
16 changed files with 838 additions and 54 deletions
+21 -15
View File
@@ -1,10 +1,11 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
@@ -20,7 +21,7 @@ const validRequestId = (value: unknown): value is string => typeof value === "st
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string): Promise<Receipt>;
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
@@ -40,12 +41,14 @@ export function createAgentOptimisticOutbox(options: {
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
let attachments: AgentAttachment[] = [];
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
@@ -78,7 +81,8 @@ export function createAgentOptimisticOutbox(options: {
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const dispatch = (row: AgentOutboxRow): Promise<void> => {
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
@@ -92,9 +96,10 @@ export function createAgentOptimisticOutbox(options: {
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
onPersisted?.();
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
@@ -115,20 +120,21 @@ export function createAgentOptimisticOutbox(options: {
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string): Promise<void> {
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row);
return dispatch(row, onPersisted);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const committed = new Map(room.messages.map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
@@ -143,7 +149,7 @@ export function createAgentOptimisticOutbox(options: {
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.map(message => {
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
@@ -155,8 +161,8 @@ export function createAgentOptimisticOutbox(options: {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));