@relaymessenger/openclaw-plugin 0.3.3 → 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 -498
- 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 -80
- package/dist/src/ingress.js +64 -0
- package/dist/src/outbound.js +48 -109
- 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 -611
- package/src/dispatch.ts +324 -0
- package/src/full-sync.ts +47 -0
- package/src/gateway.ts +216 -0
- package/src/inbound.ts +71 -111
- package/src/ingress.ts +123 -0
- package/src/outbound.ts +70 -142
- package/src/runtime.ts +4 -4
- package/src/state.ts +609 -0
- package/src/types.ts +51 -148
- package/dist/src/account-lock.js +0 -91
- package/dist/src/client.js +0 -229
- package/dist/src/cursor-store.js +0 -136
- package/dist/src/inbound-dedupe.js +0 -175
- package/dist/src/lifecycle.js +0 -35
- package/dist/src/poll-loop.js +0 -125
- package/dist/src/responding.js +0 -13
- package/dist/src/security.js +0 -26
- package/dist/src/state-files.js +0 -167
- package/src/account-lock.ts +0 -108
- package/src/client.ts +0 -330
- package/src/cursor-store.ts +0 -186
- package/src/inbound-dedupe.ts +0 -241
- package/src/lifecycle.ts +0 -42
- package/src/poll-loop.ts +0 -161
- package/src/responding.ts +0 -21
- package/src/security.ts +0 -36
- package/src/state-files.ts +0 -212
package/src/inbound-dedupe.ts
DELETED
|
@@ -1,241 +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 {
|
|
10
|
-
assertRelayStateDocument,
|
|
11
|
-
emptyRelayStateDocument,
|
|
12
|
-
openRelayStateDocument,
|
|
13
|
-
} from "./state-files.js";
|
|
14
|
-
|
|
15
|
-
// One shared namespace with stable Relay identity baked into each key so local
|
|
16
|
-
// account renames cannot reset safety state or partition the row budget.
|
|
17
|
-
const RELAY_INBOUND_DEDUPE_SCOPE = "global";
|
|
18
|
-
// 30d window: a long outage can replay a deep cursor backlog.
|
|
19
|
-
export const RELAY_INBOUND_DEDUPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
20
|
-
export const RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 20_000;
|
|
21
|
-
|
|
22
|
-
type MaybePromise<T> = T | Promise<T>;
|
|
23
|
-
|
|
24
|
-
/** Minimal claim/commit/release slice of the SDK's ClaimableDedupe. */
|
|
25
|
-
export type RelayClaimableGuard = {
|
|
26
|
-
claim: (key: string, opts: { namespace: string }) => Promise<{ kind: string }>;
|
|
27
|
-
commit: (key: string, opts: { namespace: string }) => Promise<unknown>;
|
|
28
|
-
release: (key: string, opts: { namespace: string }) => void;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export type RelayAttemptStateStore = {
|
|
32
|
-
lookup(key: string): MaybePromise<{ attemptedAt: number } | undefined>;
|
|
33
|
-
register(
|
|
34
|
-
key: string,
|
|
35
|
-
value: { attemptedAt: number },
|
|
36
|
-
opts?: { ttlMs?: number },
|
|
37
|
-
): MaybePromise<void>;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type RelayInboundDeduper = {
|
|
41
|
-
/** True when the caller now owns the event; false for committed or in-flight duplicates. */
|
|
42
|
-
claimEvent: (eventId: string) => Promise<boolean>;
|
|
43
|
-
/** Records an attempted event at the agent-dispatch boundary so restart cannot run it again. */
|
|
44
|
-
commitEvent: (eventId: string) => Promise<void>;
|
|
45
|
-
/** Drops an uncommitted claim so a failed dispatch can retry the event. */
|
|
46
|
-
releaseEvent: (eventId: string) => void;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export function buildRelayInboundDedupeKey(params: {
|
|
50
|
-
baseUrl: string;
|
|
51
|
-
agentId: string;
|
|
52
|
-
eventId: string;
|
|
53
|
-
}): string | null {
|
|
54
|
-
const eventId = params.eventId.trim();
|
|
55
|
-
if (!eventId) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
// NUL separator: event ids are opaque strings, so a printable separator
|
|
59
|
-
// could collide two distinct (account, event) pairs.
|
|
60
|
-
return `${new URL(params.baseUrl).origin}\0${params.agentId}\0${eventId}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function createRelayInboundDeduper(params: {
|
|
64
|
-
guard: RelayClaimableGuard;
|
|
65
|
-
baseUrl: string;
|
|
66
|
-
agentId: string;
|
|
67
|
-
}): RelayInboundDeduper {
|
|
68
|
-
const namespace = RELAY_INBOUND_DEDUPE_SCOPE;
|
|
69
|
-
return {
|
|
70
|
-
claimEvent: async (eventId) => {
|
|
71
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
72
|
-
if (!key) {
|
|
73
|
-
// Fail closed: an event without a durable identity cannot safely
|
|
74
|
-
// cross the at-most-once agent/tool side-effect boundary.
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
return (await params.guard.claim(key, { namespace })).kind === "claimed";
|
|
78
|
-
},
|
|
79
|
-
commitEvent: async (eventId) => {
|
|
80
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
81
|
-
if (!key) {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
await params.guard.commit(key, { namespace });
|
|
85
|
-
},
|
|
86
|
-
releaseEvent: (eventId) => {
|
|
87
|
-
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
88
|
-
if (key) {
|
|
89
|
-
params.guard.release(key, { namespace });
|
|
90
|
-
}
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function durableAttemptKey(key: string, namespace: string): string {
|
|
96
|
-
return createHash("sha256").update(`${namespace}\0${key}`).digest("hex");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Strict Relay-owned guard used by the channel runtime. Unlike a normal
|
|
101
|
-
* message dedupe cache, persistence is not best effort: a failed attempt write
|
|
102
|
-
* must stop before agent dispatch or a crash could execute local tools twice.
|
|
103
|
-
*/
|
|
104
|
-
export function createRelayInboundDedupeGuard(params?: {
|
|
105
|
-
env?: NodeJS.ProcessEnv;
|
|
106
|
-
onDiskError?: (error: unknown) => void;
|
|
107
|
-
store?: RelayAttemptStateStore;
|
|
108
|
-
maxEntries?: number;
|
|
109
|
-
ttlMs?: number;
|
|
110
|
-
now?: () => number;
|
|
111
|
-
}): RelayClaimableGuard {
|
|
112
|
-
type PersistedAttempt = { attemptedAt: number; expiresAt: number };
|
|
113
|
-
const now = params?.now ?? Date.now;
|
|
114
|
-
const ttlMs = params?.ttlMs ?? RELAY_INBOUND_DEDUPE_TTL_MS;
|
|
115
|
-
const maxEntries = params?.maxEntries ?? RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES;
|
|
116
|
-
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
|
|
117
|
-
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
118
|
-
}
|
|
119
|
-
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
120
|
-
throw new Error("relay inbound dedupe maxEntries must be a positive safe integer");
|
|
121
|
-
}
|
|
122
|
-
const readNow = (): number => {
|
|
123
|
-
const timestamp = now();
|
|
124
|
-
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
125
|
-
throw new Error("relay inbound dedupe clock returned an invalid timestamp");
|
|
126
|
-
}
|
|
127
|
-
return timestamp;
|
|
128
|
-
};
|
|
129
|
-
const isPersistedAttempt = (key: string, value: unknown): value is PersistedAttempt => {
|
|
130
|
-
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object") return false;
|
|
131
|
-
const entry = value as Partial<PersistedAttempt>;
|
|
132
|
-
return (
|
|
133
|
-
typeof entry.attemptedAt === "number" &&
|
|
134
|
-
Number.isSafeInteger(entry.attemptedAt) &&
|
|
135
|
-
entry.attemptedAt >= 0 &&
|
|
136
|
-
typeof entry.expiresAt === "number" &&
|
|
137
|
-
Number.isSafeInteger(entry.expiresAt) &&
|
|
138
|
-
entry.expiresAt > entry.attemptedAt
|
|
139
|
-
);
|
|
140
|
-
};
|
|
141
|
-
const state = params?.store
|
|
142
|
-
? undefined
|
|
143
|
-
: openRelayStateDocument<PersistedAttempt>({
|
|
144
|
-
fileName: "inbound-attempts.json",
|
|
145
|
-
...(params?.env ? { env: params.env } : {}),
|
|
146
|
-
});
|
|
147
|
-
const store =
|
|
148
|
-
params?.store ??
|
|
149
|
-
{
|
|
150
|
-
lookup: async (key: string) => {
|
|
151
|
-
const current = await state!.read();
|
|
152
|
-
if (current === undefined) return undefined;
|
|
153
|
-
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
154
|
-
const entry = current.entries[key];
|
|
155
|
-
if (!entry || entry.expiresAt <= readNow()) return undefined;
|
|
156
|
-
return { attemptedAt: entry.attemptedAt };
|
|
157
|
-
},
|
|
158
|
-
register: async (
|
|
159
|
-
key: string,
|
|
160
|
-
value: { attemptedAt: number },
|
|
161
|
-
opts?: { ttlMs?: number },
|
|
162
|
-
) => {
|
|
163
|
-
await state!.updateOr(emptyRelayStateDocument<PersistedAttempt>(), (current) => {
|
|
164
|
-
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
165
|
-
const timestamp = readNow();
|
|
166
|
-
const entryTtlMs = opts?.ttlMs ?? ttlMs;
|
|
167
|
-
if (!Number.isSafeInteger(entryTtlMs) || entryTtlMs < 1) {
|
|
168
|
-
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
169
|
-
}
|
|
170
|
-
const expiresAt = value.attemptedAt + entryTtlMs;
|
|
171
|
-
if (!Number.isSafeInteger(expiresAt)) {
|
|
172
|
-
throw new Error("relay inbound dedupe expiration exceeds safe integer range");
|
|
173
|
-
}
|
|
174
|
-
const liveEntries = Object.fromEntries(
|
|
175
|
-
Object.entries(current.entries).filter(([, entry]) => entry.expiresAt > timestamp),
|
|
176
|
-
);
|
|
177
|
-
liveEntries[key] = {
|
|
178
|
-
attemptedAt: value.attemptedAt,
|
|
179
|
-
expiresAt,
|
|
180
|
-
};
|
|
181
|
-
const ordered = Object.entries(liveEntries).sort(
|
|
182
|
-
([leftKey, left], [rightKey, right]) =>
|
|
183
|
-
left.attemptedAt - right.attemptedAt || leftKey.localeCompare(rightKey),
|
|
184
|
-
);
|
|
185
|
-
const retained = ordered.slice(Math.max(ordered.length - maxEntries, 0));
|
|
186
|
-
return {
|
|
187
|
-
version: current.version,
|
|
188
|
-
entries: Object.fromEntries(retained),
|
|
189
|
-
};
|
|
190
|
-
});
|
|
191
|
-
},
|
|
192
|
-
} satisfies RelayAttemptStateStore;
|
|
193
|
-
const inflight = new Set<string>();
|
|
194
|
-
const claiming = new Set<string>();
|
|
195
|
-
|
|
196
|
-
const withDiskError = async <T>(operation: () => MaybePromise<T>): Promise<T> => {
|
|
197
|
-
try {
|
|
198
|
-
return await operation();
|
|
199
|
-
} catch (error) {
|
|
200
|
-
params?.onDiskError?.(error);
|
|
201
|
-
throw error;
|
|
202
|
-
}
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
claim: async (key, opts) => {
|
|
207
|
-
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
208
|
-
if (inflight.has(storageKey) || claiming.has(storageKey)) {
|
|
209
|
-
return { kind: "inflight" };
|
|
210
|
-
}
|
|
211
|
-
claiming.add(storageKey);
|
|
212
|
-
try {
|
|
213
|
-
if ((await withDiskError(() => store.lookup(storageKey))) !== undefined) {
|
|
214
|
-
return { kind: "duplicate" };
|
|
215
|
-
}
|
|
216
|
-
inflight.add(storageKey);
|
|
217
|
-
return { kind: "claimed" };
|
|
218
|
-
} finally {
|
|
219
|
-
claiming.delete(storageKey);
|
|
220
|
-
}
|
|
221
|
-
},
|
|
222
|
-
commit: async (key, opts) => {
|
|
223
|
-
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
224
|
-
try {
|
|
225
|
-
await withDiskError(() =>
|
|
226
|
-
store.register(
|
|
227
|
-
storageKey,
|
|
228
|
-
{ attemptedAt: readNow() },
|
|
229
|
-
{ ttlMs },
|
|
230
|
-
),
|
|
231
|
-
);
|
|
232
|
-
return true;
|
|
233
|
-
} finally {
|
|
234
|
-
inflight.delete(storageKey);
|
|
235
|
-
}
|
|
236
|
-
},
|
|
237
|
-
release: (key, opts) => {
|
|
238
|
-
inflight.delete(durableAttemptKey(key, opts.namespace));
|
|
239
|
-
},
|
|
240
|
-
};
|
|
241
|
-
}
|
package/src/lifecycle.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
export type RelayAccountLifecycleLease = {
|
|
2
|
-
signal: AbortSignal;
|
|
3
|
-
release: () => void;
|
|
4
|
-
};
|
|
5
|
-
|
|
6
|
-
/** Owns exactly one abortable long-poll lifecycle per configured account. */
|
|
7
|
-
export function createRelayAccountLifecycleRegistry() {
|
|
8
|
-
const controllers = new Map<string, AbortController>();
|
|
9
|
-
|
|
10
|
-
return {
|
|
11
|
-
acquire(accountId: string, parentSignal: AbortSignal): RelayAccountLifecycleLease {
|
|
12
|
-
if (controllers.has(accountId)) {
|
|
13
|
-
throw new Error(`relay: account "${accountId}" already has an active consumer`);
|
|
14
|
-
}
|
|
15
|
-
const controller = new AbortController();
|
|
16
|
-
controllers.set(accountId, controller);
|
|
17
|
-
const signal = AbortSignal.any([parentSignal, controller.signal]);
|
|
18
|
-
let released = false;
|
|
19
|
-
return {
|
|
20
|
-
signal,
|
|
21
|
-
release: () => {
|
|
22
|
-
if (released) {
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
released = true;
|
|
26
|
-
if (controllers.get(accountId) === controller) {
|
|
27
|
-
controllers.delete(accountId);
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
stop(accountId: string): boolean {
|
|
34
|
-
const controller = controllers.get(accountId);
|
|
35
|
-
if (!controller) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
controller.abort();
|
|
39
|
-
return true;
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
}
|
package/src/poll-loop.ts
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
// startAccount receive engine: abort-aware long poll over GET /v1/events with
|
|
2
|
-
// claim -> durable attempt commit -> dispatch per event and the cursor advanced
|
|
3
|
-
// only after the batch's commits. The supervisor owns restart/backoff: this loop settles
|
|
4
|
-
// (throws) on terminal auth failure and consumer conflicts, and only sleeps
|
|
5
|
-
// in-loop for transient errors.
|
|
6
|
-
import { RelayApiError, isAbortError } from "./client.js";
|
|
7
|
-
import type { RelayClient } from "./client.js";
|
|
8
|
-
import type { RelayCursorStore } from "./cursor-store.js";
|
|
9
|
-
import type { RelayInboundDeduper } from "./inbound-dedupe.js";
|
|
10
|
-
import type { RelayEvent } from "./types.js";
|
|
11
|
-
|
|
12
|
-
const TRANSIENT_BASE_DELAY_MS = 500;
|
|
13
|
-
const TRANSIENT_MAX_DELAY_MS = 30_000;
|
|
14
|
-
|
|
15
|
-
export type RelayPollLoopParams = {
|
|
16
|
-
client: RelayClient;
|
|
17
|
-
cursorStore: RelayCursorStore;
|
|
18
|
-
deduper: RelayInboundDeduper;
|
|
19
|
-
abortSignal: AbortSignal;
|
|
20
|
-
/**
|
|
21
|
-
* Perform safe preflight, then call markAttempt exactly before the first
|
|
22
|
-
* non-replayable agent/tool side effect. Errors before that callback replay;
|
|
23
|
-
* errors after it are acknowledged because tools may already have run.
|
|
24
|
-
*/
|
|
25
|
-
handleEvent: (event: RelayEvent, markAttempt: () => Promise<void>) => Promise<void>;
|
|
26
|
-
/**
|
|
27
|
-
* Cheap pre-dedupe classification: events returning false (receipts,
|
|
28
|
-
* reactions, echoes) are acked by the batch cursor without burning a
|
|
29
|
-
* dedupe claim/commit or a dispatch.
|
|
30
|
-
*/
|
|
31
|
-
shouldProcess?: (event: RelayEvent) => boolean;
|
|
32
|
-
timeoutSeconds?: number;
|
|
33
|
-
limit?: number;
|
|
34
|
-
onBatch?: (events: readonly RelayEvent[]) => void;
|
|
35
|
-
log?: (line: string) => void;
|
|
36
|
-
/** Injectable for tests. */
|
|
37
|
-
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
38
|
-
random?: () => number;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
function defaultSleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
42
|
-
return new Promise((resolve) => {
|
|
43
|
-
if (signal.aborted) {
|
|
44
|
-
resolve();
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
const timer = setTimeout(() => {
|
|
48
|
-
signal.removeEventListener("abort", onAbort);
|
|
49
|
-
resolve();
|
|
50
|
-
}, ms);
|
|
51
|
-
const onAbort = () => {
|
|
52
|
-
clearTimeout(timer);
|
|
53
|
-
resolve();
|
|
54
|
-
};
|
|
55
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export async function runRelayPollLoop(params: RelayPollLoopParams): Promise<void> {
|
|
60
|
-
const sleep = params.sleep ?? defaultSleep;
|
|
61
|
-
const random = params.random ?? Math.random;
|
|
62
|
-
const log = params.log ?? (() => {});
|
|
63
|
-
let transientAttempts = 0;
|
|
64
|
-
|
|
65
|
-
const transientDelayMs = () => {
|
|
66
|
-
const backoff = Math.min(
|
|
67
|
-
TRANSIENT_MAX_DELAY_MS,
|
|
68
|
-
TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6),
|
|
69
|
-
);
|
|
70
|
-
return backoff + Math.floor(random() * 250);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
while (!params.abortSignal.aborted) {
|
|
74
|
-
let page;
|
|
75
|
-
try {
|
|
76
|
-
page = await params.client.pollEvents({
|
|
77
|
-
cursor: params.cursorStore.current(),
|
|
78
|
-
timeoutSeconds: params.timeoutSeconds ?? 30,
|
|
79
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
80
|
-
signal: params.abortSignal,
|
|
81
|
-
});
|
|
82
|
-
} catch (error) {
|
|
83
|
-
if (params.abortSignal.aborted || isAbortError(error)) {
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
87
|
-
// Token revoked: settle so the supervisor applies terminalDisconnect
|
|
88
|
-
// (server-channels.ts:718) — an operator has to fix the token.
|
|
89
|
-
throw error;
|
|
90
|
-
}
|
|
91
|
-
if (error instanceof RelayApiError && error.kind === "conflict") {
|
|
92
|
-
// Another consumer took the long poll. Settle and let the
|
|
93
|
-
// supervisor's backoff arbitrate.
|
|
94
|
-
log(`[relay] long poll terminated by another consumer: ${error.message}`);
|
|
95
|
-
throw error;
|
|
96
|
-
}
|
|
97
|
-
transientAttempts += 1;
|
|
98
|
-
log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
|
|
99
|
-
await sleep(transientDelayMs(), params.abortSignal);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
transientAttempts = 0;
|
|
103
|
-
|
|
104
|
-
if (page.events.length > 0) {
|
|
105
|
-
params.onBatch?.(page.events);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let batchFailed = false;
|
|
109
|
-
for (const event of page.events) {
|
|
110
|
-
if (params.abortSignal.aborted) {
|
|
111
|
-
batchFailed = true;
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
if (params.shouldProcess && !params.shouldProcess(event)) {
|
|
115
|
-
// Bookkeeping events (receipts, reactions, echoes) never dispatch, so
|
|
116
|
-
// they are acked by the batch cursor without a dedupe row.
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const claimed = await params.deduper.claimEvent(event.event_id);
|
|
120
|
-
if (claimed) {
|
|
121
|
-
let attempted = false;
|
|
122
|
-
const markAttempt = async () => {
|
|
123
|
-
if (attempted) return;
|
|
124
|
-
await params.deduper.commitEvent(event.event_id);
|
|
125
|
-
attempted = true;
|
|
126
|
-
if (params.abortSignal.aborted) throw new Error("aborted after durable attempt");
|
|
127
|
-
};
|
|
128
|
-
try {
|
|
129
|
-
await params.handleEvent(event, markAttempt);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
if (!attempted) {
|
|
132
|
-
params.deduper.releaseEvent(event.event_id);
|
|
133
|
-
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
134
|
-
batchFailed = true;
|
|
135
|
-
break;
|
|
136
|
-
}
|
|
137
|
-
log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
|
|
138
|
-
`will not replay possible tool side effects: ${String(error)}`);
|
|
139
|
-
}
|
|
140
|
-
if (!attempted) {
|
|
141
|
-
// Admission/preflight intentionally consumed no side effect; release
|
|
142
|
-
// the claim and allow the page cursor to acknowledge it.
|
|
143
|
-
params.deduper.releaseEvent(event.event_id);
|
|
144
|
-
}
|
|
145
|
-
if (params.abortSignal.aborted) {
|
|
146
|
-
batchFailed = true;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// The server's next_cursor covers the whole page (cursor N acks <= N), so
|
|
153
|
-
// only a batch with durable attempt markers may ack it. A marker-write
|
|
154
|
-
// failure acks nothing and committed predecessors absorb the replay.
|
|
155
|
-
if (!batchFailed) {
|
|
156
|
-
await params.cursorStore.advance(page.nextCursor);
|
|
157
|
-
} else {
|
|
158
|
-
await sleep(transientDelayMs(), params.abortSignal);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
package/src/responding.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import type { RelayClient } from "./client.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The receipt must commit before OpenClaw can run an agent or tool. A rejected
|
|
5
|
-
* receipt leaves the durable attempt marker untouched, so the poll loop can
|
|
6
|
-
* replay the event safely instead of hiding the failure after execution.
|
|
7
|
-
*/
|
|
8
|
-
export async function markRespondingBeforeAttempt(params: {
|
|
9
|
-
client: RelayClient;
|
|
10
|
-
conversationId: string;
|
|
11
|
-
messageId: string;
|
|
12
|
-
label: string;
|
|
13
|
-
markAttempt: () => Promise<void>;
|
|
14
|
-
}): Promise<void> {
|
|
15
|
-
await params.client.setResponding({
|
|
16
|
-
conversationId: params.conversationId,
|
|
17
|
-
messageId: params.messageId,
|
|
18
|
-
label: params.label,
|
|
19
|
-
});
|
|
20
|
-
await params.markAttempt();
|
|
21
|
-
}
|
package/src/security.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import type { RelayAgentProfile } from "./types.js";
|
|
2
|
-
|
|
3
|
-
function normalizeSenderId(value: string | number): string | null {
|
|
4
|
-
const normalized = String(value).trim();
|
|
5
|
-
return normalized ? normalized : null;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Build the only identities allowed to start an OpenClaw turn. The Relay
|
|
10
|
-
* account owner is pinned from the authenticated agent profile; operators can
|
|
11
|
-
* deliberately extend that set with `allowFrom`. No wildcard is accepted.
|
|
12
|
-
*/
|
|
13
|
-
export function resolveRelayAllowedSenderIds(params: {
|
|
14
|
-
profile: Pick<RelayAgentProfile, "owner_user_id">;
|
|
15
|
-
allowFrom?: Array<string | number>;
|
|
16
|
-
}): string[] {
|
|
17
|
-
const allowed = new Set<string>();
|
|
18
|
-
const owner = params.profile.owner_user_id?.trim();
|
|
19
|
-
if (owner) {
|
|
20
|
-
allowed.add(owner);
|
|
21
|
-
}
|
|
22
|
-
for (const entry of params.allowFrom ?? []) {
|
|
23
|
-
const normalized = normalizeSenderId(entry);
|
|
24
|
-
if (normalized && normalized !== "*") {
|
|
25
|
-
allowed.add(normalized);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return [...allowed];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function relaySenderIsAllowed(
|
|
32
|
-
allowedSenderIds: readonly string[],
|
|
33
|
-
senderId: string,
|
|
34
|
-
): boolean {
|
|
35
|
-
return allowedSenderIds.includes(senderId);
|
|
36
|
-
}
|