@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.
- package/LICENSE +1 -1
- package/README.md +159 -124
- package/contracts/relay-sdk-0.3.0-staging.4.registry.json +58 -0
- package/contracts/relay-v1.lock.json +77 -0
- package/dist/index.js +2 -2
- package/dist/setup-entry.js +1 -2
- package/dist/src/accounts.js +63 -34
- package/dist/src/channel.js +144 -533
- package/dist/src/dispatch.js +257 -0
- package/dist/src/full-sync.js +24 -0
- package/dist/src/gateway.js +171 -0
- package/dist/src/inbound.js +54 -85
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -111
- package/dist/src/runtime.js +2 -3
- package/dist/src/state.js +492 -0
- package/dist/src/types.js +1 -3
- package/index.ts +1 -2
- package/openclaw.plugin.json +15 -18
- package/package.json +113 -40
- package/setup-entry.ts +0 -2
- package/src/accounts.ts +95 -51
- package/src/channel.ts +271 -646
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -122
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -149
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -162
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -13
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/invocations.js +0 -47
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -137
- package/dist/src/responding.js +0 -36
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -243
- package/dist/src/vendor/relay-sdk/client.js +0 -163
- package/dist/src/vendor/relay-sdk/errors.js +0 -45
- package/dist/src/vendor/relay-sdk/types.js +0 -2
- package/dist/src/vendor/relay-sdk/url.js +0 -39
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -51
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/invocations.ts +0 -58
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -173
- package/src/responding.ts +0 -52
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -298
- package/src/vendor/relay-sdk/README.md +0 -28
- package/src/vendor/relay-sdk/client.ts +0 -293
- package/src/vendor/relay-sdk/errors.ts +0 -61
- package/src/vendor/relay-sdk/types.ts +0 -82
- package/src/vendor/relay-sdk/url.ts +0 -43
package/src/types.ts
CHANGED
|
@@ -1,155 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
id: string;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
// Inline mention of a conversation participant. Offsets are UTF-16 code
|
|
14
|
-
// units into the part's text, which holds the inserted display name with
|
|
15
|
-
// no "@". Ranges are sorted by start and never overlap.
|
|
16
|
-
export type RelayMentionRange = {
|
|
17
|
-
start: number;
|
|
18
|
-
length: number;
|
|
19
|
-
participant_id: string;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export type RelayTextStyle = "bold" | "italic" | "underline" | "strikethrough" | "monospace" | "spoiler";
|
|
23
|
-
|
|
24
|
-
// One formatting run over a text part, offsets in UTF-16 code units like
|
|
25
|
-
// mentions. An EMPTY styles array on the part is meaningful: it marks
|
|
26
|
-
// structured plain text as opposed to a legacy Markdown body.
|
|
27
|
-
export type RelayStyleRange = {
|
|
28
|
-
start: number;
|
|
29
|
-
length: number;
|
|
30
|
-
styles: RelayTextStyle[];
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export type RelayTextPart = {
|
|
34
|
-
part_index?: number;
|
|
35
|
-
type: "text";
|
|
36
|
-
text: string;
|
|
37
|
-
mentions?: RelayMentionRange[];
|
|
38
|
-
styles?: RelayStyleRange[];
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type RelayMediaPart = {
|
|
42
|
-
part_index?: number;
|
|
43
|
-
type: "media";
|
|
44
|
-
url: string;
|
|
45
|
-
attachment_id?: string;
|
|
46
|
-
// Pixel dimensions (always paired) and a blurhash placeholder (base83)
|
|
47
|
-
// to draw before the bytes download.
|
|
48
|
-
width?: number;
|
|
49
|
-
height?: number;
|
|
50
|
-
blur_hash?: string;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type RelayVoiceMemoPart = {
|
|
54
|
-
part_index?: number;
|
|
55
|
-
type: "voice_memo";
|
|
56
|
-
url: string;
|
|
57
|
-
attachment_id?: string;
|
|
58
|
-
duration_ms?: number;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export type RelayLinkPreviewPart = {
|
|
62
|
-
part_index?: number;
|
|
63
|
-
type: "link_preview";
|
|
64
|
-
url: string;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export type RelayDataPart = {
|
|
68
|
-
part_index?: number;
|
|
69
|
-
type: "data";
|
|
70
|
-
data: unknown;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
export type RelayPart =
|
|
74
|
-
| RelayTextPart
|
|
75
|
-
| RelayMediaPart
|
|
76
|
-
| RelayVoiceMemoPart
|
|
77
|
-
| RelayLinkPreviewPart
|
|
78
|
-
| RelayDataPart;
|
|
79
|
-
|
|
80
|
-
export type RelayReplyRef = {
|
|
81
|
-
message_id?: string;
|
|
82
|
-
} | null;
|
|
83
|
-
|
|
84
|
-
export type RelayMessage = {
|
|
85
|
-
id: string;
|
|
86
|
-
conversation_id: string;
|
|
87
|
-
sequence: number;
|
|
88
|
-
sender: RelaySender;
|
|
89
|
-
parts: RelayPart[];
|
|
90
|
-
reply_to?: RelayReplyRef;
|
|
91
|
-
fallback_text: string;
|
|
92
|
-
status: string;
|
|
93
|
-
created_at: string;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export type RelayEventType =
|
|
97
|
-
| "message.received"
|
|
98
|
-
| "reaction.added"
|
|
99
|
-
| "reaction.removed"
|
|
100
|
-
| "message.delivered"
|
|
101
|
-
| "message.read"
|
|
102
|
-
| (string & {});
|
|
103
|
-
|
|
104
|
-
export type RelayEvent = {
|
|
105
|
-
event_id: string;
|
|
106
|
-
event_type: RelayEventType;
|
|
107
|
-
agent_id: string;
|
|
108
|
-
created_at: string;
|
|
109
|
-
data: {
|
|
110
|
-
message?: RelayMessage;
|
|
111
|
-
/**
|
|
112
|
-
* Present only when this event belongs to a group invocation: a human
|
|
113
|
-
* mentioned the agent, replied to it, or picked it. In a group the server
|
|
114
|
-
* delivers nothing to an agent that was not invoked, and every call the
|
|
115
|
-
* agent then makes about the message must carry this id back
|
|
116
|
-
* (`/typing`, `/responding`, and the reply itself all refuse without it).
|
|
117
|
-
* A direct message never carries one — the server rejects
|
|
118
|
-
* `invoked_agent_ids` outside a group — so its presence is also how the
|
|
119
|
-
* plugin knows it is in a group.
|
|
120
|
-
*/
|
|
121
|
-
invocation_id?: string;
|
|
122
|
-
[key: string]: unknown;
|
|
123
|
-
};
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
export type RelayAgentProfile = {
|
|
127
|
-
id: string;
|
|
128
|
-
owner_user_id?: string;
|
|
129
|
-
handle: string;
|
|
130
|
-
display_name: string;
|
|
131
|
-
tagline?: string;
|
|
132
|
-
avatar_url?: string | null;
|
|
133
|
-
visibility?: "private" | "unlisted" | "public";
|
|
134
|
-
created_at?: string;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export type RelayEventsPage = {
|
|
138
|
-
events: RelayEvent[];
|
|
139
|
-
nextCursor: number;
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
// The 202 from POST /v1/messages. The server splits the accepted parts at
|
|
143
|
-
// ingest: each visible non-media part becomes its own message, contiguous
|
|
144
|
-
// media parts stay one media message, and a voice memo always commits alone,
|
|
145
|
-
// so one send commits one or more messages, in display order.
|
|
146
|
-
export type RelaySendResult = {
|
|
147
|
-
messages: RelayMessage[];
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// Plugin config (channels.relay) and resolved account.
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
1
|
+
import type {
|
|
2
|
+
Chat,
|
|
3
|
+
ChatHandle,
|
|
4
|
+
MessageWebhookData,
|
|
5
|
+
Message,
|
|
6
|
+
RelayWebhookEnvelope,
|
|
7
|
+
RelayWebhookEvent,
|
|
8
|
+
} from "@relaymessenger/sdk";
|
|
9
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core";
|
|
153
10
|
|
|
154
11
|
export type RelayAccountConfig = {
|
|
155
12
|
name?: string;
|
|
@@ -157,31 +14,63 @@ export type RelayAccountConfig = {
|
|
|
157
14
|
token?: string;
|
|
158
15
|
tokenFile?: string;
|
|
159
16
|
baseUrl?: string;
|
|
160
|
-
allowFrom?:
|
|
161
|
-
pollTimeoutSeconds?: number;
|
|
17
|
+
allowFrom?: string[];
|
|
162
18
|
};
|
|
163
19
|
|
|
164
20
|
export type RelayChannelConfig = RelayAccountConfig & {
|
|
165
|
-
accounts?: Record<string, Partial<RelayAccountConfig>>;
|
|
166
21
|
defaultAccount?: string;
|
|
22
|
+
accounts?: Record<string, RelayAccountConfig>;
|
|
167
23
|
};
|
|
168
24
|
|
|
169
|
-
export type RelayCoreConfig = {
|
|
170
|
-
channels?: {
|
|
25
|
+
export type RelayCoreConfig = OpenClawConfig & {
|
|
26
|
+
channels?: OpenClawConfig["channels"] & {
|
|
171
27
|
relay?: RelayChannelConfig;
|
|
172
28
|
};
|
|
173
|
-
session?: {
|
|
174
|
-
store?: string;
|
|
175
|
-
};
|
|
176
29
|
};
|
|
177
30
|
|
|
178
31
|
export type ResolvedRelayAccount = {
|
|
179
32
|
accountId: string;
|
|
33
|
+
name?: string;
|
|
180
34
|
enabled: boolean;
|
|
181
35
|
configured: boolean;
|
|
182
|
-
name?: string;
|
|
183
36
|
token: string;
|
|
184
37
|
baseUrl: string;
|
|
185
|
-
|
|
38
|
+
allowFrom: string[];
|
|
186
39
|
config: RelayAccountConfig;
|
|
187
40
|
};
|
|
41
|
+
|
|
42
|
+
export type RelayIngressPayload = {
|
|
43
|
+
version: 1;
|
|
44
|
+
rawEvent: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type RelaySnapshot = {
|
|
48
|
+
version: 1;
|
|
49
|
+
throughSequence: string;
|
|
50
|
+
reason: "checkpoint_outside_retention";
|
|
51
|
+
completedAt: string;
|
|
52
|
+
chats: Array<{
|
|
53
|
+
chat: Chat;
|
|
54
|
+
messages: Message[];
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type RelayMessageReceivedEvent = RelayWebhookEnvelope<
|
|
59
|
+
MessageWebhookData,
|
|
60
|
+
"message.received"
|
|
61
|
+
>;
|
|
62
|
+
|
|
63
|
+
export type RelayInboundFacts = {
|
|
64
|
+
eventId: string;
|
|
65
|
+
messageId: string;
|
|
66
|
+
chatId: string;
|
|
67
|
+
chatType: "direct" | "group";
|
|
68
|
+
contactId: string;
|
|
69
|
+
handle: string;
|
|
70
|
+
displayName: string;
|
|
71
|
+
text: string;
|
|
72
|
+
mentionHandles: string[];
|
|
73
|
+
ownerHandle?: ChatHandle;
|
|
74
|
+
replyToId?: string;
|
|
75
|
+
timestamp?: number;
|
|
76
|
+
};
|
package/dist/src/account-lock.js
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
function processIsLive(pid) {
|
|
6
|
-
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
7
|
-
return false;
|
|
8
|
-
try {
|
|
9
|
-
process.kill(pid, 0);
|
|
10
|
-
return true;
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
return error?.code === "EPERM";
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
function readOwner(path) {
|
|
17
|
-
try {
|
|
18
|
-
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
-
if (Number.isSafeInteger(value.pid) &&
|
|
20
|
-
typeof value.nonce === "string" &&
|
|
21
|
-
typeof value.account_id === "string" &&
|
|
22
|
-
typeof value.created_at === "string") {
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Missing/malformed ownership is never deleted in place by a contender.
|
|
28
|
-
}
|
|
29
|
-
return undefined;
|
|
30
|
-
}
|
|
31
|
-
/** Atomic filesystem lease preventing two OpenClaw processes polling one agent. */
|
|
32
|
-
export class RelayAccountLock {
|
|
33
|
-
accountId;
|
|
34
|
-
lockPath;
|
|
35
|
-
ownerPath;
|
|
36
|
-
nonce = randomUUID();
|
|
37
|
-
held = false;
|
|
38
|
-
constructor(baseUrl, agentId, accountId, baseDir = join(homedir(), ".openclaw", "relay", "consumer-locks")) {
|
|
39
|
-
this.accountId = accountId;
|
|
40
|
-
const key = createHash("sha256").update(`${baseUrl}\0${agentId}`).digest("hex");
|
|
41
|
-
this.lockPath = join(baseDir, key);
|
|
42
|
-
this.ownerPath = join(this.lockPath, "owner.json");
|
|
43
|
-
}
|
|
44
|
-
acquire() {
|
|
45
|
-
mkdirSync(join(this.lockPath, ".."), { recursive: true, mode: 0o700 });
|
|
46
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
47
|
-
try {
|
|
48
|
-
mkdirSync(this.lockPath, { mode: 0o700 });
|
|
49
|
-
const owner = {
|
|
50
|
-
pid: process.pid,
|
|
51
|
-
nonce: this.nonce,
|
|
52
|
-
account_id: this.accountId,
|
|
53
|
-
created_at: new Date().toISOString(),
|
|
54
|
-
};
|
|
55
|
-
writeFileSync(this.ownerPath, `${JSON.stringify(owner)}\n`, { mode: 0o600 });
|
|
56
|
-
this.held = true;
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
catch (error) {
|
|
60
|
-
if (error?.code !== "EEXIST")
|
|
61
|
-
throw error;
|
|
62
|
-
const owner = readOwner(this.ownerPath);
|
|
63
|
-
if (!owner || processIsLive(owner.pid)) {
|
|
64
|
-
const claimant = owner
|
|
65
|
-
? `account "${owner.account_id}" (pid ${owner.pid})`
|
|
66
|
-
: "an existing process with unreadable ownership";
|
|
67
|
-
throw new Error(`relay: this agent already has an active consumer in ${claimant}`);
|
|
68
|
-
}
|
|
69
|
-
const stalePath = `${this.lockPath}.stale-${Date.now()}-${randomUUID()}`;
|
|
70
|
-
try {
|
|
71
|
-
renameSync(this.lockPath, stalePath);
|
|
72
|
-
rmSync(stalePath, { recursive: true, force: true });
|
|
73
|
-
}
|
|
74
|
-
catch (renameError) {
|
|
75
|
-
if (renameError?.code !== "ENOENT")
|
|
76
|
-
throw renameError;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
throw new Error("relay: could not acquire the agent consumer lock");
|
|
81
|
-
}
|
|
82
|
-
release() {
|
|
83
|
-
if (!this.held)
|
|
84
|
-
return;
|
|
85
|
-
const owner = readOwner(this.ownerPath);
|
|
86
|
-
if (owner?.nonce === this.nonce && existsSync(this.lockPath)) {
|
|
87
|
-
rmSync(this.lockPath, { recursive: true, force: true });
|
|
88
|
-
}
|
|
89
|
-
this.held = false;
|
|
90
|
-
}
|
|
91
|
-
}
|
package/dist/src/client.js
DELETED
|
@@ -1,13 +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
|
-
export { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./vendor/relay-sdk/url.js";
|
|
12
|
-
export { classifyRelayHttpStatus, isAbortError, isRelayWebhookConflict, RelayApiError, } from "./vendor/relay-sdk/errors.js";
|
|
13
|
-
export const createRelayClient = createVendoredRelayClient;
|
package/dist/src/cursor-store.js
DELETED
|
@@ -1,136 +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 { assertRelayStateDocument, emptyRelayStateDocument, openRelayStateDocument, } from "./state-files.js";
|
|
7
|
-
export const RELAY_CURSOR_MAX_ENTRIES = 1_000;
|
|
8
|
-
export const RELAY_CURSOR_OVERFLOW_POLICY = "reject-new";
|
|
9
|
-
const RECORD_VERSION = 2;
|
|
10
|
-
/**
|
|
11
|
-
* Open Relay's private, lock-protected state file with fail-closed capacity
|
|
12
|
-
* semantics. A cursor is permanent safety state: evicting an old identity to
|
|
13
|
-
* admit a new one could replay retained events when the old identity returns.
|
|
14
|
-
*/
|
|
15
|
-
export function openRelayCursorStateStore(warn, options = {}) {
|
|
16
|
-
const maxEntries = options.maxEntries ?? RELAY_CURSOR_MAX_ENTRIES;
|
|
17
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
18
|
-
throw new Error("relay cursor maxEntries must be a positive safe integer");
|
|
19
|
-
}
|
|
20
|
-
const store = openRelayStateDocument({
|
|
21
|
-
fileName: "cursors.json",
|
|
22
|
-
...(options.env ? { env: options.env } : {}),
|
|
23
|
-
});
|
|
24
|
-
const storageKey = (key) => createHash("sha256").update(key).digest("hex");
|
|
25
|
-
const validateEntry = (key, value) => {
|
|
26
|
-
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object")
|
|
27
|
-
return false;
|
|
28
|
-
const record = value;
|
|
29
|
-
return (record.version === RECORD_VERSION &&
|
|
30
|
-
isValidCursor(record.cursor) &&
|
|
31
|
-
typeof record.baseUrl === "string" &&
|
|
32
|
-
(() => {
|
|
33
|
-
try {
|
|
34
|
-
return new URL(record.baseUrl).origin === record.baseUrl;
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
})() &&
|
|
40
|
-
typeof record.agentId === "string" &&
|
|
41
|
-
record.agentId.length > 0);
|
|
42
|
-
};
|
|
43
|
-
const read = async () => {
|
|
44
|
-
try {
|
|
45
|
-
const current = await store.read();
|
|
46
|
-
if (current === undefined)
|
|
47
|
-
return emptyRelayStateDocument();
|
|
48
|
-
assertRelayStateDocument(current, "cursor", validateEntry);
|
|
49
|
-
return current;
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
|
|
53
|
-
throw error;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
return {
|
|
57
|
-
lookup: async (key) => (await read()).entries[storageKey(key)],
|
|
58
|
-
register: async (key, value) => {
|
|
59
|
-
try {
|
|
60
|
-
await store.updateOr(emptyRelayStateDocument(), (current) => {
|
|
61
|
-
assertRelayStateDocument(current, "cursor", validateEntry);
|
|
62
|
-
const hashedKey = storageKey(key);
|
|
63
|
-
if (!Object.hasOwn(current.entries, hashedKey) &&
|
|
64
|
-
Object.keys(current.entries).length >= maxEntries) {
|
|
65
|
-
throw new Error(`relay cursor state reached ${maxEntries} identities (${RELAY_CURSOR_OVERFLOW_POLICY})`);
|
|
66
|
-
}
|
|
67
|
-
return {
|
|
68
|
-
version: current.version,
|
|
69
|
-
entries: { ...current.entries, [hashedKey]: value },
|
|
70
|
-
};
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
warn(`[relay] cursor state unavailable; refusing unsafe cursor reset: ${String(error)}`);
|
|
75
|
-
throw error;
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
function isValidCursor(value) {
|
|
81
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
82
|
-
}
|
|
83
|
-
export function createRelayCursorStore(params) {
|
|
84
|
-
const baseUrl = new URL(params.baseUrl).origin;
|
|
85
|
-
const key = `relay:${baseUrl}:${params.agentId}`;
|
|
86
|
-
let cursor = 0;
|
|
87
|
-
let loaded = false;
|
|
88
|
-
return {
|
|
89
|
-
load: async () => {
|
|
90
|
-
let record;
|
|
91
|
-
try {
|
|
92
|
-
record = await params.store.lookup(key);
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
params.onPersistError?.(error);
|
|
96
|
-
throw new Error(`relay cursor state could not be loaded: ${String(error)}`);
|
|
97
|
-
}
|
|
98
|
-
if (!record) {
|
|
99
|
-
cursor = 0;
|
|
100
|
-
loaded = true;
|
|
101
|
-
return cursor;
|
|
102
|
-
}
|
|
103
|
-
if (record.version !== RECORD_VERSION ||
|
|
104
|
-
!isValidCursor(record.cursor) ||
|
|
105
|
-
record.agentId !== params.agentId ||
|
|
106
|
-
record.baseUrl !== baseUrl) {
|
|
107
|
-
throw new Error(`relay cursor state is corrupt for ${baseUrl} ${params.agentId}; refusing cursor-zero replay`);
|
|
108
|
-
}
|
|
109
|
-
cursor = record.cursor;
|
|
110
|
-
loaded = true;
|
|
111
|
-
return cursor;
|
|
112
|
-
},
|
|
113
|
-
current: () => cursor,
|
|
114
|
-
advance: async (next) => {
|
|
115
|
-
if (!loaded) {
|
|
116
|
-
throw new Error("relay cursor store: advance() before load()");
|
|
117
|
-
}
|
|
118
|
-
if (!isValidCursor(next) || next <= cursor) {
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
try {
|
|
122
|
-
await params.store.register(key, {
|
|
123
|
-
version: RECORD_VERSION,
|
|
124
|
-
cursor: next,
|
|
125
|
-
baseUrl,
|
|
126
|
-
agentId: params.agentId,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
catch (error) {
|
|
130
|
-
params.onPersistError?.(error);
|
|
131
|
-
throw new Error(`relay cursor advance was not durable: ${String(error)}`);
|
|
132
|
-
}
|
|
133
|
-
cursor = next;
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
// Relay inbound replay protection. The long-poll cursor acknowledges batches,
|
|
2
|
-
// so a crash between dispatch and cursor advance
|
|
3
|
-
// replays events. Each (canonical origin, agent, event) is claimed, safely
|
|
4
|
-
// preflighted, and durably committed immediately before agent dispatch. That
|
|
5
|
-
// gives engine/tool side effects at-most-once semantics:
|
|
6
|
-
// an interrupted turn may need the user to resend, but it is never silently
|
|
7
|
-
// executed twice.
|
|
8
|
-
import { createHash } from "node:crypto";
|
|
9
|
-
import { assertRelayStateDocument, emptyRelayStateDocument, openRelayStateDocument, } from "./state-files.js";
|
|
10
|
-
// One shared namespace with stable Relay identity baked into each key so local
|
|
11
|
-
// account renames cannot reset safety state or partition the row budget.
|
|
12
|
-
const RELAY_INBOUND_DEDUPE_SCOPE = "global";
|
|
13
|
-
// 30d window: a long outage can replay a deep cursor backlog.
|
|
14
|
-
export const RELAY_INBOUND_DEDUPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
15
|
-
export const RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 20_000;
|
|
16
|
-
export function buildRelayInboundDedupeKey(params) {
|
|
17
|
-
const eventId = params.eventId.trim();
|
|
18
|
-
if (!eventId) {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
// NUL separator: event ids are opaque strings, so a printable separator
|
|
22
|
-
// could collide two distinct (account, event) pairs.
|
|
23
|
-
return `${new URL(params.baseUrl).origin}\0${params.agentId}\0${eventId}`;
|
|
24
|
-
}
|
|
25
|
-
export function createRelayInboundDeduper(params) {
|
|
26
|
-
const namespace = RELAY_INBOUND_DEDUPE_SCOPE;
|
|
27
|
-
return {
|
|
28
|
-
claimEvent: async (eventId) => {
|
|
29
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
30
|
-
if (!key) {
|
|
31
|
-
// Fail closed: an event without a durable identity cannot safely
|
|
32
|
-
// cross the at-most-once agent/tool side-effect boundary.
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
return (await params.guard.claim(key, { namespace })).kind === "claimed";
|
|
36
|
-
},
|
|
37
|
-
commitEvent: async (eventId) => {
|
|
38
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
39
|
-
if (!key) {
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
await params.guard.commit(key, { namespace });
|
|
43
|
-
},
|
|
44
|
-
releaseEvent: (eventId) => {
|
|
45
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
46
|
-
if (key) {
|
|
47
|
-
params.guard.release(key, { namespace });
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
function durableAttemptKey(key, namespace) {
|
|
53
|
-
return createHash("sha256").update(`${namespace}\0${key}`).digest("hex");
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Strict Relay-owned guard used by the channel runtime. Unlike a normal
|
|
57
|
-
* message dedupe cache, persistence is not best effort: a failed attempt write
|
|
58
|
-
* must stop before agent dispatch or a crash could execute local tools twice.
|
|
59
|
-
*/
|
|
60
|
-
export function createRelayInboundDedupeGuard(params) {
|
|
61
|
-
const now = params?.now ?? Date.now;
|
|
62
|
-
const ttlMs = params?.ttlMs ?? RELAY_INBOUND_DEDUPE_TTL_MS;
|
|
63
|
-
const maxEntries = params?.maxEntries ?? RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES;
|
|
64
|
-
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
|
|
65
|
-
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
66
|
-
}
|
|
67
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
68
|
-
throw new Error("relay inbound dedupe maxEntries must be a positive safe integer");
|
|
69
|
-
}
|
|
70
|
-
const readNow = () => {
|
|
71
|
-
const timestamp = now();
|
|
72
|
-
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
73
|
-
throw new Error("relay inbound dedupe clock returned an invalid timestamp");
|
|
74
|
-
}
|
|
75
|
-
return timestamp;
|
|
76
|
-
};
|
|
77
|
-
const isPersistedAttempt = (key, value) => {
|
|
78
|
-
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object")
|
|
79
|
-
return false;
|
|
80
|
-
const entry = value;
|
|
81
|
-
return (typeof entry.attemptedAt === "number" &&
|
|
82
|
-
Number.isSafeInteger(entry.attemptedAt) &&
|
|
83
|
-
entry.attemptedAt >= 0 &&
|
|
84
|
-
typeof entry.expiresAt === "number" &&
|
|
85
|
-
Number.isSafeInteger(entry.expiresAt) &&
|
|
86
|
-
entry.expiresAt > entry.attemptedAt);
|
|
87
|
-
};
|
|
88
|
-
const state = params?.store
|
|
89
|
-
? undefined
|
|
90
|
-
: openRelayStateDocument({
|
|
91
|
-
fileName: "inbound-attempts.json",
|
|
92
|
-
...(params?.env ? { env: params.env } : {}),
|
|
93
|
-
});
|
|
94
|
-
const store = params?.store ??
|
|
95
|
-
{
|
|
96
|
-
lookup: async (key) => {
|
|
97
|
-
const current = await state.read();
|
|
98
|
-
if (current === undefined)
|
|
99
|
-
return undefined;
|
|
100
|
-
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
101
|
-
const entry = current.entries[key];
|
|
102
|
-
if (!entry || entry.expiresAt <= readNow())
|
|
103
|
-
return undefined;
|
|
104
|
-
return { attemptedAt: entry.attemptedAt };
|
|
105
|
-
},
|
|
106
|
-
register: async (key, value, opts) => {
|
|
107
|
-
await state.updateOr(emptyRelayStateDocument(), (current) => {
|
|
108
|
-
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
109
|
-
const timestamp = readNow();
|
|
110
|
-
const entryTtlMs = opts?.ttlMs ?? ttlMs;
|
|
111
|
-
if (!Number.isSafeInteger(entryTtlMs) || entryTtlMs < 1) {
|
|
112
|
-
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
113
|
-
}
|
|
114
|
-
const expiresAt = value.attemptedAt + entryTtlMs;
|
|
115
|
-
if (!Number.isSafeInteger(expiresAt)) {
|
|
116
|
-
throw new Error("relay inbound dedupe expiration exceeds safe integer range");
|
|
117
|
-
}
|
|
118
|
-
const liveEntries = Object.fromEntries(Object.entries(current.entries).filter(([, entry]) => entry.expiresAt > timestamp));
|
|
119
|
-
liveEntries[key] = {
|
|
120
|
-
attemptedAt: value.attemptedAt,
|
|
121
|
-
expiresAt,
|
|
122
|
-
};
|
|
123
|
-
const ordered = Object.entries(liveEntries).sort(([leftKey, left], [rightKey, right]) => left.attemptedAt - right.attemptedAt || leftKey.localeCompare(rightKey));
|
|
124
|
-
const retained = ordered.slice(Math.max(ordered.length - maxEntries, 0));
|
|
125
|
-
return {
|
|
126
|
-
version: current.version,
|
|
127
|
-
entries: Object.fromEntries(retained),
|
|
128
|
-
};
|
|
129
|
-
});
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
const inflight = new Set();
|
|
133
|
-
const claiming = new Set();
|
|
134
|
-
const withDiskError = async (operation) => {
|
|
135
|
-
try {
|
|
136
|
-
return await operation();
|
|
137
|
-
}
|
|
138
|
-
catch (error) {
|
|
139
|
-
params?.onDiskError?.(error);
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
return {
|
|
144
|
-
claim: async (key, opts) => {
|
|
145
|
-
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
146
|
-
if (inflight.has(storageKey) || claiming.has(storageKey)) {
|
|
147
|
-
return { kind: "inflight" };
|
|
148
|
-
}
|
|
149
|
-
claiming.add(storageKey);
|
|
150
|
-
try {
|
|
151
|
-
if ((await withDiskError(() => store.lookup(storageKey))) !== undefined) {
|
|
152
|
-
return { kind: "duplicate" };
|
|
153
|
-
}
|
|
154
|
-
inflight.add(storageKey);
|
|
155
|
-
return { kind: "claimed" };
|
|
156
|
-
}
|
|
157
|
-
finally {
|
|
158
|
-
claiming.delete(storageKey);
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
commit: async (key, opts) => {
|
|
162
|
-
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
163
|
-
try {
|
|
164
|
-
await withDiskError(() => store.register(storageKey, { attemptedAt: readNow() }, { ttlMs }));
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
finally {
|
|
168
|
-
inflight.delete(storageKey);
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
release: (key, opts) => {
|
|
172
|
-
inflight.delete(durableAttemptKey(key, opts.namespace));
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
}
|