feat(tlon): add durable ingress (#110910)

This commit is contained in:
Peter Steinberger
2026-07-18 20:19:04 +01:00
committed by GitHub
parent 53ad69c6ee
commit 138d2d071c
8 changed files with 1250 additions and 435 deletions
+232 -214
View File
@@ -1,5 +1,6 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
@@ -27,11 +28,8 @@ import { resolveChannelAuthorization } from "./authorization.js";
import { createTlonCitationResolver } from "./cites.js";
import { fetchAllChannels, fetchInitData } from "./discovery.js";
import { cacheMessage, fetchThreadHistory, getChannelHistory } from "./history.js";
import { createTlonIngressMonitor, type TlonIngressLifecycle } from "./ingress.js";
import { downloadMessageImages } from "./media.js";
import {
createProcessedMessageTracker,
runWithProcessedMessageClaim,
} from "./processed-messages.js";
import {
applyTlonSettingsOverrides,
buildTlonSettingsMigrations,
@@ -140,7 +138,6 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
},
});
const processedTracker = createProcessedMessageTracker(2000);
let groupChannels: string[] = [];
let botNickname: string | null = null;
@@ -308,6 +305,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
timestamp: number;
parentId?: string | null;
isThreadReply?: boolean;
turnAdoptionLifecycle?: TlonIngressLifecycle;
}) => {
const {
messageId,
@@ -320,6 +318,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
parentId,
isThreadReply,
messageContent,
turnAdoptionLifecycle,
} = params;
const groupChannel = channelNest; // For compatibility
let messageText = params.messageText;
@@ -654,6 +653,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
responsePrefix,
humanDelay,
},
...(turnAdoptionLifecycle
? { replyOptions: bindIngressLifecycleToReplyOptions(turnAdoptionLifecycle) }
: {}),
record: {
onRecordError: (err) => {
runtime.error?.(`[tlon] failed updating session meta: ${String(err)}`);
@@ -739,7 +741,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
});
// Firehose handler for all channel messages (/v2)
const handleChannelsFirehose = async (event: unknown) => {
const handleChannelsFirehose = async (
event: unknown,
turnAdoptionLifecycle?: TlonIngressLifecycle,
) => {
try {
const eventRecord = asRecord(event);
const nest = readString(eventRecord, "nest");
@@ -780,119 +785,111 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
return;
}
const processed = await runWithProcessedMessageClaim({
tracker: processedTracker,
const senderShip = normalizeShip(readString(content, "author") ?? "");
if (!senderShip || senderShip === botShipName) {
return;
}
const rawText = extractMessageText(content.content);
if (!rawText.trim()) {
return;
}
const contentBody = content.content;
const sentAt = readNumber(content, "sent") ?? Date.now();
cacheMessage(nest, {
author: senderShip,
content: rawText,
timestamp: sentAt,
id: messageId,
task: async () => {
const senderShip = normalizeShip(readString(content, "author") ?? "");
if (!senderShip || senderShip === botShipName) {
return;
}
const rawText = extractMessageText(content.content);
if (!rawText.trim()) {
return;
}
const contentBody = content.content;
const sentAt = readNumber(content, "sent") ?? Date.now();
cacheMessage(nest, {
author: senderShip,
content: rawText,
timestamp: sentAt,
id: messageId,
});
// Get thread info early for participation check
const seal = isThreadReply ? asRecord(replySet?.seal) : asRecord(set?.seal);
const parentId = readString(seal, "parent-id") ?? readString(seal, "parent") ?? null;
// Check if we should respond:
// 1. Direct mention always triggers response
// 2. Thread replies where we've participated - respond if relevant (let agent decide)
const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
const inParticipatedThread =
isThreadReply && parentId && participatedThreads.has(parentId);
const mentionDecision = resolveTlonGroupMentionDecision({
cfg,
accountId: account.accountId,
wasMentioned: mentioned,
botParticipatedInThread: Boolean(inParticipatedThread),
});
if (mentionDecision.shouldSkip) {
return;
}
// Log why we're responding
if (mentionDecision.implicitMention && !mentioned) {
runtime.log?.(
`[tlon] Responding to thread we participated in (no mention): ${parentId}`,
);
}
// Owner is always allowed
if (isOwner(senderShip)) {
runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
} else {
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
if (mode === "restricted") {
const normalizedAllowed = allowedShips.map(normalizeShip);
if (!normalizedAllowed.includes(senderShip)) {
// If owner is configured, queue approval request
if (effectiveOwnerShip) {
const approval = createPendingApproval({
type: "channel",
requestingShip: senderShip,
channelNest: nest,
messagePreview: sliceUtf16Safe(rawText, 0, 100),
originalMessage: {
messageId: messageId ?? "",
messageText: rawText,
messageContent: contentBody,
timestamp: sentAt,
parentId: parentId ?? undefined,
isThreadReply,
},
});
await queueApprovalRequest(approval);
} else {
runtime.log?.(
`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`,
);
}
return;
}
}
}
const messageText = await resolveAuthorizedMessageText({
rawText,
content: contentBody,
authorizedForCites: true,
resolveAllCites,
});
const parsed = parseChannelNest(nest);
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
messageContent: contentBody, // Pass raw content for media extraction
isGroup: true,
channelNest: nest,
hostShip: parsed?.hostShip,
channelName: parsed?.channelName,
timestamp: sentAt,
parentId,
isThreadReply,
});
},
});
void processed;
// Get thread info early for participation check
const seal = isThreadReply ? asRecord(replySet?.seal) : asRecord(set?.seal);
const parentId = readString(seal, "parent-id") ?? readString(seal, "parent") ?? null;
// Check if we should respond:
// 1. Direct mention always triggers response
// 2. Thread replies where we've participated - respond if relevant (let agent decide)
const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
const inParticipatedThread = isThreadReply && parentId && participatedThreads.has(parentId);
const mentionDecision = resolveTlonGroupMentionDecision({
cfg,
accountId: account.accountId,
wasMentioned: mentioned,
botParticipatedInThread: Boolean(inParticipatedThread),
});
if (mentionDecision.shouldSkip) {
return;
}
// Log why we're responding
if (mentionDecision.implicitMention && !mentioned) {
runtime.log?.(`[tlon] Responding to thread we participated in (no mention): ${parentId}`);
}
// Owner is always allowed
if (isOwner(senderShip)) {
runtime.log?.(`[tlon] Owner ${senderShip} is always allowed in channels`);
} else {
const { mode, allowedShips } = resolveChannelAuthorization(cfg, nest, currentSettings);
if (mode === "restricted") {
const normalizedAllowed = allowedShips.map(normalizeShip);
if (!normalizedAllowed.includes(senderShip)) {
// If owner is configured, queue approval request
if (effectiveOwnerShip) {
const approval = createPendingApproval({
type: "channel",
requestingShip: senderShip,
channelNest: nest,
messagePreview: sliceUtf16Safe(rawText, 0, 100),
originalMessage: {
messageId: messageId ?? "",
messageText: rawText,
messageContent: contentBody,
timestamp: sentAt,
parentId: parentId ?? undefined,
isThreadReply,
},
});
await queueApprovalRequest(approval);
} else {
runtime.log?.(
`[tlon] Access denied: ${senderShip} in ${nest} (allowed: ${allowedShips.join(", ")})`,
);
}
return;
}
}
}
const messageText = await resolveAuthorizedMessageText({
rawText,
content: contentBody,
authorizedForCites: true,
resolveAllCites,
});
const parsed = parseChannelNest(nest);
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText,
messageContent: contentBody, // Pass raw content for media extraction
isGroup: true,
channelNest: nest,
hostShip: parsed?.hostShip,
channelName: parsed?.channelName,
timestamp: sentAt,
parentId,
isThreadReply,
turnAdoptionLifecycle,
});
} catch (error: unknown) {
runtime.error?.(`[tlon] Error handling channel firehose event: ${formatErrorMessage(error)}`);
throw error;
}
};
@@ -900,7 +897,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Track which DM invites we've already processed to avoid duplicate accepts
const processedDmInvites = new Set<string>();
const handleChatFirehose = async (event: unknown) => {
const handleChatFirehose = async (
event: unknown,
turnAdoptionLifecycle?: TlonIngressLifecycle,
) => {
try {
// Handle DM invite lists (arrays)
if (Array.isArray(event)) {
@@ -976,116 +976,125 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
return;
}
const processed = await runWithProcessedMessageClaim({
tracker: processedTracker,
id: messageId,
task: async () => {
const authorShip = normalizeShip(readString(essay, "author") ?? "");
const partnerShip = extractDmPartnerShip(whom);
const senderShip = partnerShip || authorShip;
const authorShip = normalizeShip(readString(essay, "author") ?? "");
const partnerShip = extractDmPartnerShip(whom);
const senderShip = partnerShip || authorShip;
// Ignore the bot's own outbound DM events.
if (authorShip === botShipName) {
return;
}
if (!senderShip || senderShip === botShipName) {
return;
}
// Ignore the bot's own outbound DM events.
if (authorShip === botShipName) {
return;
}
if (!senderShip || senderShip === botShipName) {
return;
}
// Log mismatch between author and partner for debugging
if (authorShip && partnerShip && authorShip !== partnerShip) {
runtime.log?.(
`[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`,
);
}
// Log mismatch between author and partner for debugging
if (authorShip && partnerShip && authorShip !== partnerShip) {
runtime.log?.(
`[tlon] DM ship mismatch (author=${authorShip}, partner=${partnerShip}) - routing to partner`,
);
}
const rawText = extractMessageText(essay.content);
if (!rawText.trim()) {
return;
}
const rawText = extractMessageText(essay.content);
if (!rawText.trim()) {
return;
}
// Check if this is the owner sending an approval response
const messageText = rawText;
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
const handled = await handleApprovalResponse(messageText);
if (handled) {
runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
return;
}
}
// Check if this is the owner sending an approval response
const messageText = rawText;
if (isOwner(senderShip) && isApprovalResponse(messageText)) {
const handled = await handleApprovalResponse(messageText);
if (handled) {
runtime.log?.(`[tlon] Processed approval response from owner: ${messageText}`);
return;
}
}
// Check if this is the owner sending an admin command
if (isOwner(senderShip) && isAdminCommand(messageText)) {
const handled = await handleAdminCommand(messageText);
if (handled) {
runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
return;
}
}
// Check if this is the owner sending an admin command
if (isOwner(senderShip) && isAdminCommand(messageText)) {
const handled = await handleAdminCommand(messageText);
if (handled) {
runtime.log?.(`[tlon] Processed admin command from owner: ${messageText}`);
return;
}
}
// Owner is always allowed to DM (bypass allowlist)
if (isOwner(senderShip)) {
const resolvedMessageText = await resolveAuthorizedMessageText({
rawText,
content: essay.content,
authorizedForCites: true,
resolveAllCites,
});
runtime.log?.(`[tlon] Processing DM from owner ${senderShip}`);
await processMessage({
// Owner is always allowed to DM (bypass allowlist)
if (isOwner(senderShip)) {
const resolvedMessageText = await resolveAuthorizedMessageText({
rawText,
content: essay.content,
authorizedForCites: true,
resolveAllCites,
});
runtime.log?.(`[tlon] Processing DM from owner ${senderShip}`);
await processMessage({
messageId: messageId ?? "",
senderShip,
messageText: resolvedMessageText,
messageContent: essay.content,
isGroup: false,
timestamp: readNumber(essay, "sent") ?? Date.now(),
turnAdoptionLifecycle,
});
return;
}
// For DMs from others, check allowlist
if (!(await isDmAllowedWithIngress(senderShip, effectiveDmAllowlist))) {
// If owner is configured, queue approval request
if (effectiveOwnerShip) {
const approval = createPendingApproval({
type: "dm",
requestingShip: senderShip,
messagePreview: sliceUtf16Safe(messageText, 0, 100),
originalMessage: {
messageId: messageId ?? "",
senderShip,
messageText: resolvedMessageText,
messageText,
messageContent: essay.content,
isGroup: false,
timestamp: readNumber(essay, "sent") ?? Date.now(),
});
return;
}
// For DMs from others, check allowlist
if (!(await isDmAllowedWithIngress(senderShip, effectiveDmAllowlist))) {
// If owner is configured, queue approval request
if (effectiveOwnerShip) {
const approval = createPendingApproval({
type: "dm",
requestingShip: senderShip,
messagePreview: sliceUtf16Safe(messageText, 0, 100),
originalMessage: {
messageId: messageId ?? "",
messageText,
messageContent: essay.content,
timestamp: readNumber(essay, "sent") ?? Date.now(),
},
});
await queueApprovalRequest(approval);
} else {
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
}
return;
}
await processMessage({
messageText: await resolveAuthorizedMessageText({
rawText,
content: essay.content,
authorizedForCites: true,
resolveAllCites,
}),
messageId: messageId ?? "",
senderShip,
messageContent: essay.content, // Pass raw content for media extraction
isGroup: false,
timestamp: readNumber(essay, "sent") ?? Date.now(),
},
});
},
await queueApprovalRequest(approval);
} else {
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
}
return;
}
await processMessage({
messageText: await resolveAuthorizedMessageText({
rawText,
content: essay.content,
authorizedForCites: true,
resolveAllCites,
}),
messageId: messageId ?? "",
senderShip,
messageContent: essay.content, // Pass raw content for media extraction
isGroup: false,
timestamp: readNumber(essay, "sent") ?? Date.now(),
turnAdoptionLifecycle,
});
void processed;
} catch (error: unknown) {
runtime.error?.(`[tlon] Error handling chat firehose event: ${formatErrorMessage(error)}`);
throw error;
}
};
const ingress = createTlonIngressMonitor({
accountId: account.accountId,
runtime,
abortSignal: opts.abortSignal,
dispatch: async (source, event, turnAdoptionLifecycle) => {
if (source === "channels") {
await handleChannelsFirehose(event, turnAdoptionLifecycle);
return;
}
await handleChatFirehose(event, turnAdoptionLifecycle);
},
});
try {
runtime.log?.("[tlon] Subscribing to firehose updates...");
@@ -1093,8 +1102,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await api.subscribe({
app: "channels",
path: "/v2",
event: (event) => {
void handleChannelsFirehose(event);
event: async (event) => {
const result = await ingress.receive({ source: "channels", event });
if (result.kind === "ignored") {
await handleChannelsFirehose(event);
}
},
err: (error) => {
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
@@ -1109,8 +1121,11 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await api.subscribe({
app: "chat",
path: "/v3",
event: (event) => {
void handleChatFirehose(event);
event: async (event) => {
const result = await ingress.receive({ source: "chat", event });
if (result.kind === "ignored") {
await handleChatFirehose(event);
}
},
err: (error) => {
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
@@ -1474,6 +1489,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
await api.connect();
ingress.start();
runtime.log?.("[tlon] Connected! Firehose subscriptions active");
// Periodically refresh channel discovery
@@ -1516,6 +1532,8 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await new Promise(() => {});
}
} finally {
api?.stopReceiving();
await ingress.stop();
try {
await api?.close();
} catch (error: unknown) {
+375
View File
@@ -0,0 +1,375 @@
// Tlon durable ingress tests cover append, recovery, tombstones, and guard parity.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
closeOpenClawStateDatabaseForTest,
createChannelIngressQueueForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { UrbitHttpError } from "../urbit/errors.js";
import { createTlonIngressMonitor } from "./ingress.js";
type TlonIngressQueue = NonNullable<Parameters<typeof createTlonIngressMonitor>[0]["queue"]>;
type TlonIngressPayload = Parameters<TlonIngressQueue["enqueue"]>[1];
type TlonIngressDispatch = Parameters<typeof createTlonIngressMonitor>[0]["dispatch"];
function channelEvent(params?: { id?: string; nest?: string; text?: string }) {
return {
nest: params?.nest ?? "chat/~zod/general",
response: {
post: {
id: params?.id ?? "message-1",
"r-post": {
set: {
essay: {
author: "~nec",
content: [{ text: params?.text ?? "hello" }],
sent: 1_700_000_000_000,
},
},
},
},
},
};
}
function channelReplyEvent(params?: { id?: string; nest?: string; text?: string }) {
return {
nest: params?.nest ?? "chat/~zod/general",
response: {
post: {
id: "parent-post",
"r-post": {
reply: {
id: params?.id ?? "reply-1",
"r-reply": {
set: {
memo: {
author: "~nec",
content: [{ text: params?.text ?? "hello" }],
sent: 1_700_000_000_000,
},
},
},
},
},
},
},
};
}
function chatEvent(params?: { id?: string; peer?: string; text?: string }) {
return {
whom: params?.peer ?? "~nec",
id: params?.id ?? "dm-1",
response: {
add: {
essay: {
author: params?.peer ?? "~nec",
content: [{ text: params?.text ?? "hello" }],
sent: 1_700_000_000_000,
},
},
},
};
}
function createQueue(stateDir: string, accountId = "default"): TlonIngressQueue {
return createChannelIngressQueueForTests<TlonIngressPayload>({
channelId: "tlon",
accountId,
stateDir,
});
}
async function withQueue<T>(
fn: (queue: TlonIngressQueue, stateDir: string) => Promise<T>,
): Promise<T> {
const created = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tlon-ingress-"));
const stateDir = await fs.realpath(created);
try {
return await fn(createQueue(stateDir), stateDir);
} finally {
closeOpenClawStateDatabaseForTest();
await fs.rm(stateDir, { recursive: true, force: true });
}
}
function startMonitor(queue: TlonIngressQueue, dispatch: TlonIngressDispatch) {
const monitor = createTlonIngressMonitor({
accountId: "default",
queue,
dispatch,
runtime: { error: vi.fn(), log: vi.fn() },
pollIntervalMs: 60_000,
adoptionStallTimeoutMs: 5_000,
});
monitor.start();
return monitor;
}
function deferred() {
let resolve = () => {};
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.restoreAllMocks();
});
describe("Tlon durable ingress", () => {
it("propagates durable append failure before dispatch", async () => {
await withQueue(async (queue) => {
const appendError = new Error("sqlite unavailable");
const failingQueue = {
...queue,
enqueue: vi.fn().mockRejectedValue(appendError),
} satisfies TlonIngressQueue;
const dispatch = vi.fn();
const monitor = startMonitor(failingQueue, dispatch);
try {
await expect(monitor.receive({ source: "channels", event: channelEvent() })).rejects.toBe(
appendError,
);
expect(dispatch).not.toHaveBeenCalled();
} finally {
await monitor.stop();
}
});
});
it("recovers an uncompleted message with a fresh drain exactly once", async () => {
await withQueue(async (queue, stateDir) => {
const interruptedDispatch = vi.fn(async () => ({ kind: "deferred" }) as const);
const interrupted = startMonitor(queue, interruptedDispatch);
await interrupted.receive({
source: "channels",
event: channelEvent({ id: "message-restart" }),
});
await interrupted.waitForIdle();
expect(await queue.listClaims()).toHaveLength(1);
await interrupted.stop();
closeOpenClawStateDatabaseForTest();
const recoveredDispatch = vi.fn<TlonIngressDispatch>(async (_source, _event, lifecycle) => {
await lifecycle.onAdopted();
});
const recovered = startMonitor(createQueue(stateDir), recoveredDispatch);
try {
await recovered.waitForIdle();
expect(recoveredDispatch).toHaveBeenCalledTimes(1);
} finally {
await recovered.stop();
}
});
});
it("retains completion so a duplicate message id cannot dispatch twice", async () => {
await withQueue(async (queue) => {
const dispatch = vi.fn<TlonIngressDispatch>(async (_source, _event, lifecycle) => {
await lifecycle.onAdopted();
});
const monitor = startMonitor(queue, dispatch);
try {
const event = chatEvent({ id: "dm-completed" });
await monitor.receive({ source: "chat", event });
await monitor.waitForIdle();
await monitor.receive({ source: "chat", event });
await monitor.waitForIdle();
expect(dispatch).toHaveBeenCalledTimes(1);
} finally {
await monitor.stop();
}
});
});
it("preserves the retired guard for reissued or edited message envelopes", async () => {
await withQueue(async (queue) => {
const dispatch = vi.fn<TlonIngressDispatch>(async (_source, _event, lifecycle) => {
await lifecycle.onAdopted();
});
const monitor = startMonitor(queue, dispatch);
try {
await monitor.receive({
source: "channels",
event: channelEvent({ id: "message-guard", text: "first delivery" }),
});
await monitor.waitForIdle();
await monitor.receive({
source: "channels",
event: channelEvent({ id: "message-guard", text: "edited redelivery" }),
});
await monitor.waitForIdle();
expect(dispatch).toHaveBeenCalledTimes(1);
} finally {
await monitor.stop();
}
});
});
it("retains completed logical ids by count rather than age", async () => {
await withQueue(async (queue) => {
let now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
const dispatch = vi.fn<TlonIngressDispatch>(async (_source, _event, lifecycle) => {
await lifecycle.onAdopted();
});
const monitor = startMonitor(queue, dispatch);
try {
const original = channelEvent({ id: "message-aged" });
await monitor.receive({ source: "channels", event: original });
await monitor.waitForIdle();
now += 31 * 24 * 60 * 60 * 1_000;
await monitor.receive({
source: "channels",
event: channelEvent({ id: "message-prune-trigger" }),
});
await monitor.waitForIdle();
await monitor.receive({ source: "channels", event: original });
await monitor.waitForIdle();
expect(dispatch).toHaveBeenCalledTimes(2);
} finally {
await monitor.stop();
}
});
});
it("stores raw envelopes in per-conversation lanes", async () => {
await withQueue(async (queue) => {
const dispatch = vi.fn(async () => ({ kind: "deferred" }) as const);
const monitor = startMonitor(queue, dispatch);
const group = channelEvent({ id: "message-raw", nest: "chat/~zod/ops" });
try {
await monitor.receive({ source: "channels", event: group });
await monitor.waitForIdle();
expect(await queue.listClaims()).toEqual([
expect.objectContaining({
id: "message-raw",
laneKey: "group:chat/~zod/ops",
payload: expect.objectContaining({ rawEvent: JSON.stringify(group) }),
}),
]);
} finally {
await monitor.stop();
}
});
});
it("uses the nested reply id rather than the parent post id", async () => {
await withQueue(async (queue) => {
const dispatch = vi.fn(async () => ({ kind: "deferred" }) as const);
const monitor = startMonitor(queue, dispatch);
const reply = channelReplyEvent({ id: "reply-stable", nest: "chat/~zod/ops" });
try {
await monitor.receive({ source: "channels", event: reply });
await monitor.waitForIdle();
expect(await queue.listClaims()).toEqual([
expect.objectContaining({ id: "reply-stable", laneKey: "group:chat/~zod/ops" }),
]);
} finally {
await monitor.stop();
}
});
});
it("dead-letters malformed persisted payloads and authentication failures", async () => {
await withQueue(async (queue) => {
await queue.enqueue(
"message-malformed",
{ version: 1, receivedAt: 1, source: "channels", rawEvent: "{" },
{ receivedAt: 1, laneKey: "group:chat/~zod/general" },
);
const dispatch = vi.fn<TlonIngressDispatch>(async () => {
throw new UrbitHttpError({ operation: "Poke", status: 401 });
});
const monitor = startMonitor(queue, dispatch);
try {
await monitor.waitForIdle();
expect((await queue.enqueue("message-malformed", {} as TlonIngressPayload)).kind).toBe(
"failed",
);
await monitor.receive({
source: "chat",
event: chatEvent({ id: "message-auth" }),
});
await monitor.waitForIdle();
expect((await queue.enqueue("message-auth", {} as TlonIngressPayload)).kind).toBe("failed");
} finally {
await monitor.stop();
}
});
});
it("keeps unrelated authentication failures retryable", async () => {
await withQueue(async (queue) => {
const dispatch = vi.fn<TlonIngressDispatch>(async () => {
throw Object.assign(new Error("model credentials expired"), { status: 401 });
});
const monitor = startMonitor(queue, dispatch);
try {
await monitor.receive({
source: "chat",
event: chatEvent({ id: "message-provider-auth" }),
});
await monitor.waitForIdle();
expect((await queue.listPending({ limit: "all" })).map((record) => record.id)).toEqual([
"message-provider-auth",
]);
} finally {
await monitor.stop();
}
});
});
it("waits for admitted work and leaves it pending on repeated stop", async () => {
await withQueue(async (queue) => {
const stored = deferred();
const release = deferred();
const enqueue = queue.enqueue.bind(queue);
queue.enqueue = async (...args) => {
const result = await enqueue(...args);
stored.resolve();
await release.promise;
return result;
};
const dispatch = vi.fn();
const monitor = startMonitor(queue, dispatch);
const admission = monitor.receive({
source: "channels",
event: channelEvent({ id: "message-stop-1" }),
});
await stored.promise;
const queuedAdmission = monitor.receive({
source: "channels",
event: channelEvent({ id: "message-stop-2" }),
});
let stopSettled = false;
const stopping = monitor.stop().then(() => {
stopSettled = true;
});
const stoppingAgain = monitor.stop();
await Promise.resolve();
expect(stopSettled).toBe(false);
await expect(
monitor.receive({ source: "channels", event: channelEvent({ id: "message-too-late" }) }),
).rejects.toThrow("stopped before dispatch adoption");
release.resolve();
await Promise.all([admission, queuedAdmission, stopping, stoppingAgain]);
expect(dispatch).not.toHaveBeenCalled();
expect((await queue.listPending({ limit: "all" })).map((record) => record.id)).toEqual([
"message-stop-1",
"message-stop-2",
]);
});
});
});
+366
View File
@@ -0,0 +1,366 @@
// Tlon plugin module owns raw Urbit firehose durable ingress mapping and draining.
import {
createChannelIngressDrain,
DEFAULT_INGRESS_ADOPTION_STALL_MS,
DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS,
type ChannelIngressQueue,
} from "openclaw/plugin-sdk/channel-outbound";
import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { getTlonRuntime } from "../runtime.js";
import { UrbitAuthError, UrbitHttpError } from "../urbit/errors.js";
const TLON_INGRESS_PAYLOAD_VERSION = 1;
const TLON_INGRESS_POLL_INTERVAL_MS = 1_000;
const TLON_INGRESS_PRUNE_INTERVAL_MS = 60 * 60 * 1_000;
const TLON_INGRESS_FAILED_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
// Preserve the retired process-local guard's full 2,000-message key window.
const TLON_INGRESS_TOMBSTONE_MAX_ENTRIES = 2_000;
export type TlonIngressLifecycle = {
abortSignal: AbortSignal;
onAdopted: () => void | Promise<void>;
onDeferred: () => void;
onAdoptionFinalizing: () => void;
onAbandoned: () => void | Promise<void>;
};
type TlonIngressSource = "channels" | "chat";
type TlonIngressPayload = {
version: 1;
receivedAt: number;
source: TlonIngressSource;
rawEvent: string;
};
type TlonIngressDrain = {
drainOnce: (options?: { shouldStop?: () => boolean }) => Promise<{ started: number }>;
waitForIdle: () => Promise<void>;
dispose: () => void;
};
type TlonIngressDispatchResult =
| { kind: "completed" }
| { kind: "deferred" }
| { kind: "failed-retryable"; error: unknown };
type TlonIngressDispatch = (
source: TlonIngressSource,
event: unknown,
lifecycle: TlonIngressLifecycle,
) => Promise<TlonIngressDispatchResult | void> | TlonIngressDispatchResult | void;
class TlonIngressPermanentError extends Error {
constructor(
readonly reason: "invalid-event" | "tlon-auth",
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "TlonIngressPermanentError";
}
}
class TlonIngressShutdownError extends Error {
constructor() {
super("Tlon ingress stopped before dispatch adoption.");
this.name = "TlonIngressShutdownError";
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function nonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function inspectChannelsEvent(event: unknown): { eventId: string; laneKey: string } | null {
const envelope = isRecord(event) ? event : null;
const nest = nonEmptyString(envelope?.nest);
const response = isRecord(envelope?.response) ? envelope.response : null;
const post = isRecord(response?.post) ? response.post : null;
const rPost = isRecord(post?.["r-post"]) ? post["r-post"] : null;
const set = isRecord(rPost?.set) ? rPost.set : null;
const reply = isRecord(rPost?.reply) ? rPost.reply : null;
const rReply = isRecord(reply?.["r-reply"]) ? reply["r-reply"] : null;
const replySet = isRecord(rReply?.set) ? rReply.set : null;
if (!nest || (!isRecord(set?.essay) && !isRecord(replySet?.memo))) {
return null;
}
const eventId = nonEmptyString(isRecord(replySet?.memo) ? reply?.id : post?.id);
return eventId ? { eventId, laneKey: `group:${nest}` } : null;
}
function inspectChatEvent(event: unknown): { eventId: string; laneKey: string } | null {
const envelope = isRecord(event) ? event : null;
const response = isRecord(envelope?.response) ? envelope.response : null;
const add = isRecord(response?.add) ? response.add : null;
const essay = isRecord(add?.essay) ? add.essay : null;
const eventId = nonEmptyString(envelope?.id);
if (!essay || !eventId) {
return null;
}
const whom = isRecord(envelope?.whom) ? nonEmptyString(envelope.whom.ship) : null;
const peer = nonEmptyString(envelope?.whom) ?? whom ?? nonEmptyString(essay.author);
return { eventId, laneKey: peer ? `direct:${peer}` : `event:${eventId}` };
}
function inspectTlonIngressEvent(
source: TlonIngressSource,
event: unknown,
): { eventId: string; laneKey: string } | null {
// Urbit SSE ids belong to a disposable HTTP channel. The message id inside
// each firehose envelope survives resubscription and preserves the retired guard key.
return source === "channels" ? inspectChannelsEvent(event) : inspectChatEvent(event);
}
function parseClaimedEvent(payload: TlonIngressPayload, claimedId: string): unknown {
if (
payload.version !== TLON_INGRESS_PAYLOAD_VERSION ||
(payload.source !== "channels" && payload.source !== "chat") ||
typeof payload.rawEvent !== "string"
) {
throw new TlonIngressPermanentError(
"invalid-event",
`Tlon ingress row ${claimedId} has an invalid payload.`,
);
}
let event: unknown;
try {
event = JSON.parse(payload.rawEvent);
} catch (error) {
throw new TlonIngressPermanentError(
"invalid-event",
`Tlon ingress row ${claimedId} contains invalid JSON.`,
{ cause: error },
);
}
const facts = inspectTlonIngressEvent(payload.source, event);
if (!facts || facts.eventId !== claimedId) {
throw new TlonIngressPermanentError(
"invalid-event",
`Tlon ingress row ${claimedId} has invalid message identity.`,
);
}
return event;
}
function resolveTlonIngressNonRetryableFailure(error: unknown) {
if (error instanceof TlonIngressPermanentError) {
return { reason: error.reason, message: error.message };
}
for (const candidate of collectErrorGraphCandidates(error, (current) => [current.cause])) {
if (
candidate instanceof UrbitAuthError ||
(candidate instanceof UrbitHttpError &&
(candidate.status === 401 || candidate.status === 403))
) {
return { reason: "tlon-auth", message: formatErrorMessage(candidate) };
}
}
return null;
}
type TlonIngressMonitor = {
receive: (params: {
source: TlonIngressSource;
event: unknown;
}) => Promise<{ kind: "accepted" } | { kind: "ignored" }>;
start: () => void;
stop: () => Promise<void>;
waitForIdle: () => Promise<void>;
};
export function createTlonIngressMonitor(options: {
accountId: string;
queue?: ChannelIngressQueue<TlonIngressPayload>;
dispatch: TlonIngressDispatch;
runtime: Pick<RuntimeEnv, "error" | "log">;
pollIntervalMs?: number;
adoptionStallTimeoutMs?: number;
abortSignal?: AbortSignal;
}): TlonIngressMonitor {
let queue = options.queue;
let drain: TlonIngressDrain | undefined;
let accepting = true;
let running = false;
let stopped = false;
let requested = false;
let pumping: Promise<void> | undefined;
let pollTimer: ReturnType<typeof setInterval> | undefined;
let lastPrunedAt = 0;
let stopPromise: Promise<void> | undefined;
const getQueue = (): ChannelIngressQueue<TlonIngressPayload> => {
queue ??= getTlonRuntime().state.openChannelIngressQueue<TlonIngressPayload>({
accountId: options.accountId,
});
return queue;
};
const getDrain = (): TlonIngressDrain => {
drain ??= createChannelIngressDrain<TlonIngressPayload>({
queue: getQueue(),
adoptionStallTimeoutMs: options.adoptionStallTimeoutMs ?? DEFAULT_INGRESS_ADOPTION_STALL_MS,
retryPolicy: {
maxAttempts: DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS,
deadLetterMinAgeMs: DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS,
},
resolveNonRetryableFailure: resolveTlonIngressNonRetryableFailure,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
onLog: (message) => options.runtime.log?.(`tlon ${message}`),
dispatchClaimedEvent: async (record, lifecycle) => {
if (!running || lifecycle.abortSignal.aborted || options.abortSignal?.aborted) {
return { kind: "failed-retryable", error: new TlonIngressShutdownError() };
}
const event = parseClaimedEvent(record.payload, record.id);
try {
const result = await options.dispatch(record.payload.source, event, lifecycle);
return !running || options.abortSignal?.aborted
? { kind: "failed-retryable", error: new TlonIngressShutdownError() }
: result;
} catch (error) {
if (!running || options.abortSignal?.aborted) {
return { kind: "failed-retryable", error };
}
throw error;
}
},
});
return drain;
};
const pruneIfDue = async (): Promise<void> => {
const now = Date.now();
if (now - lastPrunedAt < TLON_INGRESS_PRUNE_INTERVAL_MS) {
return;
}
await getQueue().prune({
completedMaxEntries: TLON_INGRESS_TOMBSTONE_MAX_ENTRIES,
failedTtlMs: TLON_INGRESS_FAILED_TTL_MS,
failedMaxEntries: TLON_INGRESS_TOMBSTONE_MAX_ENTRIES,
now,
});
lastPrunedAt = now;
};
const runPump = async (): Promise<void> => {
try {
for (;;) {
requested = false;
await pruneIfDue();
// stop() may race the async prune; never create a live drain afterward.
if (!running) {
break;
}
const activeDrain = getDrain();
const { started } = await activeDrain.drainOnce({ shouldStop: () => !running });
await activeDrain.waitForIdle();
if (!running || (!requested && started === 0)) {
break;
}
}
} catch (error) {
options.runtime.error?.(`tlon ingress drain failed: ${formatErrorMessage(error)}`);
} finally {
pumping = undefined;
if (running && requested) {
requestDrain();
}
}
};
const requestDrain = (): void => {
requested = true;
if (!running || pumping) {
return;
}
pumping = runPump();
};
// Stream callbacks are awaited, but serialize direct test/caller admissions too.
let admissionTail: Promise<void> = Promise.resolve();
const admitOnce = async (source: TlonIngressSource, event: unknown): Promise<boolean> => {
const facts = inspectTlonIngressEvent(source, event);
if (!facts) {
return false;
}
const receivedAt = Date.now();
await getQueue().enqueue(
facts.eventId,
{
version: TLON_INGRESS_PAYLOAD_VERSION,
receivedAt,
source,
rawEvent: JSON.stringify(event),
},
{ receivedAt, laneKey: facts.laneKey },
);
requestDrain();
return true;
};
return {
receive: async ({ source, event }) => {
if (!accepting) {
throw new TlonIngressShutdownError();
}
let accepted = false;
const admission = admissionTail.then(async () => {
accepted = await admitOnce(source, event);
});
admissionTail = admission.catch(() => undefined);
await admission;
return { kind: accepted ? "accepted" : "ignored" };
},
start: () => {
if (running || stopped) {
return;
}
running = true;
pollTimer = setInterval(
requestDrain,
options.pollIntervalMs ?? TLON_INGRESS_POLL_INTERVAL_MS,
);
pollTimer.unref?.();
requestDrain();
},
stop: async () => {
if (stopPromise) {
await stopPromise;
return;
}
accepting = false;
running = false;
stopped = true;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = undefined;
}
stopPromise = (async () => {
await admissionTail;
drain?.dispose();
await pumping;
// A pump may have lazily created the drain before observing running=false.
drain?.dispose();
await drain?.waitForIdle();
})();
await stopPromise;
},
waitForIdle: async () => {
for (;;) {
const activePump = pumping;
if (!activePump) {
break;
}
await activePump;
}
await drain?.waitForIdle();
},
};
}
@@ -1,59 +0,0 @@
// Tlon tests cover processed messages plugin behavior.
import { describe, expect, it } from "vitest";
import {
createProcessedMessageTracker,
runWithProcessedMessageClaim,
} from "./processed-messages.js";
describe("createProcessedMessageTracker", () => {
it("dedupes and evicts oldest entries", () => {
const tracker = createProcessedMessageTracker(3);
expect(tracker.mark("a")).toBe(true);
expect(tracker.mark("a")).toBe(false);
expect(tracker.has("a")).toBe(true);
tracker.mark("b");
tracker.mark("c");
expect(tracker.size()).toBe(3);
tracker.mark("d");
expect(tracker.size()).toBe(3);
expect(tracker.has("a")).toBe(false);
expect(tracker.has("b")).toBe(true);
expect(tracker.has("c")).toBe(true);
expect(tracker.has("d")).toBe(true);
});
it("releases failed claims so retries can run again", async () => {
const tracker = createProcessedMessageTracker();
await expect(
runWithProcessedMessageClaim({
tracker,
id: "evt-1",
task: async () => {
throw new Error("boom");
},
}),
).rejects.toThrow("boom");
expect(tracker.has("evt-1")).toBe(false);
expect(tracker.claim("evt-1")).toEqual({ kind: "claimed" });
});
it("keeps successful claims deduped", async () => {
const tracker = createProcessedMessageTracker();
await expect(
runWithProcessedMessageClaim({
tracker,
id: "evt-2",
task: async () => undefined,
}),
).resolves.toEqual({ kind: "processed", value: undefined });
expect(tracker.has("evt-2")).toBe(true);
expect(tracker.claim("evt-2")).toEqual({ kind: "duplicate" });
});
});
@@ -1,90 +0,0 @@
// Tlon plugin module implements processed messages behavior.
import { createDedupeCache } from "../../runtime-api.js";
type ProcessedMessageTracker = {
claim: (id?: string | null) => { kind: "claimed" } | { kind: "duplicate" };
commit: (id?: string | null) => void;
release: (id?: string | null) => void;
mark: (id?: string | null) => boolean;
has: (id?: string | null) => boolean;
size: () => number;
};
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
const dedupe = createDedupeCache({ ttlMs: 0, maxSize: limit });
const inFlight = new Set<string>();
const claim = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return { kind: "claimed" } as const;
}
if (inFlight.has(trimmed) || dedupe.peek(trimmed)) {
return { kind: "duplicate" } as const;
}
inFlight.add(trimmed);
return { kind: "claimed" } as const;
};
const commit = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return;
}
inFlight.delete(trimmed);
dedupe.check(trimmed);
};
const release = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return;
}
inFlight.delete(trimmed);
};
const mark = (id?: string | null) => {
const claimed = claim(id);
if (claimed.kind === "duplicate") {
return false;
}
commit(id);
return true;
};
const has = (id?: string | null) => {
const trimmed = id?.trim();
if (!trimmed) {
return false;
}
return dedupe.peek(trimmed);
};
return {
claim,
commit,
release,
mark,
has,
size: () => dedupe.size(),
};
}
export async function runWithProcessedMessageClaim<T>(params: {
tracker: ProcessedMessageTracker;
id?: string | null;
task: () => Promise<T>;
}): Promise<{ kind: "processed"; value: T } | { kind: "duplicate" }> {
const claim = params.tracker.claim(params.id);
if (claim.kind === "duplicate") {
return claim;
}
try {
const value = await params.task();
params.tracker.commit(params.id);
return { kind: "processed", value };
} catch (error) {
params.tracker.release(params.id);
throw error;
}
}
+9 -2
View File
@@ -66,7 +66,11 @@ export async function pokeUrbitChannel(
const errorText = await readResponseTextLimited(response, TLON_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
throw new UrbitHttpError({
operation: "Poke",
status: response.status,
bodyText: errorText || undefined,
});
}
return pokeId;
} finally {
@@ -95,7 +99,10 @@ export async function scryUrbitPath(
try {
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${params.path}`);
throw new UrbitHttpError({
operation: `Scry for path ${params.path}`,
status: response.status,
});
}
// Successful scry bodies come from a remote Urbit and have no protocol size bound.
// Keep the shared JSON ceiling while retaining the path needed to identify the endpoint.
+163 -8
View File
@@ -2,6 +2,7 @@
import { Readable } from "node:stream";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ensureUrbitChannelOpen } from "./channel-ops.js";
import { urbitFetch } from "./fetch.js";
import { UrbitSSEClient } from "./sse-client.js";
@@ -218,23 +219,88 @@ describe("UrbitSSEClient", () => {
expect(client.reconnectAttempts).toBe(0);
});
it("reopens the same HTTP channel so unacked events can replay", async () => {
vi.useFakeTimers();
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch.mockResolvedValue({
response: { ok: true, status: 200, body: null } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const onReconnect = vi.fn();
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
reconnectDelay: 1,
maxReconnectDelay: 1,
onReconnect,
});
const channelId = client.channelId;
const reconnecting = client.attemptReconnect();
await vi.advanceTimersByTimeAsync(1);
await reconnecting;
expect(client.channelId).toBe(channelId);
expect(onReconnect).toHaveBeenCalledOnce();
const callArgs = requireFirstMockCall(mockUrbitFetch.mock.calls, "stream reconnect")[0] as
| Parameters<typeof urbitFetch>[0]
| undefined;
expect(callArgs?.path).toBe(`/~/channel/${channelId}`);
expect(callArgs?.init?.method).toBe("GET");
expect(client.reconnectAttempts).toBe(0);
});
it("replaces a server-deleted HTTP channel and restores subscriptions", async () => {
vi.useFakeTimers();
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch
.mockResolvedValueOnce({
response: { ok: false, status: 404, body: null } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
})
.mockResolvedValueOnce({
response: { ok: true, status: 200, body: null } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
reconnectDelay: 1,
maxReconnectDelay: 1,
});
await client.subscribe({ app: "chat", path: "/dm/~zod", event: () => {} });
const deletedChannelId = client.channelId;
const reconnecting = client.attemptReconnect();
await vi.advanceTimersByTimeAsync(1);
await reconnecting;
expect(client.channelId).not.toBe(deletedChannelId);
expect(ensureUrbitChannelOpen).toHaveBeenCalledWith(
expect.objectContaining({ channelId: client.channelId }),
expect.objectContaining({ createBody: client.subscriptions }),
);
expect(mockUrbitFetch).toHaveBeenCalledTimes(2);
expect(mockUrbitFetch.mock.calls[1]?.[0].path).toBe(`/~/channel/${client.channelId}`);
expect(client.reconnectAttempts).toBe(0);
});
});
describe("event acking", () => {
it("logs malformed SSE JSON with an owned parser error", () => {
it("logs malformed SSE JSON with an owned parser error", async () => {
const logger = { error: vi.fn() };
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
logger,
});
client.processEvent("id: 1\ndata: {not json");
await client.processEvent("id: 1\ndata: {not json");
expect(logger.error).toHaveBeenCalledWith(
"Error parsing SSE event: Error: Tlon Urbit SSE event was malformed JSON",
);
});
it("guards JSON.parse against oversized SSE payload to prevent OOM", () => {
it("guards JSON.parse against oversized SSE payload to prevent OOM", async () => {
const errors: string[] = [];
const logger = { error: (msg: string) => errors.push(msg) };
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", { logger });
@@ -247,7 +313,7 @@ describe("UrbitSSEClient", () => {
const padLen = cap + 1024 - jsonOverhead;
const hugeJson = prefix + "A".repeat(padLen) + suffix;
client.processEvent(`id: 1\ndata: ${hugeJson}`);
await client.processEvent(`id: 1\ndata: ${hugeJson}`);
expect(errors).toHaveLength(1);
expect(errors[0]).toBe(
@@ -255,7 +321,7 @@ describe("UrbitSSEClient", () => {
);
});
it("accepts SSE payload at the 16 MiB boundary", () => {
it("accepts SSE payload at the 16 MiB boundary", async () => {
const cap = 16 * 1024 * 1024;
// Allocate valid JSON whose byteLength exactly equals cap.
const prefix = '{"json":{"ok":true,"x":"';
@@ -268,7 +334,7 @@ describe("UrbitSSEClient", () => {
const handler = vi.fn();
client.eventHandlers.set(1, { event: handler });
client.processEvent(`id: 1\ndata: ${hugeJson}`);
await client.processEvent(`id: 1\ndata: ${hugeJson}`);
expect(handler).toHaveBeenCalledTimes(1);
const payload = handler.mock.calls[0]?.[0] as { ok?: boolean; x?: string } | undefined;
expect(payload?.ok).toBe(true);
@@ -372,13 +438,102 @@ describe("UrbitSSEClient", () => {
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.processEvent('id: 25abc\ndata: {"json":{"ok":true}}');
await Promise.resolve();
await client.processEvent('id: 25abc\ndata: {"json":{"ok":true}}');
expect(mockUrbitFetch).not.toHaveBeenCalled();
expect((client as unknown as { lastHeardEventId: number }).lastHeardEventId).toBe(-1);
});
it("waits for durable admission before acknowledging the transport event", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch.mockResolvedValue({
response: { ok: true, status: 200 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
let releaseAdmission = () => {};
const admission = new Promise<void>((resolve) => {
releaseAdmission = resolve;
});
const handler = vi.fn(async () => {
await admission;
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.eventHandlers.set(1, { event: handler });
const processing = client.processEvent('id: 20\ndata: {"id":1,"json":{"ok":true}}');
await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
expect(mockUrbitFetch).not.toHaveBeenCalled();
releaseAdmission();
await processing;
expect(mockUrbitFetch).toHaveBeenCalledTimes(1);
const body = mockUrbitFetch.mock.calls[0]?.[0].init?.body;
if (typeof body !== "string") {
throw new Error("Expected string ACK request body");
}
expect(JSON.parse(body)).toEqual([{ id: expect.any(Number), action: "ack", "event-id": 20 }]);
});
it("does not acknowledge a failed durable admission", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.eventHandlers.set(1, {
event: async () => {
throw new Error("sqlite unavailable");
},
});
await expect(
client.processEvent('id: 20\ndata: {"id":1,"json":{"ok":true}}'),
).rejects.toThrow("sqlite unavailable");
expect(mockUrbitFetch).not.toHaveBeenCalled();
});
it("does not advance the ack watermark when the ack request fails", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch.mockResolvedValue({
response: { ok: false, status: 503 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.eventHandlers.set(1, { event: vi.fn() });
await expect(
client.processEvent('id: 20\ndata: {"id":1,"json":{"ok":true}}'),
).rejects.toThrow("Ack failed with status 503");
expect(
(client as unknown as { lastAcknowledgedEventId: number }).lastAcknowledgedEventId,
).toBe(-1);
});
it("retries a failed ack when the unacknowledged event replays", async () => {
const mockUrbitFetch = vi.mocked(urbitFetch);
mockUrbitFetch
.mockResolvedValueOnce({
response: { ok: false, status: 503 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
})
.mockResolvedValueOnce({
response: { ok: true, status: 204 } as unknown as Response,
finalUrl: "https://example.com",
release: vi.fn().mockResolvedValue(undefined),
});
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
client.eventHandlers.set(1, { event: vi.fn() });
const event = 'id: 20\ndata: {"id":1,"json":{"ok":true}}';
await expect(client.processEvent(event)).rejects.toThrow("Ack failed with status 503");
await expect(client.processEvent(event)).resolves.toBeUndefined();
expect(mockUrbitFetch).toHaveBeenCalledTimes(2);
expect(
(client as unknown as { lastAcknowledgedEventId: number }).lastAcknowledgedEventId,
).toBe(20);
});
it("tracks lastHeardEventId and ackThreshold", () => {
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
+105 -62
View File
@@ -6,6 +6,7 @@ import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { UrbitHttpError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
type UrbitSseLogger = {
@@ -63,7 +64,11 @@ export class UrbitSSEClient {
}> = [];
eventHandlers = new Map<
number,
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
{
event?: (data: unknown) => Promise<void> | void;
err?: (error: unknown) => void;
quit?: () => void;
}
>();
aborted = false;
streamController: AbortController | null = null;
@@ -80,7 +85,7 @@ export class UrbitSSEClient {
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
streamRelease: (() => Promise<void>) | null = null;
// Event ack tracking - must ack every ~50 events to keep channel healthy
// Event ack tracking keeps each HTTP channel's delivered window bounded.
private lastHeardEventId = -1;
private lastAcknowledgedEventId = -1;
private readonly ackThreshold = 20;
@@ -115,10 +120,24 @@ export class UrbitSSEClient {
};
}
private resetChannelIdentity(): void {
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
this.lastHeardEventId = -1;
this.lastAcknowledgedEventId = -1;
}
private async createCurrentChannel(): Promise<void> {
await ensureUrbitChannelOpen(this.channelRequestContext(), {
createBody: this.subscriptions,
createAuditContext: "tlon-urbit-channel-create",
});
}
async subscribe(params: {
app: string;
path: string;
event?: (data: unknown) => void;
event?: (data: unknown) => Promise<void> | void;
err?: (error: unknown) => void;
quit?: () => void;
}) {
@@ -170,10 +189,10 @@ export class UrbitSSEClient {
}
async connect() {
await ensureUrbitChannelOpen(this.channelRequestContext(), {
createBody: this.subscriptions,
createAuditContext: "tlon-urbit-channel-create",
});
// A fresh HTTP channel owns an independent event-id and ack sequence.
this.lastHeardEventId = -1;
this.lastAcknowledgedEventId = -1;
await this.createCurrentChannel();
await this.openStream();
this.isConnected = true;
@@ -218,7 +237,7 @@ export class UrbitSSEClient {
if (!response.ok) {
this.streamRelease = null;
await release();
throw new Error(`Stream connection failed: ${response.status}`);
throw new UrbitHttpError({ operation: "Stream connection", status: response.status });
}
this.processStream(response.body).catch((error: unknown) => {
@@ -263,12 +282,12 @@ export class UrbitSSEClient {
buffer += text;
bufferBytes = nextBytes;
};
const consumeText = (text: string) => {
const consumeText = async (text: string) => {
let offset = 0;
if (pendingDelimiterNewline && text.length > 0) {
pendingDelimiterNewline = false;
if (text.startsWith("\n")) {
this.processEvent(buffer);
await this.processEvent(buffer);
buffer = "";
bufferBytes = 0;
offset = 1;
@@ -287,7 +306,7 @@ export class UrbitSSEClient {
return;
}
appendPending(text.slice(offset, eventEnd));
this.processEvent(buffer);
await this.processEvent(buffer);
buffer = "";
bufferBytes = 0;
offset = eventEnd + 2;
@@ -300,13 +319,13 @@ export class UrbitSSEClient {
break;
}
if (typeof chunk === "string") {
consumeText(decoder.decode());
consumeText(chunk);
await consumeText(decoder.decode());
await consumeText(chunk);
} else {
consumeText(decoder.decode(chunk as Uint8Array, { stream: true }));
await consumeText(decoder.decode(chunk as Uint8Array, { stream: true }));
}
}
consumeText(decoder.decode());
await consumeText(decoder.decode());
} finally {
if (this.streamRelease) {
const release = this.streamRelease;
@@ -322,7 +341,7 @@ export class UrbitSSEClient {
}
}
processEvent(eventData: string) {
async processEvent(eventData: string): Promise<void> {
const lines = eventData.split("\n");
let data: string | null = null;
let eventId: number | null = null;
@@ -340,49 +359,54 @@ export class UrbitSSEClient {
return;
}
// Track event ID and send ack if needed
if (eventId !== null && !Number.isNaN(eventId)) {
if (eventId > this.lastHeardEventId) {
this.lastHeardEventId = eventId;
if (eventId - this.lastAcknowledgedEventId > this.ackThreshold) {
this.logger.log?.(
`[SSE] Acking event ${eventId} (last acked: ${this.lastAcknowledgedEventId})`,
);
this.ack(eventId).catch((err: unknown) => {
this.logger.error?.(`Failed to ack event ${eventId}: ${String(err)}`);
});
}
}
}
let parsed: ReturnType<typeof parseUrbitSsePayload>;
try {
const parsed = parseUrbitSsePayload(data);
if (parsed.response === "quit") {
if (parsed.id) {
const handlers = this.eventHandlers.get(parsed.id);
if (handlers?.quit) {
handlers.quit();
}
}
return;
}
if (parsed.id && this.eventHandlers.has(parsed.id)) {
const { event } = this.eventHandlers.get(parsed.id) ?? {};
if (event && parsed.json) {
event(parsed.json);
}
} else if (parsed.json) {
for (const { event } of this.eventHandlers.values()) {
if (event) {
event(parsed.json);
}
}
}
parsed = parseUrbitSsePayload(data);
} catch (error) {
// Malformed transport payloads are permanent. Count them handled so one
// poison event cannot pin the Urbit channel forever.
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
await this.acknowledgeHandledEventIfNeeded(eventId);
return;
}
if (parsed.response === "quit") {
if (parsed.id) {
const handlers = this.eventHandlers.get(parsed.id);
if (handlers?.quit) {
handlers.quit();
}
}
} else if (parsed.id && this.eventHandlers.has(parsed.id)) {
const { event } = this.eventHandlers.get(parsed.id) ?? {};
if (event && parsed.json) {
await event(parsed.json);
}
} else if (parsed.json) {
for (const { event } of this.eventHandlers.values()) {
if (event) {
await event(parsed.json);
}
}
}
// Handler failures propagate without ack. Durable callbacks resolve only after append.
await this.acknowledgeHandledEventIfNeeded(eventId);
}
private async acknowledgeHandledEventIfNeeded(eventId: number | null): Promise<void> {
if (eventId === null || eventId <= this.lastAcknowledgedEventId) {
return;
}
this.lastHeardEventId = Math.max(this.lastHeardEventId, eventId);
if (this.lastHeardEventId - this.lastAcknowledgedEventId <= this.ackThreshold) {
return;
}
this.logger.log?.(
`[SSE] Acking event ${this.lastHeardEventId} (last acked: ${this.lastAcknowledgedEventId})`,
);
// The acknowledged watermark advances only after PUT succeeds. A replay
// therefore retries a failed ack instead of leaving the subscription clogged.
await this.ack(this.lastHeardEventId);
}
async poke(params: { app: string; mark: string; json: unknown }) {
@@ -414,8 +438,6 @@ export class UrbitSSEClient {
}
private async ack(eventId: number): Promise<void> {
this.lastAcknowledgedEventId = eventId;
const ackData = {
id: Date.now(),
action: "ack",
@@ -431,6 +453,7 @@ export class UrbitSSEClient {
if (!response.ok) {
throw new Error(`Ack failed with status ${response.status}`);
}
this.lastAcknowledgedEventId = eventId;
} finally {
await release();
}
@@ -470,15 +493,31 @@ export class UrbitSSEClient {
setTimeout(resolve, delay);
});
try {
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
if (this.aborted || !this.autoReconnect) {
return;
}
try {
if (this.onReconnect) {
await this.onReconnect(this);
}
await this.connect();
try {
// Reopen the same Eyre channel. Its queue retains every unacked event;
// switching ids here would discard the cursor and strand failed admission.
await this.openStream();
} catch (error) {
if (!(error instanceof UrbitHttpError) || error.status !== 404) {
throw error;
}
// Eyre deletes idle channels. Only a definitive missing-channel response
// permits losing the old cursor and rebuilding every subscription.
this.resetChannelIdentity();
await this.createCurrentChannel();
await this.openStream();
}
this.isConnected = true;
this.reconnectAttempts = 0;
this.logger.log?.("[SSE] Reconnection successful!");
} catch (error) {
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
@@ -486,10 +525,14 @@ export class UrbitSSEClient {
}
}
async close() {
stopReceiving(): void {
this.aborted = true;
this.isConnected = false;
this.streamController?.abort();
}
async close() {
this.stopReceiving();
try {
const unsubscribes = this.subscriptions.map((sub) => ({