@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/dist/src/invocations.js
DELETED
|
@@ -1,47 +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();
|
|
21
|
-
function slotKey(accountId, conversationId) {
|
|
22
|
-
return `${accountId}\0${conversationId}`;
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Hold `invocationId` for the life of one turn. Returns the release function;
|
|
26
|
-
* call it in a `finally` so a thrown turn cannot strand the slot.
|
|
27
|
-
*
|
|
28
|
-
* Releasing only clears the slot if this turn still owns it, so a turn that
|
|
29
|
-
* finishes after being superseded cannot delete its successor's id.
|
|
30
|
-
*/
|
|
31
|
-
export function rememberRelayInvocation(params) {
|
|
32
|
-
const key = slotKey(params.accountId, params.conversationId);
|
|
33
|
-
pendingInvocations.set(key, params.invocationId);
|
|
34
|
-
return () => {
|
|
35
|
-
if (pendingInvocations.get(key) === params.invocationId) {
|
|
36
|
-
pendingInvocations.delete(key);
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
/** The invocation an outbound send in this conversation belongs to, if any. */
|
|
41
|
-
export function relayInvocationFor(params) {
|
|
42
|
-
return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
|
|
43
|
-
}
|
|
44
|
-
/** Test seam: drop every slot. */
|
|
45
|
-
export function resetRelayInvocationsForTest() {
|
|
46
|
-
pendingInvocations.clear();
|
|
47
|
-
}
|
package/dist/src/lifecycle.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/** Owns exactly one abortable long-poll lifecycle per configured account. */
|
|
2
|
-
export function createRelayAccountLifecycleRegistry() {
|
|
3
|
-
const controllers = new Map();
|
|
4
|
-
return {
|
|
5
|
-
acquire(accountId, parentSignal) {
|
|
6
|
-
if (controllers.has(accountId)) {
|
|
7
|
-
throw new Error(`relay: account "${accountId}" already has an active consumer`);
|
|
8
|
-
}
|
|
9
|
-
const controller = new AbortController();
|
|
10
|
-
controllers.set(accountId, controller);
|
|
11
|
-
const signal = AbortSignal.any([parentSignal, controller.signal]);
|
|
12
|
-
let released = false;
|
|
13
|
-
return {
|
|
14
|
-
signal,
|
|
15
|
-
release: () => {
|
|
16
|
-
if (released) {
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
released = true;
|
|
20
|
-
if (controllers.get(accountId) === controller) {
|
|
21
|
-
controllers.delete(accountId);
|
|
22
|
-
}
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
},
|
|
26
|
-
stop(accountId) {
|
|
27
|
-
const controller = controllers.get(accountId);
|
|
28
|
-
if (!controller) {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
controller.abort();
|
|
32
|
-
return true;
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
}
|
package/dist/src/poll-loop.js
DELETED
|
@@ -1,137 +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
|
-
const TRANSIENT_BASE_DELAY_MS = 500;
|
|
8
|
-
const TRANSIENT_MAX_DELAY_MS = 30_000;
|
|
9
|
-
function defaultSleep(ms, signal) {
|
|
10
|
-
return new Promise((resolve) => {
|
|
11
|
-
if (signal.aborted) {
|
|
12
|
-
resolve();
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
const timer = setTimeout(() => {
|
|
16
|
-
signal.removeEventListener("abort", onAbort);
|
|
17
|
-
resolve();
|
|
18
|
-
}, ms);
|
|
19
|
-
const onAbort = () => {
|
|
20
|
-
clearTimeout(timer);
|
|
21
|
-
resolve();
|
|
22
|
-
};
|
|
23
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
export async function runRelayPollLoop(params) {
|
|
27
|
-
const sleep = params.sleep ?? defaultSleep;
|
|
28
|
-
const random = params.random ?? Math.random;
|
|
29
|
-
const log = params.log ?? (() => { });
|
|
30
|
-
let transientAttempts = 0;
|
|
31
|
-
const transientDelayMs = () => {
|
|
32
|
-
const backoff = Math.min(TRANSIENT_MAX_DELAY_MS, TRANSIENT_BASE_DELAY_MS * 2 ** Math.min(transientAttempts, 6));
|
|
33
|
-
return backoff + Math.floor(random() * 250);
|
|
34
|
-
};
|
|
35
|
-
while (!params.abortSignal.aborted) {
|
|
36
|
-
let page;
|
|
37
|
-
try {
|
|
38
|
-
page = await params.client.pollEvents({
|
|
39
|
-
cursor: params.cursorStore.current(),
|
|
40
|
-
timeoutSeconds: params.timeoutSeconds ?? 30,
|
|
41
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
42
|
-
signal: params.abortSignal,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
if (params.abortSignal.aborted || isAbortError(error)) {
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
50
|
-
// Token revoked: settle so the supervisor applies terminalDisconnect
|
|
51
|
-
// (server-channels.ts:718) — an operator has to fix the token.
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
if (error instanceof RelayApiError && error.kind === "conflict") {
|
|
55
|
-
// Another consumer took the long poll. Settle and let the
|
|
56
|
-
// supervisor's backoff arbitrate.
|
|
57
|
-
log(`[relay] long poll terminated by another consumer: ${error.message}`);
|
|
58
|
-
throw error;
|
|
59
|
-
}
|
|
60
|
-
transientAttempts += 1;
|
|
61
|
-
log(`[relay] transient poll error (attempt ${transientAttempts}): ${String(error)}`);
|
|
62
|
-
await sleep(transientDelayMs(), params.abortSignal);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
transientAttempts = 0;
|
|
66
|
-
if (page.events.length > 0) {
|
|
67
|
-
params.onBatch?.(page.events);
|
|
68
|
-
}
|
|
69
|
-
let batchFailed = false;
|
|
70
|
-
for (const event of page.events) {
|
|
71
|
-
if (params.abortSignal.aborted) {
|
|
72
|
-
batchFailed = true;
|
|
73
|
-
break;
|
|
74
|
-
}
|
|
75
|
-
if (params.shouldProcess && !params.shouldProcess(event)) {
|
|
76
|
-
// Bookkeeping events (receipts, reactions, echoes) never dispatch, so
|
|
77
|
-
// they are acked by the batch cursor without a dedupe row.
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
const claimed = await params.deduper.claimEvent(event.event_id);
|
|
81
|
-
if (claimed) {
|
|
82
|
-
let attempted = false;
|
|
83
|
-
const markAttempt = async () => {
|
|
84
|
-
if (attempted)
|
|
85
|
-
return;
|
|
86
|
-
await params.deduper.commitEvent(event.event_id);
|
|
87
|
-
attempted = true;
|
|
88
|
-
if (params.abortSignal.aborted)
|
|
89
|
-
throw new Error("aborted after durable attempt");
|
|
90
|
-
};
|
|
91
|
-
try {
|
|
92
|
-
await params.handleEvent(event, markAttempt);
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
if (!attempted) {
|
|
96
|
-
params.deduper.releaseEvent(event.event_id);
|
|
97
|
-
// A rejection is the server's final answer: replaying the identical
|
|
98
|
-
// request produces the identical refusal. Holding the cursor for it
|
|
99
|
-
// is a livelock, and the cursor is ONE watermark for the whole
|
|
100
|
-
// channel — so a single permanently-refused event would starve every
|
|
101
|
-
// later message, direct ones included (REL-167). Losing one event is
|
|
102
|
-
// strictly better than losing the channel, so log it loudly and let
|
|
103
|
-
// the page cursor move past it.
|
|
104
|
-
if (error instanceof RelayApiError && error.kind === "rejected") {
|
|
105
|
-
log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
|
|
106
|
-
`skipping it so later messages are not starved: ${String(error)}`);
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
110
|
-
batchFailed = true;
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
log(`[relay] event ${event.event_id} dispatch failed after its attempt was committed; ` +
|
|
114
|
-
`will not replay possible tool side effects: ${String(error)}`);
|
|
115
|
-
}
|
|
116
|
-
if (!attempted) {
|
|
117
|
-
// Admission/preflight intentionally consumed no side effect; release
|
|
118
|
-
// the claim and allow the page cursor to acknowledge it.
|
|
119
|
-
params.deduper.releaseEvent(event.event_id);
|
|
120
|
-
}
|
|
121
|
-
if (params.abortSignal.aborted) {
|
|
122
|
-
batchFailed = true;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
// The server's next_cursor covers the whole page (cursor N acks <= N), so
|
|
128
|
-
// only a batch with durable attempt markers may ack it. A marker-write
|
|
129
|
-
// failure acks nothing and committed predecessors absorb the replay.
|
|
130
|
-
if (!batchFailed) {
|
|
131
|
-
await params.cursorStore.advance(page.nextCursor);
|
|
132
|
-
}
|
|
133
|
-
else {
|
|
134
|
-
await sleep(transientDelayMs(), params.abortSignal);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
}
|
package/dist/src/responding.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { RelayApiError } from "./client.js";
|
|
2
|
-
/**
|
|
3
|
-
* Record the read/responding receipt, then commit the durable attempt marker.
|
|
4
|
-
*
|
|
5
|
-
* The receipt is a courtesy to the person waiting: it turns their message Read
|
|
6
|
-
* and shows that something is composing. It is NOT permission to answer, and
|
|
7
|
-
* it used to be treated as such — a rejected receipt threw here, before
|
|
8
|
-
* `markAttempt`, which sent the poll loop down its replay branch and froze the
|
|
9
|
-
* channel's single delivery cursor. One group mention whose receipt the server
|
|
10
|
-
* refused therefore starved every later message, direct ones included
|
|
11
|
-
* (REL-167).
|
|
12
|
-
*
|
|
13
|
-
* So a failed receipt is reported and the turn continues. The ordering that
|
|
14
|
-
* mattered is kept: the receipt is still attempted BEFORE the attempt marker,
|
|
15
|
-
* so a receipt that succeeds still precedes any agent or tool work.
|
|
16
|
-
*/
|
|
17
|
-
export async function markRespondingBeforeAttempt(params) {
|
|
18
|
-
const { facts } = params;
|
|
19
|
-
try {
|
|
20
|
-
await params.client.setResponding({
|
|
21
|
-
conversationId: facts.conversationId,
|
|
22
|
-
messageId: facts.messageId,
|
|
23
|
-
label: params.label,
|
|
24
|
-
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
catch (error) {
|
|
28
|
-
// An aborted shutdown is not a receipt failure; let it settle the loop.
|
|
29
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
30
|
-
throw error;
|
|
31
|
-
}
|
|
32
|
-
const detail = error instanceof RelayApiError ? error.message : String(error);
|
|
33
|
-
params.onReceiptFailure?.(`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`);
|
|
34
|
-
}
|
|
35
|
-
await params.markAttempt();
|
|
36
|
-
}
|
package/dist/src/security.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
function normalizeSenderId(value) {
|
|
2
|
-
const normalized = String(value).trim();
|
|
3
|
-
return normalized ? normalized : null;
|
|
4
|
-
}
|
|
5
|
-
/**
|
|
6
|
-
* Build the only identities allowed to start an OpenClaw turn. The Relay
|
|
7
|
-
* account owner is pinned from the authenticated agent profile; operators can
|
|
8
|
-
* deliberately extend that set with `allowFrom`. No wildcard is accepted.
|
|
9
|
-
*/
|
|
10
|
-
export function resolveRelayAllowedSenderIds(params) {
|
|
11
|
-
const allowed = new Set();
|
|
12
|
-
const owner = params.profile.owner_user_id?.trim();
|
|
13
|
-
if (owner) {
|
|
14
|
-
allowed.add(owner);
|
|
15
|
-
}
|
|
16
|
-
for (const entry of params.allowFrom ?? []) {
|
|
17
|
-
const normalized = normalizeSenderId(entry);
|
|
18
|
-
if (normalized && normalized !== "*") {
|
|
19
|
-
allowed.add(normalized);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return [...allowed];
|
|
23
|
-
}
|
|
24
|
-
export function relaySenderIsAllowed(allowedSenderIds, senderId) {
|
|
25
|
-
return allowedSenderIds.includes(senderId);
|
|
26
|
-
}
|
package/dist/src/state-files.js
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
import { jsonStore } from "@openclaw/fs-safe/store";
|
|
2
|
-
import { withFileLock } from "@openclaw/fs-safe/file-lock";
|
|
3
|
-
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
|
4
|
-
import { chmodSync, lstatSync, mkdirSync } from "node:fs";
|
|
5
|
-
import { hostname } from "node:os";
|
|
6
|
-
import { basename, join } from "node:path";
|
|
7
|
-
const RELAY_STATE_DOCUMENT_VERSION = 1;
|
|
8
|
-
const RELAY_STATE_LOCK_VERSION = 1;
|
|
9
|
-
const RELAY_STATE_LOCK_TIMEOUT_MS = 30_000;
|
|
10
|
-
export function emptyRelayStateDocument() {
|
|
11
|
-
return { version: RELAY_STATE_DOCUMENT_VERSION, entries: {} };
|
|
12
|
-
}
|
|
13
|
-
function isRecord(value) {
|
|
14
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
15
|
-
}
|
|
16
|
-
export function assertRelayStateDocument(value, label, validateEntry) {
|
|
17
|
-
if (!isRecord(value) ||
|
|
18
|
-
value.version !== RELAY_STATE_DOCUMENT_VERSION ||
|
|
19
|
-
!isRecord(value.entries)) {
|
|
20
|
-
throw new Error(`relay ${label} state is corrupt`);
|
|
21
|
-
}
|
|
22
|
-
for (const [key, entry] of Object.entries(value.entries)) {
|
|
23
|
-
if (!validateEntry(key, entry)) {
|
|
24
|
-
throw new Error(`relay ${label} state is corrupt`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function isRelayStateLockOwner(value) {
|
|
29
|
-
if (!isRecord(value))
|
|
30
|
-
return false;
|
|
31
|
-
return (value.version === RELAY_STATE_LOCK_VERSION &&
|
|
32
|
-
value.kind === "relay-state" &&
|
|
33
|
-
Number.isSafeInteger(value.pid) &&
|
|
34
|
-
value.pid > 0 &&
|
|
35
|
-
typeof value.host === "string" &&
|
|
36
|
-
value.host.length > 0 &&
|
|
37
|
-
typeof value.createdAt === "string" &&
|
|
38
|
-
Number.isFinite(Date.parse(value.createdAt)));
|
|
39
|
-
}
|
|
40
|
-
function localProcessIsLive(pid) {
|
|
41
|
-
try {
|
|
42
|
-
process.kill(pid, 0);
|
|
43
|
-
return true;
|
|
44
|
-
}
|
|
45
|
-
catch (error) {
|
|
46
|
-
// EPERM proves the process exists but is owned by another user. Unknown
|
|
47
|
-
// failures also fail closed; only ESRCH proves this host no longer has it.
|
|
48
|
-
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Windows opens the sidecar lock with an openat-style
|
|
53
|
-
* `O_CREAT | O_EXCL` beneath a parent handle. While a just-released lock file
|
|
54
|
-
* is still delete-pending, that create returns `ACCESS_DENIED` instead of the
|
|
55
|
-
* `already exists` fs-safe retries on, so contention escapes the acquire loop
|
|
56
|
-
* as a hard `EACCES`. Retry those on Windows only, bounded by the caller's lock
|
|
57
|
-
* timeout: a genuine permission failure simply reproduces until the deadline
|
|
58
|
-
* and then surfaces unchanged.
|
|
59
|
-
*/
|
|
60
|
-
function isWindowsLockAcquisitionContention(error) {
|
|
61
|
-
if (process.platform !== "win32")
|
|
62
|
-
return false;
|
|
63
|
-
const code = error?.code;
|
|
64
|
-
return code === "EACCES" || code === "EPERM";
|
|
65
|
-
}
|
|
66
|
-
function lockRetryDelayMs(attempt, remainingMs) {
|
|
67
|
-
const backoff = Math.min(25 * 2 ** attempt, 250);
|
|
68
|
-
return Math.max(1, Math.min(backoff * (0.5 + Math.random() / 2), remainingMs));
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* The sidecar lock has no in-process fast path: every waiter polls the lock
|
|
72
|
-
* file, and losing an attempt costs an exclusive create plus a snapshot read.
|
|
73
|
-
* Relay mutates one document from several tasks at once — a poll batch
|
|
74
|
-
* registers one dedupe entry per inbound message — so N in-process writers
|
|
75
|
-
* become N pollers competing with the holder for the same file. Funnelling
|
|
76
|
-
* them through one in-memory queue leaves a single poller per process, which
|
|
77
|
-
* matters most on Windows: same-process losers no longer race the holder's
|
|
78
|
-
* unlink, so contention stops manifesting as delete-pending denials.
|
|
79
|
-
*
|
|
80
|
-
* Keyed by the store's file path. Two paths spelled differently for one file
|
|
81
|
-
* would each get a queue and simply fall back to the sidecar lock for
|
|
82
|
-
* correctness, so a miss costs throughput rather than serialization.
|
|
83
|
-
*/
|
|
84
|
-
const RELAY_STATE_MUTEX_KEY = Symbol.for("relay.stateFileMutexes");
|
|
85
|
-
function stateFileMutexes() {
|
|
86
|
-
const container = globalThis;
|
|
87
|
-
container[RELAY_STATE_MUTEX_KEY] ??= new Map();
|
|
88
|
-
return container[RELAY_STATE_MUTEX_KEY];
|
|
89
|
-
}
|
|
90
|
-
function fileLockTimeout(filePath) {
|
|
91
|
-
return Object.assign(new Error(`file lock timeout for ${filePath}`), {
|
|
92
|
-
code: "file_lock_timeout",
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
/** Waits for our turn, but never past the caller's lock deadline. */
|
|
96
|
-
async function awaitTurn(turn, deadline, filePath) {
|
|
97
|
-
const remaining = deadline - Date.now();
|
|
98
|
-
if (remaining <= 0)
|
|
99
|
-
throw fileLockTimeout(filePath);
|
|
100
|
-
let timer;
|
|
101
|
-
try {
|
|
102
|
-
await Promise.race([
|
|
103
|
-
turn,
|
|
104
|
-
new Promise((_resolve, reject) => {
|
|
105
|
-
timer = setTimeout(() => reject(fileLockTimeout(filePath)), remaining);
|
|
106
|
-
}),
|
|
107
|
-
]);
|
|
108
|
-
}
|
|
109
|
-
finally {
|
|
110
|
-
if (timer)
|
|
111
|
-
clearTimeout(timer);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
async function withStateFileMutex(filePath, deadline, run) {
|
|
115
|
-
const mutexes = stateFileMutexes();
|
|
116
|
-
const previous = mutexes.get(filePath);
|
|
117
|
-
let release;
|
|
118
|
-
const ours = new Promise((resolve) => {
|
|
119
|
-
release = resolve;
|
|
120
|
-
});
|
|
121
|
-
// Chain even when we abandon our turn on timeout: later waiters still queue
|
|
122
|
-
// behind the holder we were waiting on, so ordering survives a giving-up
|
|
123
|
-
// waiter.
|
|
124
|
-
const tail = previous ? previous.then(() => ours) : ours;
|
|
125
|
-
mutexes.set(filePath, tail);
|
|
126
|
-
let tookTurn = false;
|
|
127
|
-
try {
|
|
128
|
-
if (previous)
|
|
129
|
-
await awaitTurn(previous, deadline, filePath);
|
|
130
|
-
tookTurn = true;
|
|
131
|
-
return await run();
|
|
132
|
-
}
|
|
133
|
-
finally {
|
|
134
|
-
release();
|
|
135
|
-
// Forgetting the queue is only safe once it has drained. A waiter that gave
|
|
136
|
-
// up is still queued behind a holder that is running, so dropping the entry
|
|
137
|
-
// there would let the next caller past the holder and back onto the lock
|
|
138
|
-
// file the queue exists to keep it off. Leaving it costs one settled promise
|
|
139
|
-
// until the next caller drains it.
|
|
140
|
-
if (tookTurn && mutexes.get(filePath) === tail)
|
|
141
|
-
mutexes.delete(filePath);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
function canRecoverRelayStateLock(value) {
|
|
145
|
-
return (isRelayStateLockOwner(value) &&
|
|
146
|
-
value.host === hostname() &&
|
|
147
|
-
!localProcessIsLive(value.pid));
|
|
148
|
-
}
|
|
149
|
-
function ensurePrivateStateDirectory(path) {
|
|
150
|
-
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
151
|
-
const stat = lstatSync(path);
|
|
152
|
-
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
153
|
-
throw new Error(`relay state path is not a private directory: ${path}`);
|
|
154
|
-
}
|
|
155
|
-
try {
|
|
156
|
-
chmodSync(path, 0o700);
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
// POSIX modes are not fully implemented on Windows. fs-safe's private
|
|
160
|
-
// write path still owns the platform-specific file guarantees.
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Relay owns these files rather than requesting OpenClaw's privileged host
|
|
165
|
-
* SQLite. jsonStore gives every mutation a private atomic replacement. An
|
|
166
|
-
* fs-safe sidecar lock serializes cross-process mutations and is recovered
|
|
167
|
-
* only when its valid Relay owner names this host and its PID is provably dead.
|
|
168
|
-
*/
|
|
169
|
-
export function openRelayStateDocument(params) {
|
|
170
|
-
if (!params.fileName || basename(params.fileName) !== params.fileName) {
|
|
171
|
-
throw new Error("relay state fileName must be one file name");
|
|
172
|
-
}
|
|
173
|
-
const lockTimeoutMs = params.lockTimeoutMs ?? RELAY_STATE_LOCK_TIMEOUT_MS;
|
|
174
|
-
if (!Number.isSafeInteger(lockTimeoutMs) || lockTimeoutMs < 1) {
|
|
175
|
-
throw new Error("relay state lockTimeoutMs must be a positive safe integer");
|
|
176
|
-
}
|
|
177
|
-
const stateRoot = resolveStateDir(params.env ?? process.env);
|
|
178
|
-
const relayRoot = join(stateRoot, "relay");
|
|
179
|
-
const relayStateRoot = join(relayRoot, "state");
|
|
180
|
-
ensurePrivateStateDirectory(relayRoot);
|
|
181
|
-
ensurePrivateStateDirectory(relayStateRoot);
|
|
182
|
-
const store = jsonStore({
|
|
183
|
-
filePath: join(relayStateRoot, params.fileName),
|
|
184
|
-
dirMode: 0o700,
|
|
185
|
-
mode: 0o600,
|
|
186
|
-
});
|
|
187
|
-
const withMutationLock = async (run) => {
|
|
188
|
-
const deadline = Date.now() + lockTimeoutMs;
|
|
189
|
-
return await withStateFileMutex(store.filePath, deadline, async () => {
|
|
190
|
-
for (let attempt = 0;; attempt += 1) {
|
|
191
|
-
// Only acquisition is retried. Once the mutation itself has started it
|
|
192
|
-
// has observed state under the lock, so replaying it could double-apply.
|
|
193
|
-
let mutationStarted = false;
|
|
194
|
-
try {
|
|
195
|
-
return await withFileLock(store.filePath, {
|
|
196
|
-
managerKey: `relay-state:${store.filePath}`,
|
|
197
|
-
staleMs: RELAY_STATE_LOCK_TIMEOUT_MS,
|
|
198
|
-
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
199
|
-
staleRecovery: "remove-if-unchanged",
|
|
200
|
-
retry: {
|
|
201
|
-
retries: 300,
|
|
202
|
-
minTimeout: 25,
|
|
203
|
-
maxTimeout: 250,
|
|
204
|
-
randomize: true,
|
|
205
|
-
},
|
|
206
|
-
payload: () => ({
|
|
207
|
-
version: RELAY_STATE_LOCK_VERSION,
|
|
208
|
-
kind: "relay-state",
|
|
209
|
-
pid: process.pid,
|
|
210
|
-
host: hostname(),
|
|
211
|
-
createdAt: new Date().toISOString(),
|
|
212
|
-
}),
|
|
213
|
-
shouldReclaim: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
214
|
-
shouldRemoveStaleLock: ({ payload }) => canRecoverRelayStateLock(payload),
|
|
215
|
-
}, async () => {
|
|
216
|
-
mutationStarted = true;
|
|
217
|
-
return await run();
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
catch (error) {
|
|
221
|
-
const remaining = deadline - Date.now();
|
|
222
|
-
if (mutationStarted ||
|
|
223
|
-
remaining <= 0 ||
|
|
224
|
-
!isWindowsLockAcquisitionContention(error)) {
|
|
225
|
-
throw error;
|
|
226
|
-
}
|
|
227
|
-
await new Promise((resolve) => setTimeout(resolve, lockRetryDelayMs(attempt, remaining)));
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
};
|
|
232
|
-
return {
|
|
233
|
-
filePath: store.filePath,
|
|
234
|
-
read: store.read,
|
|
235
|
-
readOr: store.readOr,
|
|
236
|
-
readRequired: store.readRequired,
|
|
237
|
-
write: async (value) => {
|
|
238
|
-
await withMutationLock(async () => await store.write(value));
|
|
239
|
-
},
|
|
240
|
-
update: async (run) => await withMutationLock(async () => await store.update(run)),
|
|
241
|
-
updateOr: async (fallback, run) => await withMutationLock(async () => await store.updateOr(fallback, run)),
|
|
242
|
-
};
|
|
243
|
-
}
|