167 lines
9.3 KiB
TypeScript
167 lines
9.3 KiB
TypeScript
import type { Message } from "./api";
|
|||
|
|
|
||
|
|
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;
|
||
|
|
};
|
||
|
|
export type AgentOutboxStorage = {
|
||
|
|
read(): unknown; write(rows: AgentOutboxRow[]): void;
|
||
|
|
legacyRows?(): unknown; legacyIds?(): unknown;
|
||
|
|
};
|
||
|
|
type Receipt = { status: string; messageId?: string };
|
||
|
|
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
|
||
|
|
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
|
||
|
|
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||
|
|
? message.metadata as Record<string, unknown> : {};
|
||
|
|
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
|
||
|
|
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
|
||
|
|
|
||
|
|
/** 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>;
|
||
|
|
storage: AgentOutboxStorage;
|
||
|
|
changed(): void;
|
||
|
|
newId?: () => string;
|
||
|
|
now?: () => number;
|
||
|
|
}) {
|
||
|
|
const newId = options.newId || (() => crypto.randomUUID());
|
||
|
|
const now = options.now || Date.now;
|
||
|
|
const rows = new Map<string, AgentOutboxRow>();
|
||
|
|
const inFlight = new Map<string, Promise<void>>();
|
||
|
|
const rejectedStorage = new Set<string>();
|
||
|
|
const confirmedEchoes = new Map<string, Message>();
|
||
|
|
let latestTime = 0;
|
||
|
|
const timestamp = () => {
|
||
|
|
latestTime = Math.max(now(), latestTime + 1);
|
||
|
|
return new Date(latestTime).toISOString();
|
||
|
|
};
|
||
|
|
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;
|
||
|
|
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,
|
||
|
|
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
|
||
|
|
rows.set(row.id, row);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Join the former UI-only failed cards to the old sender's receipt keys.
|
||
|
|
// Neither representation is retried automatically during migration.
|
||
|
|
const oldRows = options.storage.legacyRows?.();
|
||
|
|
const oldIds = options.storage.legacyIds?.();
|
||
|
|
const ids = new Map<string, string>();
|
||
|
|
if (Array.isArray(oldIds)) for (const value of oldIds) {
|
||
|
|
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
|
||
|
|
}
|
||
|
|
const contents = new Map<string, { content: string; status?: string; error?: string }>();
|
||
|
|
if (Array.isArray(oldRows)) for (const value of oldRows) {
|
||
|
|
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
|
||
|
|
}
|
||
|
|
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
|
||
|
|
for (const value of [...contents.values()].slice(0, 100)) {
|
||
|
|
const requestId = ids.get(value.content) || newId();
|
||
|
|
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
|
||
|
|
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
|
||
|
|
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
|
||
|
|
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const persist = () => {
|
||
|
|
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
|
||
|
|
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
|
||
|
|
options.storage.write(pending.map(row => ({ ...row })));
|
||
|
|
};
|
||
|
|
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 running = inFlight.get(row.id);
|
||
|
|
if (running) return running;
|
||
|
|
if (row.confirmed || row.seenOnServer) return Promise.resolve();
|
||
|
|
row.status = "sending"; delete row.error;
|
||
|
|
rejectedStorage.delete(row.id);
|
||
|
|
try { persist(); }
|
||
|
|
catch (error) {
|
||
|
|
// A refused new draft must not prevent older persisted drafts from
|
||
|
|
// draining their outbox. The refused row remains visibly retryable.
|
||
|
|
rejectedStorage.add(row.id);
|
||
|
|
row.status = "failed"; row.error = errorText(error); publish();
|
||
|
|
return Promise.reject(error);
|
||
|
|
}
|
||
|
|
// 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 => {
|
||
|
|
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
|
||
|
|
if (!row.confirmed) {
|
||
|
|
row.seenOnServer = true;
|
||
|
|
row.status = receipt.status === "queued" ? "queued" : "complete";
|
||
|
|
delete row.error; saveBestEffort(); publish();
|
||
|
|
}
|
||
|
|
}).catch(error => {
|
||
|
|
// A canonical echo wins over a lost HTTP response. The message has already
|
||
|
|
// been admitted, so a transport error must not paint it failed or resend it.
|
||
|
|
if (row.confirmed || row.seenOnServer) return;
|
||
|
|
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
|
||
|
|
throw error;
|
||
|
|
}).finally(() => { inFlight.delete(row.id); });
|
||
|
|
inFlight.set(row.id, pending);
|
||
|
|
return pending;
|
||
|
|
};
|
||
|
|
// Complete migration even when the user leaves restored drafts untouched:
|
||
|
|
// 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."));
|
||
|
|
const requestId = newId();
|
||
|
|
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
|
||
|
|
rows.set(row.id, row);
|
||
|
|
return dispatch(row);
|
||
|
|
},
|
||
|
|
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));
|
||
|
|
let changed = false;
|
||
|
|
for (const row of rows.values()) {
|
||
|
|
if (committed.has(row.id)) {
|
||
|
|
confirmedEchoes.set(row.id, committed.get(row.id)!);
|
||
|
|
if (!row.confirmed || row.status !== "complete" || row.error) {
|
||
|
|
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
|
||
|
|
}
|
||
|
|
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
|
||
|
|
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (changed) { saveBestEffort(); publish(); }
|
||
|
|
},
|
||
|
|
display(serverMessages: readonly Message[]): Message[] {
|
||
|
|
const displayed = new Map(serverMessages.map(message => {
|
||
|
|
const row = rows.get(message.id);
|
||
|
|
if (!row) return [message.id, message] as const;
|
||
|
|
const serverInfo = info(message);
|
||
|
|
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
|
||
|
|
return [message.id, { ...message, createdAt: row.createdAt,
|
||
|
|
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
|
||
|
|
}));
|
||
|
|
for (const row of rows.values()) if (!displayed.has(row.id)) {
|
||
|
|
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 } : {}) },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||
|
|
},
|
||
|
|
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
|
||
|
|
};
|
||
|
|
}
|