@agentrux/agentrux-openclaw-plugin 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ /**
3
+ * AgenTrux Ingress + Tools plugin for OpenClaw.
4
+ *
5
+ * Tools (LLM-callable):
6
+ * - agentrux_activate, agentrux_publish, agentrux_read,
7
+ * agentrux_send_message, agentrux_redeem_grant
8
+ *
9
+ * Ingress (external → OpenClaw):
10
+ * - registerHttpRoute("/agentrux/webhook") — AgenTrux Webhook receiver
11
+ * - Dispatcher (async loop) — subagent.run() + outbox + waterline cursor
12
+ * - SafetyPoller — gap detection fallback
13
+ *
14
+ * Credentials: ~/.agentrux/credentials.json (0600)
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.default = default_1;
18
+ const credentials_1 = require("./credentials");
19
+ const http_client_1 = require("./http-client");
20
+ const cursor_1 = require("./cursor");
21
+ const queue_1 = require("./queue");
22
+ const webhook_handler_1 = require("./webhook-handler");
23
+ const dispatcher_1 = require("./dispatcher");
24
+ const poller_1 = require("./poller");
25
+ const sse_listener_1 = require("./sse-listener");
26
+ // Module-level state for tools (shared across tool calls)
27
+ let credentials = null;
28
+ function getCredentials() {
29
+ if (!credentials) {
30
+ credentials = (0, credentials_1.loadCredentials)();
31
+ if (!credentials)
32
+ throw new Error("Not connected to AgenTrux. Use activate first.");
33
+ }
34
+ return credentials;
35
+ }
36
+ async function toolAuthRequest(method, urlPath, body) {
37
+ return (0, http_client_1.authRequest)(getCredentials(), method, urlPath, body);
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Plugin entry
41
+ // ---------------------------------------------------------------------------
42
+ function default_1(api) {
43
+ // Ensure logger has all required methods (OpenClaw logger may not match console interface)
44
+ const rawLogger = api.logger || {};
45
+ const logger = {
46
+ info: (...a) => (rawLogger.info || console.log)(...a),
47
+ warn: (...a) => (rawLogger.warn || console.warn)(...a),
48
+ error: (...a) => (rawLogger.error || console.error)(...a),
49
+ };
50
+ // =======================================================================
51
+ // TOOLS (available to LLM in all modes)
52
+ // =======================================================================
53
+ // --- activate ---
54
+ api.registerTool({
55
+ name: "agentrux_activate",
56
+ description: "Connect to AgenTrux with a one-time activation code. " +
57
+ "Returns script_id, client_secret, and available topics. " +
58
+ "Credentials are saved permanently for future sessions.",
59
+ parameters: {
60
+ type: "object",
61
+ properties: {
62
+ activation_code: { type: "string", description: "One-time activation code (ac_...)" },
63
+ base_url: { type: "string", description: "AgenTrux API URL (default: https://api.agentrux.com)" },
64
+ },
65
+ required: ["activation_code"],
66
+ },
67
+ async execute(_id, params) {
68
+ const baseUrl = params.base_url || "https://api.agentrux.com";
69
+ const r = await (0, http_client_1.httpJson)("POST", `${baseUrl}/auth/activate`, {
70
+ activation_code: params.activation_code,
71
+ });
72
+ if (r.status !== 200) {
73
+ return { content: [{ type: "text", text: `Activation failed: ${JSON.stringify(r.data)}` }] };
74
+ }
75
+ const creds = { base_url: baseUrl, script_id: r.data.script_id, clientSecret: r.data.client_secret };
76
+ (0, credentials_1.saveCredentials)(creds);
77
+ credentials = creds;
78
+ const grants = (r.data.grants || []).map((g) => ` - ${g.topic_id} (${g.action})`).join("\n");
79
+ return {
80
+ content: [{ type: "text", text: `Connected to AgenTrux!\nScript ID: ${r.data.script_id}\nTopics:\n${grants}` }],
81
+ };
82
+ },
83
+ }, { optional: true });
84
+ // --- publish ---
85
+ api.registerTool({
86
+ name: "agentrux_publish",
87
+ description: "Publish an event to an AgenTrux topic.",
88
+ parameters: {
89
+ type: "object",
90
+ properties: {
91
+ topic_id: { type: "string", description: "UUID of the topic" },
92
+ event_type: { type: "string", description: "Event type (e.g. 'message.send')" },
93
+ payload: { type: "object", description: "JSON payload" },
94
+ correlation_id: { type: "string", description: "Optional correlation ID" },
95
+ reply_topic: { type: "string", description: "Optional reply topic UUID" },
96
+ },
97
+ required: ["topic_id", "event_type", "payload"],
98
+ },
99
+ async execute(_id, params) {
100
+ const body = { type: params.event_type, payload: params.payload };
101
+ if (params.correlation_id)
102
+ body.correlation_id = params.correlation_id;
103
+ if (params.reply_topic)
104
+ body.reply_topic = params.reply_topic;
105
+ const result = await toolAuthRequest("POST", `/topics/${params.topic_id}/events`, body);
106
+ return { content: [{ type: "text", text: `Event published (event_id: ${result.event_id})` }] };
107
+ },
108
+ });
109
+ // --- read ---
110
+ api.registerTool({
111
+ name: "agentrux_read",
112
+ description: "Read events from an AgenTrux topic.",
113
+ parameters: {
114
+ type: "object",
115
+ properties: {
116
+ topic_id: { type: "string", description: "UUID of the topic" },
117
+ limit: { type: "number", description: "Max events to return (default 10)" },
118
+ event_type: { type: "string", description: "Filter by event type" },
119
+ },
120
+ required: ["topic_id"],
121
+ },
122
+ async execute(_id, params) {
123
+ const query = new URLSearchParams();
124
+ query.set("limit", String(params.limit || 10));
125
+ if (params.event_type)
126
+ query.set("type", params.event_type);
127
+ const result = await toolAuthRequest("GET", `/topics/${params.topic_id}/events?${query}`);
128
+ const items = result.items || [];
129
+ if (items.length === 0)
130
+ return { content: [{ type: "text", text: "No events found." }] };
131
+ const lines = items.map((e) => `[seq:${e.sequence_no}] ${e.type} — ${JSON.stringify(e.payload)} (${e.correlation_id || "-"})`);
132
+ return { content: [{ type: "text", text: `${items.length} events:\n${lines.join("\n")}` }] };
133
+ },
134
+ });
135
+ // --- send_message ---
136
+ api.registerTool({
137
+ name: "agentrux_send_message",
138
+ description: "Send a message to another agent and wait for a reply.",
139
+ parameters: {
140
+ type: "object",
141
+ properties: {
142
+ topic_id: { type: "string", description: "Target agent's topic UUID" },
143
+ reply_topic: { type: "string", description: "Your topic UUID for replies" },
144
+ message: { type: "string", description: "Message text" },
145
+ timeout_seconds: { type: "number", description: "Wait timeout (default 30)" },
146
+ },
147
+ required: ["topic_id", "reply_topic", "message"],
148
+ },
149
+ async execute(_id, params) {
150
+ const corrId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
151
+ const timeout = (params.timeout_seconds || 30) * 1000;
152
+ await toolAuthRequest("POST", `/topics/${params.topic_id}/events`, {
153
+ type: "message.request",
154
+ payload: { text: params.message },
155
+ correlation_id: corrId,
156
+ reply_topic: params.reply_topic,
157
+ });
158
+ const start = Date.now();
159
+ while (Date.now() - start < timeout) {
160
+ const result = await toolAuthRequest("GET", `/topics/${params.reply_topic}/events?limit=20`);
161
+ for (const e of result.items || []) {
162
+ if (e.correlation_id === corrId) {
163
+ return { content: [{ type: "text", text: `Reply:\n${JSON.stringify(e.payload, null, 2)}` }] };
164
+ }
165
+ }
166
+ await new Promise((r) => setTimeout(r, 2000));
167
+ }
168
+ return { content: [{ type: "text", text: `No reply within ${params.timeout_seconds || 30}s.` }] };
169
+ },
170
+ });
171
+ // --- redeem_grant ---
172
+ api.registerTool({
173
+ name: "agentrux_redeem_grant",
174
+ description: "Redeem an invite code for cross-account topic access.",
175
+ parameters: {
176
+ type: "object",
177
+ properties: { invite_code: { type: "string", description: "Invite code (inv_...)" } },
178
+ required: ["invite_code"],
179
+ },
180
+ async execute(_id, params) {
181
+ const creds = getCredentials();
182
+ const r = await (0, http_client_1.httpJson)("POST", `${creds.base_url}/auth/redeem-invite-code`, {
183
+ invite_code: params.invite_code,
184
+ script_id: creds.script_id,
185
+ client_secret: creds.clientSecret,
186
+ });
187
+ if (r.status >= 400) {
188
+ return { content: [{ type: "text", text: `Failed: ${JSON.stringify(r.data)}` }] };
189
+ }
190
+ (0, http_client_1.invalidateToken)();
191
+ return {
192
+ content: [{ type: "text", text: `Access granted! Topic: ${r.data.topic_id} (${r.data.action})` }],
193
+ };
194
+ },
195
+ }, { optional: true });
196
+ // --- upload ---
197
+ api.registerTool({
198
+ name: "agentrux_upload",
199
+ description: "Upload a local file to AgenTrux and get a download URL. " +
200
+ "Use this to attach screenshots, logs, or other files to your response.",
201
+ parameters: {
202
+ type: "object",
203
+ properties: {
204
+ topic_id: { type: "string", description: "Topic UUID to attach the file to" },
205
+ file_path: { type: "string", description: "Absolute path to the local file" },
206
+ content_type: { type: "string", description: "MIME type (e.g. image/png, text/plain)" },
207
+ },
208
+ required: ["topic_id", "file_path", "content_type"],
209
+ },
210
+ async execute(_id, params) {
211
+ const creds = getCredentials();
212
+ const result = await (0, http_client_1.uploadFile)(creds, params.topic_id, params.file_path, params.content_type);
213
+ return {
214
+ content: [{
215
+ type: "text",
216
+ text: `File uploaded!\nObject ID: ${result.object_id}\nDownload URL: ${result.download_url}`,
217
+ }],
218
+ };
219
+ },
220
+ });
221
+ // =======================================================================
222
+ // INGRESS (Webhook + Dispatcher + Poller — only in full registration mode)
223
+ // =======================================================================
224
+ if (api.registrationMode !== "full")
225
+ return;
226
+ // Prevent multiple ingress starts (plugin loaded multiple times by OpenClaw)
227
+ if (globalThis.__agentruxIngressStarted)
228
+ return;
229
+ globalThis.__agentruxIngressStarted = true;
230
+ // Clear token cache on startup to pick up latest grant scopes
231
+ (0, http_client_1.invalidateToken)();
232
+ // pluginConfig may be undefined if OpenClaw doesn't resolve it due to id mismatch
233
+ // Fallback: read from api.config (full OpenClaw config)
234
+ const pluginConfig = api.pluginConfig
235
+ || api.config?.plugins?.entries?.["agentrux-openclaw-plugin"]?.config
236
+ || {};
237
+ const commandTopicId = pluginConfig.commandTopicId;
238
+ const resultTopicId = pluginConfig.resultTopicId;
239
+ const webhookSecret = pluginConfig.webhookSecret;
240
+ const agentId = pluginConfig.agentId;
241
+ // Skip ingress if not configured
242
+ if (!commandTopicId || !resultTopicId || !agentId) {
243
+ logger.info?.("[agentrux] Ingress not configured (missing commandTopicId/resultTopicId/agentId). Tools-only mode.");
244
+ return;
245
+ }
246
+ const creds = (0, credentials_1.loadCredentials)();
247
+ if (!creds) {
248
+ logger.warn?.("[agentrux] No credentials found. Run agentrux_activate first. Ingress disabled.");
249
+ return;
250
+ }
251
+ // Initialize components
252
+ const cursor = (0, cursor_1.loadCursor)();
253
+ const queue = new queue_1.BoundedQueue(100);
254
+ const dispatcher = new dispatcher_1.Dispatcher({
255
+ commandTopicId,
256
+ resultTopicId,
257
+ agentId,
258
+ maxConcurrency: pluginConfig.maxConcurrency || 3,
259
+ subagentTimeoutMs: pluginConfig.subagentTimeoutMs || 120_000,
260
+ gatewayPort: 18789, // OpenClaw default gateway port
261
+ }, creds, cursor, queue, logger);
262
+ const ingressMode = pluginConfig.ingressMode || "webhook";
263
+ const poller = new poller_1.SafetyPoller(creds, commandTopicId, cursor, queue, pluginConfig.pollIntervalMs || 60_000, // default 60s
264
+ logger);
265
+ // SSE listener (only when ingressMode=sse)
266
+ const sseListener = ingressMode === "sse"
267
+ ? new sse_listener_1.SSEListener(creds, commandTopicId, queue, logger)
268
+ : null;
269
+ // --- Internal dispatch endpoint (subagent.run() in Gateway request context) ---
270
+ api.registerHttpRoute({
271
+ path: "/agentrux/dispatch",
272
+ auth: "plugin",
273
+ match: "exact",
274
+ handler: async (req, res) => {
275
+ const chunks = [];
276
+ await new Promise((resolve) => {
277
+ req.on("data", (c) => chunks.push(c));
278
+ req.on("end", resolve);
279
+ });
280
+ const params = JSON.parse(Buffer.concat(chunks).toString());
281
+ try {
282
+ // OpenClaw 2026.3.24+: provider/model override is not authorized
283
+ // for plugin subagent runs. The agent's configured model is used.
284
+ const { runId } = await api.runtime.subagent.run({
285
+ sessionKey: params.sessionKey,
286
+ message: params.message,
287
+ idempotencyKey: params.idempotencyKey,
288
+ deliver: false,
289
+ });
290
+ const waitResult = await api.runtime.subagent.waitForRun({
291
+ runId,
292
+ timeoutMs: params.timeoutMs || 60_000,
293
+ });
294
+ let responseText = "";
295
+ if (waitResult.status === "ok") {
296
+ const { messages } = await api.runtime.subagent.getSessionMessages({
297
+ sessionKey: params.sessionKey,
298
+ });
299
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
300
+ if (lastAssistant?.content) {
301
+ for (const block of lastAssistant.content) {
302
+ if (block.type === "text" && block.text)
303
+ responseText += block.text;
304
+ }
305
+ }
306
+ }
307
+ res.statusCode = 200;
308
+ res.setHeader("Content-Type", "application/json");
309
+ res.end(JSON.stringify({ responseText, status: waitResult.status }));
310
+ }
311
+ catch (e) {
312
+ res.statusCode = 500;
313
+ res.end(JSON.stringify({ responseText: "", status: "error", error: e.message }));
314
+ }
315
+ return true;
316
+ },
317
+ });
318
+ // --- Webhook endpoint (only when ingressMode=webhook) ---
319
+ if (ingressMode === "webhook" && webhookSecret) {
320
+ api.registerHttpRoute({
321
+ path: "/agentrux/webhook",
322
+ auth: "plugin",
323
+ match: "exact",
324
+ handler: (0, webhook_handler_1.createWebhookHandler)(queue, webhookSecret, commandTopicId),
325
+ });
326
+ logger.info?.("[agentrux] Webhook endpoint registered: /agentrux/webhook");
327
+ }
328
+ else if (ingressMode === "webhook" && !webhookSecret) {
329
+ logger.warn?.("[agentrux] ingressMode=webhook but webhookSecret not set. Falling back to poll-only.");
330
+ }
331
+ // --- Start Dispatcher + (Webhook|SSE) + Poller ---
332
+ setTimeout(() => {
333
+ try {
334
+ dispatcher.start().catch((e) => logger.error("[agentrux] Dispatcher failed:", e));
335
+ if (sseListener) {
336
+ sseListener.start().catch((e) => logger.error("[agentrux] SSE failed:", e));
337
+ }
338
+ poller.start().catch((e) => logger.error("[agentrux] Poller failed:", e));
339
+ logger.info(`[agentrux] Ingress started: mode=${ingressMode} topic=${commandTopicId} agent=${agentId} poll@${pluginConfig.pollIntervalMs || 60000}ms`);
340
+ }
341
+ catch (e) {
342
+ logger.error("[agentrux] Ingress startup error:", e);
343
+ }
344
+ }, 5000);
345
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Outbox pattern: decouple subagent completion from result publish.
3
+ * Entries: pending_publish → published | dead_letter
4
+ * Published entries are retained for idempotency across restarts.
5
+ */
6
+ import { type Credentials } from "./credentials";
7
+ export interface OutboxEntry {
8
+ eventId: string;
9
+ requestId: string;
10
+ sequenceNo: number;
11
+ status: "pending_publish" | "published" | "dead_letter";
12
+ result: {
13
+ message: string;
14
+ status: string;
15
+ conversationKey?: string;
16
+ attachments?: Array<{
17
+ url: string;
18
+ type: string;
19
+ }>;
20
+ };
21
+ createdAt: string;
22
+ retryCount: number;
23
+ }
24
+ export declare function addToOutbox(entry: Omit<OutboxEntry, "status" | "retryCount" | "createdAt">): void;
25
+ /**
26
+ * Find by request_id across all states (pending, published, dead_letter).
27
+ * Used for application-layer idempotency.
28
+ */
29
+ export declare function findByRequestId(requestId: string): OutboxEntry | undefined;
30
+ /**
31
+ * Flush pending_publish entries: attempt publish, move to published or dead_letter.
32
+ * Returns sequence numbers that were finalized (published or dead_letter).
33
+ */
34
+ export declare function flushOutbox(creds: Credentials, resultTopicId: string, logger: {
35
+ error: (...args: any[]) => void;
36
+ warn: (...args: any[]) => void;
37
+ }): Promise<number[]>;
package/dist/outbox.js ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * Outbox pattern: decouple subagent completion from result publish.
4
+ * Entries: pending_publish → published | dead_letter
5
+ * Published entries are retained for idempotency across restarts.
6
+ */
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ Object.defineProperty(exports, "__esModule", { value: true });
41
+ exports.addToOutbox = addToOutbox;
42
+ exports.findByRequestId = findByRequestId;
43
+ exports.flushOutbox = flushOutbox;
44
+ const fs = __importStar(require("fs"));
45
+ const path = __importStar(require("path"));
46
+ const http_client_1 = require("./http-client");
47
+ const HOME = process.env.HOME || process.env.USERPROFILE || "~";
48
+ const OUTBOX_PATH = path.join(HOME, ".agentrux", "outbox.json");
49
+ const MAX_RETRIES = 3;
50
+ const MAX_PUBLISHED_HISTORY = 500;
51
+ function loadStore() {
52
+ try {
53
+ if (fs.existsSync(OUTBOX_PATH)) {
54
+ const raw = JSON.parse(fs.readFileSync(OUTBOX_PATH, "utf-8"));
55
+ return {
56
+ entries: raw.entries || [],
57
+ published: raw.published || [],
58
+ deadLetter: raw.deadLetter || [],
59
+ };
60
+ }
61
+ }
62
+ catch { }
63
+ return { entries: [], published: [], deadLetter: [] };
64
+ }
65
+ function persistStore(store) {
66
+ const dir = path.dirname(OUTBOX_PATH);
67
+ if (!fs.existsSync(dir))
68
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ const tmpPath = OUTBOX_PATH + ".tmp." + process.pid;
70
+ fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600 });
71
+ try {
72
+ fs.fsyncSync(fs.openSync(tmpPath, "r"));
73
+ }
74
+ catch { }
75
+ fs.renameSync(tmpPath, OUTBOX_PATH);
76
+ }
77
+ function addToOutbox(entry) {
78
+ const store = loadStore();
79
+ store.entries.push({
80
+ ...entry,
81
+ status: "pending_publish",
82
+ retryCount: 0,
83
+ createdAt: new Date().toISOString(),
84
+ });
85
+ persistStore(store);
86
+ }
87
+ /**
88
+ * Find by request_id across all states (pending, published, dead_letter).
89
+ * Used for application-layer idempotency.
90
+ */
91
+ function findByRequestId(requestId) {
92
+ const store = loadStore();
93
+ return (store.entries.find((e) => e.requestId === requestId) ||
94
+ store.published.find((e) => e.requestId === requestId) ||
95
+ store.deadLetter.find((e) => e.requestId === requestId));
96
+ }
97
+ /**
98
+ * Flush pending_publish entries: attempt publish, move to published or dead_letter.
99
+ * Returns sequence numbers that were finalized (published or dead_letter).
100
+ */
101
+ async function flushOutbox(creds, resultTopicId, logger) {
102
+ const store = loadStore();
103
+ const finalized = [];
104
+ for (const entry of store.entries) {
105
+ if (entry.status !== "pending_publish")
106
+ continue;
107
+ try {
108
+ await (0, http_client_1.publishEvent)(creds, resultTopicId, "openclaw.response", {
109
+ request_id: entry.requestId,
110
+ conversation_key: entry.result.conversationKey,
111
+ status: entry.result.status,
112
+ message: entry.result.message,
113
+ });
114
+ entry.status = "published";
115
+ store.published.push({ ...entry });
116
+ finalized.push(entry.sequenceNo);
117
+ }
118
+ catch (e) {
119
+ entry.retryCount++;
120
+ if (entry.retryCount >= MAX_RETRIES) {
121
+ entry.status = "dead_letter";
122
+ store.deadLetter.push({ ...entry });
123
+ finalized.push(entry.sequenceNo);
124
+ logger.warn(`Outbox dead_letter: requestId=${entry.requestId} seq=${entry.sequenceNo}`);
125
+ }
126
+ else {
127
+ logger.error(`Outbox publish retry ${entry.retryCount}/${MAX_RETRIES}: ${e.message}`);
128
+ }
129
+ }
130
+ }
131
+ // Remove finalized from entries (keep only pending)
132
+ store.entries = store.entries.filter((e) => e.status === "pending_publish");
133
+ // Cap published history
134
+ if (store.published.length > MAX_PUBLISHED_HISTORY) {
135
+ store.published = store.published.slice(-MAX_PUBLISHED_HISTORY);
136
+ }
137
+ if (store.deadLetter.length > 1000) {
138
+ store.deadLetter = store.deadLetter.slice(-1000);
139
+ }
140
+ persistStore(store);
141
+ return finalized;
142
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Safety poller: fixed-interval Pull API to detect gaps and complement Webhook.
3
+ * Only enqueues events not already processed or in-flight.
4
+ */
5
+ import { type Credentials } from "./credentials";
6
+ import { type CursorState } from "./cursor";
7
+ import { type BoundedQueue } from "./queue";
8
+ export declare class SafetyPoller {
9
+ private creds;
10
+ private commandTopicId;
11
+ private cursor;
12
+ private queue;
13
+ private intervalMs;
14
+ private logger;
15
+ private stopped;
16
+ private running;
17
+ constructor(creds: Credentials, commandTopicId: string, cursor: CursorState, queue: BoundedQueue, intervalMs: number, logger: {
18
+ info: (...a: any[]) => void;
19
+ error: (...a: any[]) => void;
20
+ });
21
+ start(): Promise<void>;
22
+ stop(): void;
23
+ private loop;
24
+ private poll;
25
+ }
package/dist/poller.js ADDED
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Safety poller: fixed-interval Pull API to detect gaps and complement Webhook.
4
+ * Only enqueues events not already processed or in-flight.
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.SafetyPoller = void 0;
8
+ const http_client_1 = require("./http-client");
9
+ const cursor_1 = require("./cursor");
10
+ class SafetyPoller {
11
+ constructor(creds, commandTopicId, cursor, queue, intervalMs, logger) {
12
+ this.creds = creds;
13
+ this.commandTopicId = commandTopicId;
14
+ this.cursor = cursor;
15
+ this.queue = queue;
16
+ this.intervalMs = intervalMs;
17
+ this.logger = logger;
18
+ this.stopped = false;
19
+ this.running = false;
20
+ }
21
+ async start() {
22
+ if (this.running)
23
+ return;
24
+ this.running = true;
25
+ this.stopped = false;
26
+ this.loop();
27
+ }
28
+ stop() {
29
+ this.stopped = true;
30
+ }
31
+ async loop() {
32
+ try {
33
+ while (!this.stopped) {
34
+ try {
35
+ await this.poll();
36
+ }
37
+ catch (e) {
38
+ this.logger.error("Safety poller error:", e.message);
39
+ }
40
+ await sleep(this.intervalMs);
41
+ }
42
+ }
43
+ finally {
44
+ this.running = false;
45
+ }
46
+ }
47
+ async poll() {
48
+ const events = await (0, http_client_1.pullEvents)(this.creds, this.commandTopicId, this.cursor.waterline, 20);
49
+ let enqueued = 0;
50
+ for (const evt of events) {
51
+ const seq = evt.sequence_no;
52
+ // Skip already processed
53
+ if (seq <= this.cursor.waterline)
54
+ continue;
55
+ if (this.cursor.completed.has(seq))
56
+ continue;
57
+ if (this.cursor.inFlight.has(seq))
58
+ continue;
59
+ if ((0, cursor_1.isEventProcessed)(this.cursor, evt.event_id))
60
+ continue;
61
+ const ok = this.queue.enqueue({
62
+ topicId: this.commandTopicId,
63
+ latestSequenceNo: seq,
64
+ eventId: evt.event_id,
65
+ timestamp: Date.now(),
66
+ });
67
+ if (ok)
68
+ enqueued++;
69
+ }
70
+ if (enqueued > 0) {
71
+ this.logger.info(`Safety poller: enqueued ${enqueued} new events`);
72
+ }
73
+ }
74
+ }
75
+ exports.SafetyPoller = SafetyPoller;
76
+ function sleep(ms) {
77
+ return new Promise((r) => setTimeout(r, ms));
78
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Bounded in-memory queue for event hints.
3
+ */
4
+ export interface QueueItem {
5
+ topicId: string;
6
+ latestSequenceNo: number;
7
+ eventId?: string;
8
+ timestamp: number;
9
+ }
10
+ export declare class BoundedQueue {
11
+ private items;
12
+ private maxSize;
13
+ constructor(maxSize?: number);
14
+ enqueue(item: QueueItem): boolean;
15
+ dequeue(count: number): QueueItem[];
16
+ get size(): number;
17
+ get isFull(): boolean;
18
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ /**
3
+ * Bounded in-memory queue for event hints.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BoundedQueue = void 0;
7
+ class BoundedQueue {
8
+ constructor(maxSize = 100) {
9
+ this.items = [];
10
+ this.maxSize = maxSize;
11
+ }
12
+ enqueue(item) {
13
+ if (this.items.length >= this.maxSize) {
14
+ return false; // queue full — caller should return 503
15
+ }
16
+ this.items.push(item);
17
+ return true;
18
+ }
19
+ dequeue(count) {
20
+ return this.items.splice(0, Math.min(count, this.items.length));
21
+ }
22
+ get size() {
23
+ return this.items.length;
24
+ }
25
+ get isFull() {
26
+ return this.items.length >= this.maxSize;
27
+ }
28
+ }
29
+ exports.BoundedQueue = BoundedQueue;