@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.0

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.
Files changed (61) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +159 -124
  3. package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
  4. package/contracts/relay-v1.lock.json +77 -0
  5. package/dist/index.js +2 -2
  6. package/dist/setup-entry.js +1 -2
  7. package/dist/src/accounts.js +63 -34
  8. package/dist/src/channel.js +144 -533
  9. package/dist/src/dispatch.js +257 -0
  10. package/dist/src/full-sync.js +24 -0
  11. package/dist/src/gateway.js +171 -0
  12. package/dist/src/inbound.js +54 -85
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -111
  15. package/dist/src/runtime.js +2 -3
  16. package/dist/src/state.js +492 -0
  17. package/dist/src/types.js +1 -3
  18. package/index.ts +1 -2
  19. package/openclaw.plugin.json +15 -18
  20. package/package.json +113 -40
  21. package/setup-entry.ts +0 -2
  22. package/src/accounts.ts +95 -51
  23. package/src/channel.ts +271 -646
  24. package/src/dispatch.ts +324 -0
  25. package/src/full-sync.ts +47 -0
  26. package/src/gateway.ts +216 -0
  27. package/src/inbound.ts +71 -122
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -149
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -162
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -13
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/invocations.js +0 -47
  38. package/dist/src/lifecycle.js +0 -35
  39. package/dist/src/poll-loop.js +0 -137
  40. package/dist/src/responding.js +0 -36
  41. package/dist/src/security.js +0 -26
  42. package/dist/src/state-files.js +0 -243
  43. package/dist/src/vendor/relay-sdk/client.js +0 -163
  44. package/dist/src/vendor/relay-sdk/errors.js +0 -45
  45. package/dist/src/vendor/relay-sdk/types.js +0 -2
  46. package/dist/src/vendor/relay-sdk/url.js +0 -39
  47. package/src/account-lock.ts +0 -108
  48. package/src/client.ts +0 -51
  49. package/src/cursor-store.ts +0 -186
  50. package/src/inbound-dedupe.ts +0 -241
  51. package/src/invocations.ts +0 -58
  52. package/src/lifecycle.ts +0 -42
  53. package/src/poll-loop.ts +0 -173
  54. package/src/responding.ts +0 -52
  55. package/src/security.ts +0 -36
  56. package/src/state-files.ts +0 -298
  57. package/src/vendor/relay-sdk/README.md +0 -28
  58. package/src/vendor/relay-sdk/client.ts +0 -293
  59. package/src/vendor/relay-sdk/errors.ts +0 -61
  60. package/src/vendor/relay-sdk/types.ts +0 -82
  61. package/src/vendor/relay-sdk/url.ts +0 -43
