@mysten-incubation/memwal-mcp 0.0.14-dev.0 → 0.0.14-dev.10
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/README.md +238 -1
- package/dist/auth-required.d.ts +325 -0
- package/dist/auth-required.d.ts.map +1 -1
- package/dist/auth-required.js +84 -16
- package/dist/auth-required.js.map +1 -1
- package/dist/auth.d.ts +179 -18
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +458 -35
- package/dist/auth.js.map +1 -1
- package/dist/auto-save.d.ts +100 -0
- package/dist/auto-save.d.ts.map +1 -0
- package/dist/auto-save.js +290 -0
- package/dist/auto-save.js.map +1 -0
- package/dist/bridge.d.ts +14 -2
- package/dist/bridge.d.ts.map +1 -1
- package/dist/bridge.js +233 -25
- package/dist/bridge.js.map +1 -1
- package/dist/consent.d.ts +36 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/consent.js +129 -0
- package/dist/consent.js.map +1 -0
- package/dist/health-probe.d.ts +35 -0
- package/dist/health-probe.d.ts.map +1 -0
- package/dist/health-probe.js +87 -0
- package/dist/health-probe.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +266 -5
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +17 -18
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +84 -20
- package/dist/instructions.js.map +1 -1
- package/dist/login.d.ts +15 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +109 -9
- package/dist/login.js.map +1 -1
- package/dist/memory-policy.d.ts +38 -0
- package/dist/memory-policy.d.ts.map +1 -0
- package/dist/memory-policy.js +80 -0
- package/dist/memory-policy.js.map +1 -0
- package/dist/messages.d.ts +10 -3
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +16 -5
- package/dist/messages.js.map +1 -1
- package/dist/recovery.d.ts +1 -0
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +15 -13
- package/dist/recovery.js.map +1 -1
- package/package.json +1 -1
package/dist/bridge.js
CHANGED
|
@@ -3,8 +3,9 @@ import { TOOL_DEFINITIONS } from "./auth-required.js";
|
|
|
3
3
|
import { clientInfoHeaders, lastClientInfoHeaders, rememberInitializeClientInfo, } from "./client-info.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import { ensureCompatibleRelayer, resolveConnectTimeoutMs } from "./compatibility.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { describeHealthProbe, probeRelayerHealth, resolveHealthProbeMs, } from "./health-probe.js";
|
|
7
|
+
import { proactiveInstructions } from "./instructions.js";
|
|
8
|
+
import { startOrReuseLoginFlow, resolveLoginTimeoutMs, isLoginSuperseded } from "./login.js";
|
|
8
9
|
import { log, note } from "./logger.js";
|
|
9
10
|
import { loginPrompt, loginSuccessNotice, loginSuccessNotification, } from "./messages.js";
|
|
10
11
|
import { openStreamableSession, resolveTransport } from "./streamable.js";
|
|
@@ -33,7 +34,8 @@ const NAMESPACE_TOOLS = new Set([
|
|
|
33
34
|
* per-call namespace always wins over the configured default.
|
|
34
35
|
*/
|
|
35
36
|
/**
|
|
36
|
-
* Name the
|
|
37
|
+
* Name the destination this process is bound to in a `memwal_health` result:
|
|
38
|
+
* the relayer it dialled, and the account it signs for.
|
|
37
39
|
*
|
|
38
40
|
* The relayer-side text can only report an origin its deployment published, and
|
|
39
41
|
* stays silent on a self-hosted or local one, where the sidecar knows nothing
|
|
@@ -45,8 +47,15 @@ const NAMESPACE_TOOLS = new Set([
|
|
|
45
47
|
* Rewrites an existing `relayer=` field rather than appending a second one: when
|
|
46
48
|
* both sides know the origin they describe the same session, and two
|
|
47
49
|
* conflicting fields would be worse than neither.
|
|
50
|
+
*
|
|
51
|
+
* `account=` rides along for the same reason (WALM-639). A project-local
|
|
52
|
+
* credentials file can point this process at a different account than the one
|
|
53
|
+
* the user signed in with, and "which account am I writing to" was otherwise
|
|
54
|
+
* only visible in stderr the MCP host usually hides — so the half of the
|
|
55
|
+
* destination that decides WHOSE memory this is now shows up beside the half
|
|
56
|
+
* that decides where it is stored.
|
|
48
57
|
*/
|
|
49
|
-
export function annotateHealthResult(result, relayerUrl) {
|
|
58
|
+
export function annotateHealthResult(result, relayerUrl, accountId) {
|
|
50
59
|
// A failed health call has no session to describe; naming a relayer beside
|
|
51
60
|
// an error reads as though that relayer answered.
|
|
52
61
|
if (result.isError)
|
|
@@ -60,6 +69,12 @@ export function annotateHealthResult(result, relayerUrl) {
|
|
|
60
69
|
block.text = existing.test(block.text)
|
|
61
70
|
? block.text.replace(existing, `relayer=${relayerUrl}`)
|
|
62
71
|
: `${block.text} relayer=${relayerUrl}`;
|
|
72
|
+
if (!accountId)
|
|
73
|
+
return;
|
|
74
|
+
const existingAccount = /\baccount=\S+/;
|
|
75
|
+
block.text = existingAccount.test(block.text)
|
|
76
|
+
? block.text.replace(existingAccount, `account=${accountId}`)
|
|
77
|
+
: `${block.text} account=${accountId}`;
|
|
63
78
|
}
|
|
64
79
|
export function applyDefaultNamespace(msg, namespace) {
|
|
65
80
|
if (!namespace)
|
|
@@ -88,7 +103,13 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
88
103
|
description: "Sign in (or re-sign in) to Walrus Memory by opening a browser. Use to switch wallets, refresh credentials, or sign in for the first time. Returns a click-able URL — the user must approve in their browser.",
|
|
89
104
|
inputSchema: {
|
|
90
105
|
type: "object",
|
|
91
|
-
properties: {
|
|
106
|
+
properties: {
|
|
107
|
+
freshKey: {
|
|
108
|
+
type: "boolean",
|
|
109
|
+
default: false,
|
|
110
|
+
description: "Mint a new delegate key instead of reusing the one a previous unfinished sign-in left pending. Leave false for an ordinary login. Set it only after a normal sign-in has already failed on the pending key — it abandons that key, which must then be removed from the dashboard if it was approved on-chain, and the new one needs its own add_delegate_key transaction.",
|
|
111
|
+
},
|
|
112
|
+
},
|
|
92
113
|
additionalProperties: false,
|
|
93
114
|
},
|
|
94
115
|
},
|
|
@@ -129,7 +150,11 @@ function buildLocalInitializeResult(params) {
|
|
|
129
150
|
// client: this local answer wins and the upstream initialize reply is
|
|
130
151
|
// suppressed. Omitting it here silently strips the proactive contract
|
|
131
152
|
// from every stdio client, which is the WALM-324 regression itself.
|
|
132
|
-
|
|
153
|
+
//
|
|
154
|
+
// Resolved per handshake, not read from a module const: whether the
|
|
155
|
+
// model is told to save unprompted depends on the user's automatic-save
|
|
156
|
+
// opt-in, which lives on disk and can change between spawns (WALM-642).
|
|
157
|
+
instructions: proactiveInstructions(),
|
|
133
158
|
};
|
|
134
159
|
}
|
|
135
160
|
/** Names of the tools we serve locally, so we can de-dup them out of the
|
|
@@ -152,6 +177,15 @@ const SIGNED_OUT_FAILURE = {
|
|
|
152
177
|
* "connection dropped, please retry", which is advice that cannot work. Name
|
|
153
178
|
* the cause and the way back in instead (GH #365 / WALM-602). */
|
|
154
179
|
const UNAUTHORIZED_TEXT = "❌ Walrus Memory rejected the saved credentials (HTTP 401). The delegate key may have been revoked or is no longer registered on this account. Call `memwal_login` to sign in again — saved credentials were NOT modified.";
|
|
180
|
+
/** Reply for a `tools/call` naming a tool the connected relayer does not
|
|
181
|
+
* serve. Names what IS on offer, because the agent's next move is to pick one
|
|
182
|
+
* of those — "unknown tool" alone leaves it guessing or retrying. */
|
|
183
|
+
export function unknownToolText(name, available) {
|
|
184
|
+
return (`❌ \`${name}\` is not a tool this Walrus Memory server offers. ` +
|
|
185
|
+
`Available: ${available.join(", ")}. ` +
|
|
186
|
+
`Your tool list is stale — re-read \`tools/list\` and use one of those instead. ` +
|
|
187
|
+
`Nothing ran, so nothing was saved or changed.`);
|
|
188
|
+
}
|
|
155
189
|
/** `failRequest` options for every credentials-rejected refusal, so one refused
|
|
156
190
|
* at handshake time and one refused on arrival afterwards read identically. */
|
|
157
191
|
const UNAUTHORIZED_FAILURE = {
|
|
@@ -175,6 +209,10 @@ const LOCAL_TOOLS_LIST = {
|
|
|
175
209
|
...LOCAL_TOOL_DEFINITIONS,
|
|
176
210
|
],
|
|
177
211
|
};
|
|
212
|
+
/** Names advertised at cold start (baseline relayer + locally-served). A
|
|
213
|
+
* `tools/call` for anything else is refused until an upstream `tools/list`
|
|
214
|
+
* has been seen — fail-closed, not fail-open. */
|
|
215
|
+
const COLD_START_TOOL_NAMES = new Set(LOCAL_TOOLS_LIST.tools.map((t) => t.name));
|
|
178
216
|
const URL_READY_TIMEOUT_MS = 5_000;
|
|
179
217
|
/** Maximum silence we tolerate on the SSE stream before assuming the
|
|
180
218
|
* relayer-side session has gone dead. The relayer sends keepalive events
|
|
@@ -218,8 +256,13 @@ const MIN_CALL_TIMEOUT_MS = 1_000;
|
|
|
218
256
|
* `MAX_STATUS_WAIT_MS` for `memwal_remember_status`, and the fixed `timeoutMs`
|
|
219
257
|
* the bulk and analyze tools pass to the SDK. Keep them in lockstep: a value
|
|
220
258
|
* below a tool's real ceiling abandons healthy work. Unlisted tools keep the
|
|
221
|
-
* default.
|
|
259
|
+
* default.
|
|
260
|
+
*
|
|
261
|
+
* `memwal_recall` is the exception to lockstep: the SDK aborts the recall
|
|
262
|
+
* request itself at 15s, but the checks it runs first (`/version`, `/config`)
|
|
263
|
+
* carry their own deadlines, or none on older SDKs. 90s covers those. */
|
|
222
264
|
const TOOL_DEADLINE_MS = {
|
|
265
|
+
memwal_recall: 90_000,
|
|
223
266
|
memwal_remember: 90_000,
|
|
224
267
|
memwal_remember_status: 60_000,
|
|
225
268
|
memwal_remember_bulk: 120_000,
|
|
@@ -700,13 +743,18 @@ function writeStdoutMessage(msg) {
|
|
|
700
743
|
* mode, but available even when creds already exist (so user can re-login,
|
|
701
744
|
* switch wallets, or refresh). Returns a click-able URL near-instantly;
|
|
702
745
|
* listener stays alive in the background until callback or timeout. */
|
|
703
|
-
async function handleLocalLogin(config, onCredentials) {
|
|
746
|
+
async function handleLocalLogin(config, onCredentials, freshKey) {
|
|
747
|
+
let discardedKey;
|
|
704
748
|
const session = startOrReuseLoginFlow({
|
|
705
749
|
relayerUrl: config.relayerUrl,
|
|
706
750
|
webUrl: config.webUrl,
|
|
707
751
|
label: config.label,
|
|
708
752
|
timeoutMs: resolveLoginTimeoutMs(),
|
|
709
753
|
openBrowser: false,
|
|
754
|
+
freshKey,
|
|
755
|
+
onDiscarded: (key) => {
|
|
756
|
+
discardedKey = key;
|
|
757
|
+
},
|
|
710
758
|
}, async (creds) => {
|
|
711
759
|
await onCredentials(creds);
|
|
712
760
|
log.info("memwal_login.bridge.success", {
|
|
@@ -750,8 +798,10 @@ async function handleLocalLogin(config, onCredentials) {
|
|
|
750
798
|
// cannot help that key, since it reuses it and the
|
|
751
799
|
// dashboard cannot register it twice.
|
|
752
800
|
data: `Walrus Memory sign-in did not complete: ${msg}. Existing credentials are ` +
|
|
753
|
-
`unchanged.
|
|
754
|
-
`
|
|
801
|
+
`unchanged. The key is kept on disk and a later start reclaims it only if ` +
|
|
802
|
+
`the relayer can confirm it was registered, which it cannot always do. ` +
|
|
803
|
+
`Call memwal_login again to retry with that key, or with freshKey: true to ` +
|
|
804
|
+
`mint a new one and abandon it.`,
|
|
755
805
|
},
|
|
756
806
|
});
|
|
757
807
|
});
|
|
@@ -761,6 +811,9 @@ async function handleLocalLogin(config, onCredentials) {
|
|
|
761
811
|
url = await Promise.race([session.url, timeoutPromise]);
|
|
762
812
|
}
|
|
763
813
|
catch (err) {
|
|
814
|
+
if (isLoginSuperseded(err)) {
|
|
815
|
+
return { isError: true, text: "This sign-in was replaced by a newer memwal_login. Open that result instead of starting another login." };
|
|
816
|
+
}
|
|
764
817
|
return {
|
|
765
818
|
isError: true,
|
|
766
819
|
text: `❌ Failed to start login: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -776,6 +829,7 @@ async function handleLocalLogin(config, onCredentials) {
|
|
|
776
829
|
url,
|
|
777
830
|
credentialsPath: credsPath(),
|
|
778
831
|
signedIn: loadCreds() !== null,
|
|
832
|
+
discardedKey,
|
|
779
833
|
}),
|
|
780
834
|
};
|
|
781
835
|
}
|
|
@@ -1148,15 +1202,23 @@ pendingLines = []) {
|
|
|
1148
1202
|
const inFlight = new Map();
|
|
1149
1203
|
const callTimeoutMs = resolveCallTimeoutMs();
|
|
1150
1204
|
const stalledHandshakeMs = resolveStalledHandshakeMs(callTimeoutMs);
|
|
1205
|
+
const healthProbeMs = resolveHealthProbeMs();
|
|
1151
1206
|
/** IDs of `tools/list` requests we've forwarded to the relayer. When
|
|
1152
1207
|
* the response comes back through the SSE pump, we splice in the
|
|
1153
1208
|
* locally-served `memwal_login` + `memwal_logout` tools so the MCP
|
|
1154
1209
|
* client surfaces them in its tool palette. */
|
|
1155
1210
|
const pendingListIds = new Set();
|
|
1156
|
-
/**
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1211
|
+
/** Tool names the CONNECTED relayer advertised on its last `tools/list`,
|
|
1212
|
+
* minus the ones we serve locally. Empty until the client has listed tools
|
|
1213
|
+
* at least once over the *current* session. `reconnect()` clears it: a
|
|
1214
|
+
* stale allow would re-forward a tool the new relayer does not serve
|
|
1215
|
+
* (GH #928), and a stale deny would refuse a tool it does. Until the
|
|
1216
|
+
* next upstream list, the gate allows only the cold-start floor. */
|
|
1217
|
+
const upstreamToolNames = new Set();
|
|
1218
|
+
/** IDs of forwarded `memwal_health` calls, each against the destination the
|
|
1219
|
+
* call went out on — relayer URL and account. Captured at send time rather
|
|
1220
|
+
* than read at reply time so a reconnect that swapped credentials mid-flight
|
|
1221
|
+
* cannot label the answer with a destination it did not come from. */
|
|
1160
1222
|
const pendingHealthIds = new Map();
|
|
1161
1223
|
/** Record a 429 and tell the user ONCE that this is a rate limit rather
|
|
1162
1224
|
* than a broken config — the distinction the MCP host cannot make for
|
|
@@ -1273,6 +1335,15 @@ pendingLines = []) {
|
|
|
1273
1335
|
throttleNoticed = false;
|
|
1274
1336
|
clearHandshakeFailure();
|
|
1275
1337
|
endConnectEpisode();
|
|
1338
|
+
// This session has not advertised anything yet. Keep the
|
|
1339
|
+
// previous set and a login that swapped relayerUrl would
|
|
1340
|
+
// re-forward a tool the new one does not serve, or refuse
|
|
1341
|
+
// one it does, until the client happened to re-list.
|
|
1342
|
+
upstreamToolNames.clear();
|
|
1343
|
+
writeStdoutMessage({
|
|
1344
|
+
jsonrpc: "2.0",
|
|
1345
|
+
method: "notifications/tools/list_changed",
|
|
1346
|
+
});
|
|
1276
1347
|
// An accepted handshake retires any earlier rejection —
|
|
1277
1348
|
// `memwal_login` re-registers a key and lands here, not on
|
|
1278
1349
|
// the background connect's publish path, so clearing only
|
|
@@ -1311,6 +1382,15 @@ pendingLines = []) {
|
|
|
1311
1382
|
log.info("bridge.replay_halted_signed_out", { id });
|
|
1312
1383
|
break;
|
|
1313
1384
|
}
|
|
1385
|
+
// The sweeper already declared this call's reply lost
|
|
1386
|
+
// and is answering it as failed. Posting it again would
|
|
1387
|
+
// run it a second time — a duplicate write for a tool
|
|
1388
|
+
// with no idempotency key — and the reply would be
|
|
1389
|
+
// dropped anyway, as a late answer for a closed-out id.
|
|
1390
|
+
if (entry.orphaned) {
|
|
1391
|
+
log.info("bridge.replay_skipped_orphaned", { id });
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1314
1394
|
const msg = entry.msg;
|
|
1315
1395
|
try {
|
|
1316
1396
|
// A replayed `initialize` produces a fresh upstream
|
|
@@ -1641,6 +1721,16 @@ pendingLines = []) {
|
|
|
1641
1721
|
// duplicate tool name. Mirrors LOCAL_TOOLS_LIST.
|
|
1642
1722
|
const upstream = result.tools.filter((t) => !LOCAL_TOOL_NAMES.has(t.name ?? ""));
|
|
1643
1723
|
result.tools = [...upstream, ...LOCAL_TOOL_DEFINITIONS];
|
|
1724
|
+
// Record what this relayer actually serves. A
|
|
1725
|
+
// later call for a name absent here is answered
|
|
1726
|
+
// locally instead of being forwarded into a wait
|
|
1727
|
+
// no reply will ever end.
|
|
1728
|
+
upstreamToolNames.clear();
|
|
1729
|
+
for (const t of upstream) {
|
|
1730
|
+
if (typeof t.name === "string" && t.name !== "") {
|
|
1731
|
+
upstreamToolNames.add(t.name);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1644
1734
|
}
|
|
1645
1735
|
}
|
|
1646
1736
|
if (value &&
|
|
@@ -1652,7 +1742,7 @@ pendingLines = []) {
|
|
|
1652
1742
|
const dialled = pendingHealthIds.get(value.id);
|
|
1653
1743
|
pendingHealthIds.delete(value.id);
|
|
1654
1744
|
if (dialled !== undefined) {
|
|
1655
|
-
annotateHealthResult(value.result, dialled);
|
|
1745
|
+
annotateHealthResult(value.result, dialled.relayerUrl, dialled.accountId);
|
|
1656
1746
|
}
|
|
1657
1747
|
}
|
|
1658
1748
|
// Health annotation runs BEFORE the sign-in banner. Both
|
|
@@ -1755,7 +1845,7 @@ pendingLines = []) {
|
|
|
1755
1845
|
id: msg.id,
|
|
1756
1846
|
});
|
|
1757
1847
|
if (params.name === "memwal_login") {
|
|
1758
|
-
const result = await handleLocalLogin(config, adoptCredentials);
|
|
1848
|
+
const result = await handleLocalLogin(config, adoptCredentials, params.arguments?.freshKey === true);
|
|
1759
1849
|
writeStdoutMessage({
|
|
1760
1850
|
jsonrpc: "2.0",
|
|
1761
1851
|
id: msg.id,
|
|
@@ -1811,6 +1901,45 @@ pendingLines = []) {
|
|
|
1811
1901
|
failRequest(msg, "credentials rejected", UNAUTHORIZED_FAILURE);
|
|
1812
1902
|
return;
|
|
1813
1903
|
}
|
|
1904
|
+
// Version skew: this bridge ships on npm and updates itself,
|
|
1905
|
+
// while a relayer ships per environment and does not, so the
|
|
1906
|
+
// bridge is routinely newer than the server it dials. A tool
|
|
1907
|
+
// named in initialize instructions or a cached list can be
|
|
1908
|
+
// missing from the session that actually came up —
|
|
1909
|
+
// `memwal_remember_status` against a prod relayer, which is
|
|
1910
|
+
// GH #928. Forwarding that call parks it in `inFlight` until
|
|
1911
|
+
// the orphan sweeper's deadline (60s + 30s headroom for that
|
|
1912
|
+
// tool), and the user reads the 90s as a hang.
|
|
1913
|
+
//
|
|
1914
|
+
// Fail closed. Before any upstream `tools/list`, only the
|
|
1915
|
+
// cold-start floor is callable. After one, only what that
|
|
1916
|
+
// relayer advertised (plus locally-served tools). A stale
|
|
1917
|
+
// tool list is not a transport fault: say so now, while the
|
|
1918
|
+
// agent can still act on it.
|
|
1919
|
+
if (msg.method === "tools/call" && msg.id != null) {
|
|
1920
|
+
const called = msg.params?.name;
|
|
1921
|
+
if (typeof called === "string" && !LOCAL_TOOL_NAMES.has(called)) {
|
|
1922
|
+
const known = upstreamToolNames.size > 0
|
|
1923
|
+
? upstreamToolNames
|
|
1924
|
+
: COLD_START_TOOL_NAMES;
|
|
1925
|
+
if (!known.has(called)) {
|
|
1926
|
+
const available = [
|
|
1927
|
+
...(upstreamToolNames.size > 0
|
|
1928
|
+
? [...upstreamToolNames, ...LOCAL_TOOL_NAMES]
|
|
1929
|
+
: COLD_START_TOOL_NAMES),
|
|
1930
|
+
].sort();
|
|
1931
|
+
log.warn("bridge.tool_not_served", {
|
|
1932
|
+
tool: called,
|
|
1933
|
+
window: upstreamToolNames.size > 0 ? "upstream" : "cold-start",
|
|
1934
|
+
});
|
|
1935
|
+
failRequest(msg, "tool not served", {
|
|
1936
|
+
toolText: unknownToolText(called, available),
|
|
1937
|
+
errorMessage: `${called} is not served by this Walrus Memory relayer`,
|
|
1938
|
+
});
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1814
1943
|
// Fill in the configured default namespace for memory tool
|
|
1815
1944
|
// calls that didn't pass one. Mutates msg in place so the
|
|
1816
1945
|
// forwarded — and any replayed-on-reconnect — copy carries it.
|
|
@@ -1825,7 +1954,10 @@ pendingLines = []) {
|
|
|
1825
1954
|
if (msg.method === "tools/call" &&
|
|
1826
1955
|
msg.id != null &&
|
|
1827
1956
|
msg.params?.name === "memwal_health") {
|
|
1828
|
-
pendingHealthIds.set(msg.id,
|
|
1957
|
+
pendingHealthIds.set(msg.id, {
|
|
1958
|
+
relayerUrl: creds?.relayerUrl ?? config.relayerUrl,
|
|
1959
|
+
accountId: creds?.accountId,
|
|
1960
|
+
});
|
|
1829
1961
|
}
|
|
1830
1962
|
// Track requests (have both method and id) so we can replay
|
|
1831
1963
|
// them on reconnect. Notifications and responses are not
|
|
@@ -2079,13 +2211,19 @@ pendingLines = []) {
|
|
|
2079
2211
|
"memwal_remember_bulk",
|
|
2080
2212
|
"memwal_analyze",
|
|
2081
2213
|
]);
|
|
2082
|
-
function expiredRequestReport(neverSent, now, tool) {
|
|
2214
|
+
function expiredRequestReport(neverSent, now, tool, health) {
|
|
2083
2215
|
if (!neverSent) {
|
|
2216
|
+
// What the relayer's `/health` said just now: tells a dead
|
|
2217
|
+
// relayer from a wrong URL from one stuck call.
|
|
2218
|
+
const described = health ? describeHealthProbe(health.probe, health.relayerUrl) : null;
|
|
2084
2219
|
// The request reached the relayer. What is missing is the reply,
|
|
2085
2220
|
// and for a write that distinction is the whole message: the work
|
|
2086
2221
|
// may have completed, may still be running, and cannot be assumed
|
|
2087
2222
|
// undone. "Please retry" is only safe advice for a read.
|
|
2088
2223
|
if (tool !== null && MUTATING_TOOLS.has(tool)) {
|
|
2224
|
+
const healthNote = described
|
|
2225
|
+
? `\nRelayer health: ${described.health}. ${described.verdict}`
|
|
2226
|
+
: "";
|
|
2089
2227
|
return {
|
|
2090
2228
|
reason: "no response to a sent write",
|
|
2091
2229
|
opts: {
|
|
@@ -2095,20 +2233,36 @@ pendingLines = []) {
|
|
|
2095
2233
|
"it and does not mean nothing was stored. Do NOT simply repeat the " +
|
|
2096
2234
|
"call: run `memwal_recall` for this content first, and only re-save " +
|
|
2097
2235
|
"what is genuinely missing. Repeating a bulk save that already " +
|
|
2098
|
-
"landed stores a second paid copy."
|
|
2236
|
+
"landed stores a second paid copy." +
|
|
2237
|
+
healthNote,
|
|
2099
2238
|
errorMessage: `Walrus Memory ${tool} was sent but its reply never arrived. The write ` +
|
|
2100
|
-
"may have completed; verify with recall before retrying."
|
|
2239
|
+
"may have completed; verify with recall before retrying." +
|
|
2240
|
+
healthNote,
|
|
2101
2241
|
},
|
|
2102
2242
|
};
|
|
2103
2243
|
}
|
|
2244
|
+
// Not "this call only reads": `memwal_restore` re-indexes. What
|
|
2245
|
+
// makes a retry safe is that none of these can store a duplicate.
|
|
2246
|
+
const nextStep = described === null || described.reachable
|
|
2247
|
+
? "Repeating this call cannot store a duplicate, so it is safe to retry " +
|
|
2248
|
+
"once. If it keeps happening, report it with the time of the call."
|
|
2249
|
+
: "Repeating this call cannot store a duplicate, so it is safe to retry " +
|
|
2250
|
+
"after the relayer is reachable again — wait a minute if the problem " +
|
|
2251
|
+
"is on the relayer's side.";
|
|
2252
|
+
const healthValue = described?.health ?? "not checked";
|
|
2104
2253
|
return {
|
|
2105
2254
|
reason: "no response",
|
|
2106
2255
|
opts: {
|
|
2107
|
-
toolText:
|
|
2108
|
-
"
|
|
2109
|
-
"
|
|
2110
|
-
|
|
2111
|
-
|
|
2256
|
+
toolText: [
|
|
2257
|
+
"❌ Walrus Memory did not answer this call.",
|
|
2258
|
+
"Cause: the request reached the relayer but no reply came back." +
|
|
2259
|
+
(described ? ` ${described.verdict}` : ""),
|
|
2260
|
+
`Relayer health: ${healthValue}`,
|
|
2261
|
+
`Next step: ${nextStep}`,
|
|
2262
|
+
].join("\n"),
|
|
2263
|
+
errorMessage: "Walrus Memory call reached the relayer but never received a " +
|
|
2264
|
+
`response (relayer health: ${healthValue}). Safe to retry: it cannot ` +
|
|
2265
|
+
"store a duplicate.",
|
|
2112
2266
|
},
|
|
2113
2267
|
};
|
|
2114
2268
|
}
|
|
@@ -2148,6 +2302,8 @@ pendingLines = []) {
|
|
|
2148
2302
|
const orphanSweeper = setInterval(() => {
|
|
2149
2303
|
const now = Date.now();
|
|
2150
2304
|
const handshakeStalledMs = handshakeStalledForMs(now);
|
|
2305
|
+
// One `/health` request per sweep, however many calls expired in it.
|
|
2306
|
+
let sweepProbe = null;
|
|
2151
2307
|
for (const [id, entry] of Array.from(inFlight.entries())) {
|
|
2152
2308
|
const elapsedMs = now - entry.startedAt;
|
|
2153
2309
|
// Never sent = no POST was ever issued for it. Read from the entry
|
|
@@ -2172,6 +2328,58 @@ pendingLines = []) {
|
|
|
2172
2328
|
: entry.deadlineMs;
|
|
2173
2329
|
if (elapsedMs <= deadlineMs)
|
|
2174
2330
|
continue;
|
|
2331
|
+
// `initialize` is answered locally and gets no reply here, so
|
|
2332
|
+
// there is nothing to explain and nothing to probe for.
|
|
2333
|
+
if (!neverSent && entry.msg.method !== "initialize") {
|
|
2334
|
+
// A sent call: ask the relayer's `/health` before answering,
|
|
2335
|
+
// so the message can say whether it is down, unreachable, or
|
|
2336
|
+
// up with this one call stuck. Only the answer waits on the
|
|
2337
|
+
// probe; the bookkeeping stays synchronous.
|
|
2338
|
+
if (entry.probing)
|
|
2339
|
+
continue;
|
|
2340
|
+
entry.probing = true;
|
|
2341
|
+
// From here the call is the sweeper's to answer: a reconnect
|
|
2342
|
+
// must not replay it behind the probe's back.
|
|
2343
|
+
entry.orphaned = true;
|
|
2344
|
+
const relayerUrl = creds?.relayerUrl ?? config.relayerUrl;
|
|
2345
|
+
// `probeRelayerHealth` does not reject, but a call left with
|
|
2346
|
+
// `probing` set and no answer is the one outcome this path
|
|
2347
|
+
// must never have, so a rejection still answers it.
|
|
2348
|
+
sweepProbe ??= probeRelayerHealth(relayerUrl, healthProbeMs).catch(() => null);
|
|
2349
|
+
void sweepProbe
|
|
2350
|
+
.then((probe) => {
|
|
2351
|
+
// A late reply, a logout or a shutdown may have answered
|
|
2352
|
+
// it while the probe ran. Answering again would be a
|
|
2353
|
+
// second response for the same id — and after stdin has
|
|
2354
|
+
// closed there is nobody left to answer.
|
|
2355
|
+
if (stdinClosed || inFlight.get(id) !== entry)
|
|
2356
|
+
return;
|
|
2357
|
+
const settledAt = Date.now();
|
|
2358
|
+
const { reason, opts } = expiredRequestReport(false, settledAt, toolNameOf(entry.msg), probe ? { probe, relayerUrl } : undefined);
|
|
2359
|
+
log.warn("bridge.call_orphaned", {
|
|
2360
|
+
id,
|
|
2361
|
+
method: entry.msg.method ?? null,
|
|
2362
|
+
elapsedMs: settledAt - entry.startedAt,
|
|
2363
|
+
deadlineMs,
|
|
2364
|
+
reason,
|
|
2365
|
+
health: probe?.kind ?? null,
|
|
2366
|
+
healthMs: probe?.ms ?? null,
|
|
2367
|
+
handshakeStalledMs: handshakeStalledForMs(settledAt),
|
|
2368
|
+
lastHandshakeError,
|
|
2369
|
+
});
|
|
2370
|
+
failRequest(entry.msg, reason, opts);
|
|
2371
|
+
})
|
|
2372
|
+
.catch((err) => {
|
|
2373
|
+
// Never leave a call marked `probing` with no answer:
|
|
2374
|
+
// clearing it lets the next sweep answer it.
|
|
2375
|
+
entry.probing = false;
|
|
2376
|
+
log.warn("bridge.call_orphaned_answer_failed", {
|
|
2377
|
+
id,
|
|
2378
|
+
error: err instanceof Error ? err.message : String(err),
|
|
2379
|
+
});
|
|
2380
|
+
});
|
|
2381
|
+
continue;
|
|
2382
|
+
}
|
|
2175
2383
|
// Built only for what actually expired: this walks `pendingForward`
|
|
2176
2384
|
// and interpolates two user-facing strings, and the branch it
|
|
2177
2385
|
// serves fires roughly never.
|