@relaymessenger/openclaw-plugin 0.3.4 → 0.4.0-staging.1
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 +162 -123
- package/contracts/relay-sdk-0.3.0-staging.5.registry.json +69 -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 +114 -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/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/invocations.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which group invocation an in-flight turn belongs to.
|
|
3
|
-
*
|
|
4
|
-
* Relay mints an invocation when a human invokes an agent in a group, and
|
|
5
|
-
* every call the agent then makes about that message has to carry the id back:
|
|
6
|
-
* `/typing` and `/responding` refuse without it, and so does the reply itself.
|
|
7
|
-
*
|
|
8
|
-
* The reply does not leave through this plugin's own code. It leaves through
|
|
9
|
-
* core's durable message adapter, whose send context carries `to`, `text`, and
|
|
10
|
-
* delivery bookkeeping and nothing about the message being answered
|
|
11
|
-
* (`ChannelMessageSendTextContext`). There is no field to thread the id
|
|
12
|
-
* through, so the turn parks it here for the adapter to find.
|
|
13
|
-
*
|
|
14
|
-
* Keyed by (accountId, conversationId) because `to` and `accountId` are all
|
|
15
|
-
* the adapter knows. Two agents in one group get separate slots. Two
|
|
16
|
-
* overlapping turns for ONE agent in ONE group share a slot and the later one
|
|
17
|
-
* wins — bounded by the server, which spends an invocation exactly once and
|
|
18
|
-
* refuses the loser rather than misattributing it.
|
|
19
|
-
*/
|
|
20
|
-
const pendingInvocations = new Map<string, string>();
|
|
21
|
-
|
|
22
|
-
function slotKey(accountId: string, conversationId: string): string {
|
|
23
|
-
return `${accountId}\0${conversationId}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Hold `invocationId` for the life of one turn. Returns the release function;
|
|
28
|
-
* call it in a `finally` so a thrown turn cannot strand the slot.
|
|
29
|
-
*
|
|
30
|
-
* Releasing only clears the slot if this turn still owns it, so a turn that
|
|
31
|
-
* finishes after being superseded cannot delete its successor's id.
|
|
32
|
-
*/
|
|
33
|
-
export function rememberRelayInvocation(params: {
|
|
34
|
-
accountId: string;
|
|
35
|
-
conversationId: string;
|
|
36
|
-
invocationId: string;
|
|
37
|
-
}): () => void {
|
|
38
|
-
const key = slotKey(params.accountId, params.conversationId);
|
|
39
|
-
pendingInvocations.set(key, params.invocationId);
|
|
40
|
-
return () => {
|
|
41
|
-
if (pendingInvocations.get(key) === params.invocationId) {
|
|
42
|
-
pendingInvocations.delete(key);
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** The invocation an outbound send in this conversation belongs to, if any. */
|
|
48
|
-
export function relayInvocationFor(params: {
|
|
49
|
-
accountId: string;
|
|
50
|
-
conversationId: string;
|
|
51
|
-
}): string | undefined {
|
|
52
|
-
return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Test seam: drop every slot. */
|
|
56
|
-
export function resetRelayInvocationsForTest(): void {
|
|
57
|
-
pendingInvocations.clear();
|
|
58
|
-
}
|
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,173 +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
|
-
// A rejection is the server's final answer: replaying the identical
|
|
134
|
-
// request produces the identical refusal. Holding the cursor for it
|
|
135
|
-
// is a livelock, and the cursor is ONE watermark for the whole
|
|
136
|
-
// channel — so a single permanently-refused event would starve every
|
|
137
|
-
// later message, direct ones included (REL-167). Losing one event is
|
|
138
|
-
// strictly better than losing the channel, so log it loudly and let
|
|
139
|
-
// the page cursor move past it.
|
|
140
|
-
if (error instanceof RelayApiError && error.kind === "rejected") {
|
|
141
|
-
log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
|
|
142
|
-
`skipping it so later messages are not starved: ${String(error)}`);
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
146
|
-
batchFailed = true;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
|
|
150
|
-
`will not replay possible tool side effects: ${String(error)}`);
|
|
151
|
-
}
|
|
152
|
-
if (!attempted) {
|
|
153
|
-
// Admission/preflight intentionally consumed no side effect; release
|
|
154
|
-
// the claim and allow the page cursor to acknowledge it.
|
|
155
|
-
params.deduper.releaseEvent(event.event_id);
|
|
156
|
-
}
|
|
157
|
-
if (params.abortSignal.aborted) {
|
|
158
|
-
batchFailed = true;
|
|
159
|
-
break;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// The server's next_cursor covers the whole page (cursor N acks <= N), so
|
|
165
|
-
// only a batch with durable attempt markers may ack it. A marker-write
|
|
166
|
-
// failure acks nothing and committed predecessors absorb the replay.
|
|
167
|
-
if (!batchFailed) {
|
|
168
|
-
await params.cursorStore.advance(page.nextCursor);
|
|
169
|
-
} else {
|
|
170
|
-
await sleep(transientDelayMs(), params.abortSignal);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
package/src/responding.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { RelayApiError } from "./client.js";
|
|
2
|
-
import type { RelayClient } from "./client.js";
|
|
3
|
-
import type { RelayInboundFacts } from "./inbound.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Record the read/responding receipt, then commit the durable attempt marker.
|
|
7
|
-
*
|
|
8
|
-
* The receipt is a courtesy to the person waiting: it turns their message Read
|
|
9
|
-
* and shows that something is composing. It is NOT permission to answer, and
|
|
10
|
-
* it used to be treated as such — a rejected receipt threw here, before
|
|
11
|
-
* `markAttempt`, which sent the poll loop down its replay branch and froze the
|
|
12
|
-
* channel's single delivery cursor. One group mention whose receipt the server
|
|
13
|
-
* refused therefore starved every later message, direct ones included
|
|
14
|
-
* (REL-167).
|
|
15
|
-
*
|
|
16
|
-
* So a failed receipt is reported and the turn continues. The ordering that
|
|
17
|
-
* mattered is kept: the receipt is still attempted BEFORE the attempt marker,
|
|
18
|
-
* so a receipt that succeeds still precedes any agent or tool work.
|
|
19
|
-
*/
|
|
20
|
-
export async function markRespondingBeforeAttempt(params: {
|
|
21
|
-
client: RelayClient;
|
|
22
|
-
/**
|
|
23
|
-
* The whole fact bundle, not its fields one at a time. The receipt needs the
|
|
24
|
-
* conversation, the message, AND the invocation when there is one, and a
|
|
25
|
-
* caller that copies two of those three out by hand can silently forget the
|
|
26
|
-
* third — which is how the group receipt shipped without its invocation id.
|
|
27
|
-
*/
|
|
28
|
-
facts: Pick<RelayInboundFacts, "conversationId" | "messageId" | "invocationId">;
|
|
29
|
-
label: string;
|
|
30
|
-
markAttempt: () => Promise<void>;
|
|
31
|
-
onReceiptFailure?: (line: string) => void;
|
|
32
|
-
}): Promise<void> {
|
|
33
|
-
const { facts } = params;
|
|
34
|
-
try {
|
|
35
|
-
await params.client.setResponding({
|
|
36
|
-
conversationId: facts.conversationId,
|
|
37
|
-
messageId: facts.messageId,
|
|
38
|
-
label: params.label,
|
|
39
|
-
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
40
|
-
});
|
|
41
|
-
} catch (error) {
|
|
42
|
-
// An aborted shutdown is not a receipt failure; let it settle the loop.
|
|
43
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
44
|
-
throw error;
|
|
45
|
-
}
|
|
46
|
-
const detail = error instanceof RelayApiError ? error.message : String(error);
|
|
47
|
-
params.onReceiptFailure?.(
|
|
48
|
-
`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`,
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
await params.markAttempt();
|
|
52
|
-
}
|
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
|
-
}
|