@@ -1,163 +0,0 @@
1
- import { RelayApiError, classifyRelayHttpStatus, isAbortError, } from "./errors.js";
2
- import { normalizeRelayBaseUrl } from "./url.js";
3
- async function readErrorDetail(response) {
4
- try {
5
- const body = (await response.json());
6
- return {
7
- ...(body?.error?.code ? { code: body.error.code } : {}),
8
- ...(body?.error?.details ? { details: body.error.details } : {}),
9
- message: body?.error?.message ?? body?.message ?? "",
10
- };
11
- }
12
- catch {
13
- return { message: "" };
14
- }
15
- }
16
- export function createRelayClient(options) {
17
- if (!options.token.trim()) {
18
- throw new Error("relay: Agent Token is required");
19
- }
20
- const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
21
- const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
22
- const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
23
- const request = async (params) => {
24
- const url = new URL(`${baseUrl}${params.path}`);
25
- for (const [key, value] of Object.entries(params.query ?? {})) {
26
- if (value !== undefined)
27
- url.searchParams.set(key, String(value));
28
- }
29
- const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
30
- const signal = params.signal
31
- ? AbortSignal.any([params.signal, timeoutSignal])
32
- : timeoutSignal;
33
- let response;
34
- try {
35
- response = await fetchImpl(url.toString(), {
36
- method: params.method,
37
- headers: {
38
- authorization: `Bearer ${options.token}`,
39
- ...(params.body === undefined ? {} : { "content-type": "application/json" }),
40
- ...params.headers,
41
- },
42
- ...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
43
- signal,
44
- });
45
- }
46
- catch (error) {
47
- if (timeoutSignal.aborted && !params.signal?.aborted) {
48
- throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
49
- }
50
- if (isAbortError(error))
51
- throw error;
52
- throw new RelayApiError(`relay: network error: ${String(error)}`, {
53
- kind: "retryable",
54
- });
55
- }
56
- if (!response.ok) {
57
- const detail = await readErrorDetail(response);
58
- throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
59
- status: response.status,
60
- kind: classifyRelayHttpStatus(response.status),
61
- ...(detail.code ? { code: detail.code } : {}),
62
- ...(detail.details ? { details: detail.details } : {}),
63
- });
64
- }
65
- return response;
66
- };
67
- const client = {
68
- baseUrl,
69
- getMe: async (params) => {
70
- const response = await request({
71
- method: "GET",
72
- path: "/v1/agents/me",
73
- ...(params?.signal ? { signal: params.signal } : {}),
74
- });
75
- const body = (await response.json());
76
- return body.agent;
77
- },
78
- pollEvents: async (params) => {
79
- const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
80
- const response = await request({
81
- method: "GET",
82
- path: "/v1/events",
83
- query: {
84
- cursor: params.cursor,
85
- timeout: timeoutSeconds,
86
- ...(params.limit === undefined ? {} : { limit: params.limit }),
87
- },
88
- ...(params.signal ? { signal: params.signal } : {}),
89
- timeoutMs: (timeoutSeconds + 15) * 1_000,
90
- });
91
- const body = (await response.json());
92
- const events = Array.isArray(body.events) ? body.events : [];
93
- const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
94
- ? body.next_cursor
95
- : params.cursor;
96
- return { events, nextCursor };
97
- },
98
- sendMessage: async (params) => {
99
- const response = await request({
100
- method: "POST",
101
- path: "/v1/messages",
102
- headers: { "idempotency-key": params.idempotencyKey },
103
- body: {
104
- conversation_id: params.conversationId,
105
- parts: params.parts,
106
- ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
107
- ...(params.replyTo ? { reply_to: params.replyTo } : {}),
108
- },
109
- ...(params.signal ? { signal: params.signal } : {}),
110
- });
111
- const body = (await response.json());
112
- return { messages: body.messages };
113
- },
114
- sendText: async (params) => {
115
- const { text, ...rest } = params;
116
- return client.sendMessage({
117
- ...rest,
118
- parts: [{ type: "text", text }],
119
- });
120
- },
121
- setTyping: async (params) => {
122
- await request({
123
- method: "POST",
124
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
125
- body: {
126
- started: params.started,
127
- ...(params.label ? { label: params.label } : {}),
128
- ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
129
- },
130
- ...(params.signal ? { signal: params.signal } : {}),
131
- });
132
- },
133
- setResponding: async (params) => {
134
- await request({
135
- method: "POST",
136
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
137
- body: {
138
- message_id: params.messageId,
139
- ...(params.label ? { label: params.label } : {}),
140
- ...(params.invocationId ? { invocation_id: params.invocationId } : {}),
141
- },
142
- ...(params.signal ? { signal: params.signal } : {}),
143
- });
144
- },
145
- markDelivered: async (params) => {
146
- await request({
147
- method: "POST",
148
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/delivered`,
149
- body: { message_id: params.messageId },
150
- ...(params.signal ? { signal: params.signal } : {}),
151
- });
152
- },
153
- markRead: async (params) => {
154
- await request({
155
- method: "POST",
156
- path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
157
- body: { message_id: params.messageId },
158
- ...(params.signal ? { signal: params.signal } : {}),
159
- });
160
- },
161
- };
162
- return client;
163
- }
@@ -1,45 +0,0 @@
1
- /** Classified Relay API failure. `terminal` means retrying the same request cannot succeed. */
2
- export class RelayApiError extends Error {
3
- status;
4
- kind;
5
- code;
6
- /** Structured `error.details` from the response body, e.g. `highest_delivered_cursor` on 422. */
7
- details;
8
- constructor(message, params) {
9
- super(message);
10
- this.name = "RelayApiError";
11
- this.status = params.status;
12
- this.kind = params.kind;
13
- this.code = params.code;
14
- this.details = params.details;
15
- }
16
- get terminal() {
17
- return this.kind !== "retryable";
18
- }
19
- get retryable() {
20
- return this.kind === "retryable";
21
- }
22
- }
23
- export class WebhookVerificationError extends Error {
24
- constructor(message) {
25
- super(message);
26
- this.name = "WebhookVerificationError";
27
- }
28
- }
29
- export function classifyRelayHttpStatus(status) {
30
- if (status === 401)
31
- return "auth";
32
- if (status === 409)
33
- return "conflict";
34
- if (status === 408 || status === 429 || status >= 500)
35
- return "retryable";
36
- return "rejected";
37
- }
38
- export function isAbortError(error) {
39
- return error instanceof Error && error.name === "AbortError";
40
- }
41
- export function isRelayWebhookConflict(error) {
42
- return (error instanceof RelayApiError &&
43
- error.status === 409 &&
44
- error.code !== "terminated_by_other_consumer");
45
- }
@@ -1,2 +0,0 @@
1
- /** Wire types for the Relay v1 developer API used by plugins and examples. */
2
- export {};
@@ -1,39 +0,0 @@
1
- import { isIP } from "node:net";
2
- export const DEFAULT_RELAY_BASE_URL = "https://api.relayapp.im";
3
- function isLoopbackHostname(hostname) {
4
- const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
5
- const ipVersion = isIP(normalized);
6
- if (ipVersion === 4)
7
- return normalized.split(".")[0] === "127";
8
- if (ipVersion === 6)
9
- return normalized === "::1";
10
- return normalized === "localhost" || normalized.endsWith(".localhost");
11
- }
12
- /**
13
- * Validate and canonicalize the API origin before a bearer token can be sent.
14
- * Remote origins must use HTTPS. Plain HTTP is allowed only on loopback.
15
- */
16
- export function normalizeRelayBaseUrl(raw) {
17
- const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
18
- let url;
19
- try {
20
- url = new URL(candidate);
21
- }
22
- catch {
23
- throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
24
- }
25
- if (url.username || url.password) {
26
- throw new Error("relay: baseUrl must not contain credentials");
27
- }
28
- if (url.search || url.hash) {
29
- throw new Error("relay: baseUrl must not contain a query or fragment");
30
- }
31
- if (!/^\/+$/u.test(url.pathname)) {
32
- throw new Error("relay: baseUrl must be an origin without a path");
33
- }
34
- if (url.protocol !== "https:" &&
35
- !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
36
- throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
37
- }
38
- return url.origin;
39
- }
@@ -1,108 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import {
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- renameSync,
7
- rmSync,
8
- writeFileSync,
9
- } from "node:fs";
10
- import { homedir } from "node:os";
11
- import { join } from "node:path";
12
-
13
- interface LockOwner {
14
- pid: number;
15
- nonce: string;
16
- account_id: string;
17
- created_at: string;
18
- }
19
-
20
- function processIsLive(pid: number): boolean {
21
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
22
- try {
23
- process.kill(pid, 0);
24
- return true;
25
- } catch (error: any) {
26
- return error?.code === "EPERM";
27
- }
28
- }
29
-
30
- function readOwner(path: string): LockOwner | undefined {
31
- try {
32
- const value = JSON.parse(readFileSync(path, "utf8")) as Partial<LockOwner>;
33
- if (
34
- Number.isSafeInteger(value.pid) &&
35
- typeof value.nonce === "string" &&
36
- typeof value.account_id === "string" &&
37
- typeof value.created_at === "string"
38
- ) {
39
- return value as LockOwner;
40
- }
41
- } catch {
42
- // Missing/malformed ownership is never deleted in place by a contender.
43
- }
44
- return undefined;
45
- }
46
-
47
- /** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
48
- export class RelayAccountLock {
49
- private readonly lockPath: string;
50
- private readonly ownerPath: string;
51
- private readonly nonce = randomUUID();
52
- private held = false;
53
-
54
- constructor(
55
- baseUrl: string,
56
- agentId: string,
57
- private readonly accountId: string,
58
- baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks"),
59
- ) {
60
- const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
61
- this.lockPath = join(baseDir, key);
62
- this.ownerPath = join(this.lockPath, "owner.json");
63
- }
64
-
65
- acquire(): void {
66
- mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
67
- for (let attempt = 0; attempt < 2; attempt += 1) {
68
- try {
69
- mkdirSync(this.lockPath, { mode: 0o700 });
70
- const owner: LockOwner = {
71
- pid: process.pid,
72
- nonce: this.nonce,
73
- account_id: this.accountId,
74
- created_at: new Date().toISOString(),
75
- };
76
- writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
77
- this.held = true;
78
- return;
79
- } catch (error: any) {
80
- if (error?.code !== "EEXIST") throw error;
81
- const owner = readOwner(this.ownerPath);
82
- if (!owner || processIsLive(owner.pid)) {
83
- const claimant = owner
84
- ? `account "${owner.account_id}" (pid ${owner.pid})`
85
- : "an existing process with unreadable ownership";
86
- throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
87
- }
88
- const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
89
- try {
90
- renameSync(this.lockPath, stalePath);
91
- rmSync(stalePath, { recursive: true, force: true });
92
- } catch (renameError: any) {
93
- if (renameError?.code !== "ENOENT") throw renameError;
94
- }
95
- }
96
- }
97
- throw new Error("relay: could not acquire the agent consumer lock");
98
- }
99
-
100
- release(): void {
101
- if (!this.held) return;
102
- const owner = readOwner(this.ownerPath);
103
- if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
104
- rmSync(this.lockPath, { recursive: true, force: true });
105
- }
106
- this.held = false;
107
- }
108
- }
package/src/client.ts DELETED
@@ -1,51 +0,0 @@
1
- // The Relay API client the plugin uses. There is no implementation here: this
2
- // is `@relaymessenger/sdk`'s client, vendored under `./vendor/relay-sdk`
3
- // (see that directory's README for why, and for the one-file swap when the
4
- // package ships).
5
- //
6
- // The plugin used to hand-roll its own client beside the SDK's. They drifted,
7
- // and the drift was a defect: the hand-rolled one had no `invocationId` on
8
- // `sendMessage`, `setTyping`, or `setResponding`, so an agent's first group
9
- // mention wedged its entire event stream (REL-167).
10
- import { createRelayClient as createVendoredRelayClient } from "./vendor/relay-sdk/client.js";
11
- import type { RelayClient as VendoredRelayClient } from "./vendor/relay-sdk/client.js";
12
- import type { RelayEventsPage } from "./types.js";
13
-
14
- export { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./vendor/relay-sdk/url.js";
15
- export {
16
- classifyRelayHttpStatus,
17
- isAbortError,
18
- isRelayWebhookConflict,
19
- RelayApiError,
20
- } from "./vendor/relay-sdk/errors.js";
21
- export type { RelayApiErrorKind } from "./vendor/relay-sdk/errors.js";
22
- export type { RelayClientOptions } from "./vendor/relay-sdk/client.js";
23
- /**
24
- * A message as the server echoes it back from a send. Distinct from
25
- * `types.ts`'s `RelayMessage`, which is the inbound shape the plugin renders
26
- * with its typed parts: a send result is only ever read for its ids.
27
- */
28
- export type { RelayMessage as RelaySentMessage } from "./vendor/relay-sdk/types.js";
29
-
30
- /**
31
- * The vendored client, restated over the plugin's own event types.
32
- *
33
- * The SDK types an event's `data` as an open `Record<string, unknown>`; the
34
- * plugin types the parts of it that it renders (`message`, its typed parts,
35
- * `invocation_id`). Both describe the same JSON at different resolutions, and
36
- * the narrowing happens for real in `buildRelayInboundFacts`, which returns
37
- * null for anything that does not match. This type only names that boundary —
38
- * the object is the SDK's, unmodified, at runtime.
39
- */
40
- export type RelayClient = Omit<VendoredRelayClient, "pollEvents"> & {
41
- pollEvents: (params: {
42
- cursor: number;
43
- timeoutSeconds?: number;
44
- limit?: number;
45
- signal?: AbortSignal;
46
- }) => Promise<RelayEventsPage>;
47
- };
48
-
49
- export const createRelayClient = createVendoredRelayClient as (
50
- options: Parameters<typeof createVendoredRelayClient>[0],
51
- ) => RelayClient;
@@ -1,186 +0,0 @@
1
- // Persisted long-poll cursor, Telegram-offset style:
2
- // monotonic writes only, bound to the agent identity so a token that now
3
- // resolves to a different agent discards the stale cursor instead of acking
4
- // another contact's event stream.
5
- import { createHash } from "node:crypto";
6
- import {
7
- assertRelayStateDocument,
8
- emptyRelayStateDocument,
9
- openRelayStateDocument,
10
- } from "./state-files.js";
11
-
12
- export const RELAY_CURSOR_MAX_ENTRIES = 1_000;
13
- export const RELAY_CURSOR_OVERFLOW_POLICY = "reject-new" as const;
14
-
15
- const RECORD_VERSION = 2;
16
-
17
- export type RelayCursorRecord = {
18
- version: number;
19
- cursor: number;
20
- baseUrl: string;
21
- agentId: string;
22
- };
23
-
24
- /**
25
- * Minimal state-store slice the cursor needs. The channel runtime binds this
26
- * to Relay-owned files; tests can inject a memory map.
27
- */
28
- export type RelayCursorStateStore = {
29
- lookup(key: string): Promise<RelayCursorRecord | undefined>;
30
- register(key: string, value: RelayCursorRecord): Promise<void>;
31
- };
32
-
33
- export type RelayCursorStore = {
34
- /** Loads the persisted cursor; only a genuinely absent stable identity starts at 0. */
35
- load(): Promise<number>;
36
- /** Last accepted cursor (in-memory view). */
37
- current(): number;
38
- /**
39
- * Persist a new cursor. Only called after the batch it acknowledges has been
40
- * durably handled (claim/commit); ignores non-monotonic or invalid values so
41
- * a replayed batch can never move the ack backwards.
42
- */
43
- advance(cursor: number): Promise<void>;
44
- };
45
-
46
- /**
47
- * Open Relay's private, lock-protected state file with fail-closed capacity
48
- * semantics. A cursor is permanent safety state: evicting an old identity to
49
- * admit a new one could replay retained events when the old identity returns.
50
- */
51
- export function openRelayCursorStateStore(
52
- warn: (line: string) => void,
53
- options: { env?: NodeJS.ProcessEnv; maxEntries?: number } = {},
54
- ): RelayCursorStateStore {
55
- const maxEntries = options.maxEntries ?? RELAY_CURSOR_MAX_ENTRIES;
56
- if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
57
- throw new Error("relay cursor maxEntries must be a positive safe integer");
58
- }
59
- const store = openRelayStateDocument<RelayCursorRecord>({
60
- fileName: "cursors.json",
61
- ...(options.env ? { env: options.env } : {}),
62
- });
63
- const storageKey = (key: string) => createHash("sha256").update(key).digest("hex");
64
- const validateEntry = (key: string, value: unknown): value is RelayCursorRecord => {
65
- if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object") return false;
66
- const record = value as Partial<RelayCursorRecord>;
67
- return (
68
- record.version === RECORD_VERSION &&
69
- isValidCursor(record.cursor) &&
70
- typeof record.baseUrl === "string" &&
71
- (() => {
72
- try {
73
- return new URL(record.baseUrl).origin === record.baseUrl;
74
- } catch {
75
- return false;
76
- }
77
- })() &&
78
- typeof record.agentId === "string" &&
79
- record.agentId.length > 0
80
- );
81
- };
82
- const read = async () => {
83
- try {
84
- const current = await store.read();
85
- if (current === undefined) return emptyRelayStateDocument<RelayCursorRecord>();
86
- assertRelayStateDocument(current, "cursor", validateEntry);
87
- return current;
88
- } catch (error) {
89
- warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
90
- throw error;
91
- }
92
- };
93
- return {
94
- lookup: async (key) => (await read()).entries[storageKey(key)],
95
- register: async (key, value) => {
96
- try {
97
- await store.updateOr(emptyRelayStateDocument<RelayCursorRecord>(), (current) => {
98
- assertRelayStateDocument(current, "cursor", validateEntry);
99
- const hashedKey = storageKey(key);
100
- if (
101
- !Object.hasOwn(current.entries, hashedKey) &&
102
- Object.keys(current.entries).length >= maxEntries
103
- ) {
104
- throw new Error(
105
- `relay cursor state reached ${maxEntries} identities (${RELAY_CURSOR_OVERFLOW_POLICY})`,
106
- );
107
- }
108
- return {
109
- version: current.version,
110
- entries: { ...current.entries, [hashedKey]: value },
111
- };
112
- });
113
- } catch (error) {
114
- warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
115
- throw error;
116
- }
117
- },
118
- };
119
- }
120
-
121
- function isValidCursor(value: unknown): value is number {
122
- return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
123
- }
124
-
125
- export function createRelayCursorStore(params: {
126
- store: RelayCursorStateStore;
127
- baseUrl: string;
128
- agentId: string;
129
- onPersistError?: (error: unknown) => void;
130
- }): RelayCursorStore {
131
- const baseUrl = new URL(params.baseUrl).origin;
132
- const key = `relay:${baseUrl}:${params.agentId}`;
133
- let cursor = 0;
134
- let loaded = false;
135
-
136
- return {
137
- load: async () => {
138
- let record: RelayCursorRecord | undefined;
139
- try {
140
- record = await params.store.lookup(key);
141
- } catch (error) {
142
- params.onPersistError?.(error);
143
- throw new Error(`relay cursor state could not be loaded: ${String(error)}`);
144
- }
145
- if (!record) {
146
- cursor = 0;
147
- loaded = true;
148
- return cursor;
149
- }
150
- if (
151
- record.version !== RECORD_VERSION ||
152
- !isValidCursor(record.cursor) ||
153
- record.agentId !== params.agentId ||
154
- record.baseUrl !== baseUrl
155
- ) {
156
- throw new Error(`relay cursor state is corrupt for ${baseUrl} ${params.agentId}; refusing cursor-zero replay`);
157
- }
158
- cursor = record.cursor;
159
- loaded = true;
160
- return cursor;
161
- },
162
-
163
- current: () => cursor,
164
-
165
- advance: async (next) => {
166
- if (!loaded) {
167
- throw new Error("relay cursor store: advance() before load()");
168
- }
169
- if (!isValidCursor(next) || next <= cursor) {
170
- return;
171
- }
172
- try {
173
- await params.store.register(key, {
174
- version: RECORD_VERSION,
175
- cursor: next,
176
- baseUrl,
177
- agentId: params.agentId,
178
- });
179
- } catch (error) {
180
- params.onPersistError?.(error);
181
- throw new Error(`relay cursor advance was not durable: ${String(error)}`);
182
- }
183
- cursor = next;
184
- },
185
- };
186
- }