@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.
Files changed (50) 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 -498
  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 -80
  13. package/dist/src/ingress.js +64 -0
  14. package/dist/src/outbound.js +48 -109
  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 -611
  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 -111
  28. package/src/ingress.ts +123 -0
  29. package/src/outbound.ts +70 -142
  30. package/src/runtime.ts +4 -4
  31. package/src/state.ts +609 -0
  32. package/src/types.ts +51 -148
  33. package/dist/src/account-lock.js +0 -91
  34. package/dist/src/client.js +0 -229
  35. package/dist/src/cursor-store.js +0 -136
  36. package/dist/src/inbound-dedupe.js +0 -175
  37. package/dist/src/lifecycle.js +0 -35
  38. package/dist/src/poll-loop.js +0 -125
  39. package/dist/src/responding.js +0 -13
  40. package/dist/src/security.js +0 -26
  41. package/dist/src/state-files.js +0 -167
  42. package/src/account-lock.ts +0 -108
  43. package/src/client.ts +0 -330
  44. package/src/cursor-store.ts +0 -186
  45. package/src/inbound-dedupe.ts +0 -241
  46. package/src/lifecycle.ts +0 -42
  47. package/src/poll-loop.ts +0 -161
  48. package/src/responding.ts +0 -21
  49. package/src/security.ts +0 -36
  50. package/src/state-files.ts +0 -212
@@ -1,212 +0,0 @@
1
- import { jsonStore } from "@openclaw/fs-safe/store";
2
- import type { JsonStore } from "@openclaw/fs-safe/store";
3
- import { withFileLock } from "@openclaw/fs-safe/file-lock";
4
- import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
5
- import { chmodSync, lstatSync, mkdirSync } from "node:fs";
6
- import { hostname } from "node:os";
7
- import { basename, join } from "node:path";
8
-
9
- const RELAY_STATE_DOCUMENT_VERSION = 1;
10
- const RELAY_STATE_LOCK_VERSION = 1;
11
- const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
12
-
13
- type RelayStateLockOwner = {
14
- version: typeof RELAY_STATE_LOCK_VERSION;
15
- kind: "relay-state";
16
- pid: number;
17
- host: string;
18
- createdAt: string;
19
- };
20
-
21
- export type RelayStateDocument<T> = {
22
- version: typeof RELAY_STATE_DOCUMENT_VERSION;
23
- entries: Record<string, T>;
24
- };
25
-
26
- export function emptyRelayStateDocument<T>(): RelayStateDocument<T> {
27
- return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
28
- }
29
-
30
- function isRecord(value: unknown): value is Record<string, unknown> {
31
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
32
- }
33
-
34
- export function assertRelayStateDocument<T>(
35
- value: unknown,
36
- label: string,
37
- validateEntry: (key: string, value: unknown) => value is T,
38
- ): asserts value is RelayStateDocument<T> {
39
- if (
40
- !isRecord(value) ||
41
- value.version !== RELAY_STATE_DOCUMENT_VERSION ||
42
- !isRecord(value.entries)
43
- ) {
44
- throw new Error(`relay ${label} state is corrupt`);
45
- }
46
- for (const [key, entry] of Object.entries(value.entries)) {
47
- if (!validateEntry(key, entry)) {
48
- throw new Error(`relay ${label} state is corrupt`);
49
- }
50
- }
51
- }
52
-
53
- function isRelayStateLockOwner(value: unknown): value is RelayStateLockOwner {
54
- if (!isRecord(value)) return false;
55
- return (
56
- value.version === RELAY_STATE_LOCK_VERSION &&
57
- value.kind === "relay-state" &&
58
- Number.isSafeInteger(value.pid) &&
59
- (value.pid as number) > 0 &&
60
- typeof value.host === "string" &&
61
- value.host.length > 0 &&
62
- typeof value.createdAt === "string" &&
63
- Number.isFinite(Date.parse(value.createdAt))
64
- );
65
- }
66
-
67
- function localProcessIsLive(pid: number): boolean {
68
- try {
69
- process.kill(pid, 0);
70
- return true;
71
- } catch (error) {
72
- // EPERM proves the process exists but is owned by another user. Unknown
73
- // failures also fail closed; only ESRCH proves this host no longer has it.
74
- return !(error instanceof Error && "code" in error && error.code === "ESRCH");
75
- }
76
- }
77
-
78
- /**
79
- * Windows opens the sidecar lock with an openat-style
80
- * `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
81
- * is still delete-pending, that create returns `ACCESS_DENIED` instead of the
82
- * `already exists` fs-safe retries on, so contention escapes the acquire loop
83
- * as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
84
- * timeout: a genuine permission failure simply reproduces until the deadline
85
- * and then surfaces unchanged.
86
- */
87
- function isWindowsLockAcquisitionContention(error: unknown): boolean {
88
- if (process.platform !== "win32") return false;
89
- const code = (error as NodeJS.ErrnoException | null)?.code;
90
- return code === "EACCES" || code === "EPERM";
91
- }
92
-
93
- function lockRetryDelayMs(attempt: number, remainingMs: number): number {
94
- const backoff = Math.min(25 * 2 ** attempt, 250);
95
- return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
96
- }
97
-
98
- function canRecoverRelayStateLock(value: unknown): boolean {
99
- return (
100
- isRelayStateLockOwner(value) &&
101
- value.host === hostname() &&
102
- !localProcessIsLive(value.pid)
103
- );
104
- }
105
-
106
- function ensurePrivateStateDirectory(path: string): void {
107
- mkdirSync(path, { recursive: true, mode: 0o700 });
108
- const stat = lstatSync(path);
109
- if (stat.isSymbolicLink() || !stat.isDirectory()) {
110
- throw new Error(`relay state path is not a private directory: ${path}`);
111
- }
112
- try {
113
- chmodSync(path, 0o700);
114
- } catch {
115
- // POSIX modes are not fully implemented on Windows. fs-safe's private
116
- // write path still owns the platform-specific file guarantees.
117
- }
118
- }
119
-
120
- /**
121
- * Relay owns these files rather than requesting OpenClaw's privileged host
122
- * SQLite. jsonStore gives every mutation a private atomic replacement. An
123
- * fs-safe sidecar lock serializes cross-process mutations and is recovered
124
- * only when its valid Relay owner names this host and its PID is provably dead.
125
- */
126
- export function openRelayStateDocument<T>(params: {
127
- fileName: string;
128
- env?: NodeJS.ProcessEnv;
129
- lockTimeoutMs?: number;
130
- }): JsonStore<RelayStateDocument<T>> {
131
- if (!params.fileName || basename(params.fileName) !== params.fileName) {
132
- throw new Error("relay state fileName must be one file name");
133
- }
134
- const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
135
- if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
136
- throw new Error("relay state lockTimeoutMs must be a positive safe integer");
137
- }
138
- const stateRoot = resolveStateDir(params.env ?? process.env);
139
- const relayRoot = join(stateRoot, "relay");
140
- const relayStateRoot = join(relayRoot, "state");
141
- ensurePrivateStateDirectory(relayRoot);
142
- ensurePrivateStateDirectory(relayStateRoot);
143
- const store = jsonStore<RelayStateDocument<T>>({
144
- filePath: join(relayStateRoot, params.fileName),
145
- dirMode: 0o700,
146
- mode: 0o600,
147
- });
148
- const withMutationLock = async <R>(run: () => Promise<R>): Promise<R> => {
149
- const deadline = Date.now() + lockTimeoutMs;
150
- for (let attempt = 0; ; attempt += 1) {
151
- // Only acquisition is retried. Once the mutation itself has started it has
152
- // observed state under the lock, so replaying it could double-apply.
153
- let mutationStarted = false;
154
- try {
155
- return await withFileLock(
156
- store.filePath,
157
- {
158
- managerKey: `relay-state:${store.filePath}`,
159
- staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
160
- timeoutMs: Math.max(1, deadline - Date.now()),
161
- staleRecovery: "remove-if-unchanged",
162
- retry: {
163
- retries: 300,
164
- minTimeout: 25,
165
- maxTimeout: 250,
166
- randomize: true,
167
- },
168
- payload: (): RelayStateLockOwner => ({
169
- version: RELAY_STATE_LOCK_VERSION,
170
- kind: "relay-state",
171
- pid: process.pid,
172
- host: hostname(),
173
- createdAt: new Date().toISOString(),
174
- }),
175
- shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
176
- shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
177
- },
178
- async () => {
179
- mutationStarted = true;
180
- return await run();
181
- },
182
- );
183
- } catch (error) {
184
- const remaining = deadline - Date.now();
185
- if (
186
- mutationStarted ||
187
- remaining <= 0 ||
188
- !isWindowsLockAcquisitionContention(error)
189
- ) {
190
- throw error;
191
- }
192
- await new Promise((resolve) =>
193
- setTimeout(resolve, lockRetryDelayMs(attempt, remaining)),
194
- );
195
- }
196
- }
197
- };
198
-
199
- return {
200
- filePath: store.filePath,
201
- read: store.read,
202
- readOr: store.readOr,
203
- readRequired: store.readRequired,
204
- write: async (value) => {
205
- await withMutationLock(async () => await store.write(value));
206
- },
207
- update: async (run) =>
208
- await withMutationLock(async () => await store.update(run)),
209
- updateOr: async (fallback, run) =>
210
- await withMutationLock(async () => await store.updateOr(fallback, run)),
211
- };
212
- }