@openviking/dsh-memory-plugin 0.3.0 → 0.4.3
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 +86 -22
- package/capture.mjs +14 -0
- package/client.mjs +6 -155
- package/config.mjs +36 -166
- package/index.mjs +17 -4
- package/mcp-env.mjs +33 -0
- package/mcp.mjs +2 -33
- package/package.json +9 -6
- package/runtime.mjs +115 -28
- package/servers/mcp-proxy.mjs +13 -19
- package/shared/capture-utils.mjs +146 -7
- package/shared/config-schema.mjs +359 -0
- package/shared/credentials.mjs +238 -102
- package/shared/input-filters.mjs +219 -0
- package/shared/mcp-proxy-config.mjs +171 -9
- package/shared/mcp-proxy-core.mjs +35 -15
- package/shared/ov-http.mjs +112 -0
- package/shared/pending-queue.mjs +41 -5
- package/shared/plugin-config.mjs +239 -29
- package/shared/recall-compress-core.mjs +17 -6
- package/shared/recall-core.mjs +128 -10
- package/shared/uri-guard.mjs +173 -1
- package/shared/workspace-config.mjs +433 -0
- package/shared/workspace-identity.mjs +334 -0
- package/shared/workspace-peer.mjs +189 -8
- package/shared/workspace-registry.mjs +87 -0
- package/skills/openviking-memory/SKILL.md +19 -0
- package/skills.mjs +3 -4
- package/uri-guard.mjs +20 -10
- package/shared/setup-wizard.mjs +0 -114
|
@@ -12,9 +12,56 @@
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join, resolve as resolvePath } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { CONNECTION_ENV_VARS, CREDENTIAL_ENV_VARS } from "./credentials.mjs";
|
|
16
|
+
|
|
15
17
|
export const DEFAULT_PROXY_TIMEOUT_MS = 15000;
|
|
16
18
|
const MIN_PROXY_TIMEOUT_MS = 1000;
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every variable that changes what an MCP proxy sends. Hooks inherit their
|
|
22
|
+
* host's whole environment; a host that hands its MCP servers an allowlist
|
|
23
|
+
* instead (Codex's `env_vars`) has to name each of these, or the proxy
|
|
24
|
+
* resolves a different connection from the hooks beside it.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_PROXY_ENV_VARS = [
|
|
27
|
+
...CONNECTION_ENV_VARS,
|
|
28
|
+
"OPENVIKING_HOME",
|
|
29
|
+
"OPENVIKING_STATE_DIR",
|
|
30
|
+
"OPENVIKING_RECALL_PEER_SCOPE",
|
|
31
|
+
"OPENVIKING_TIMEOUT_MS",
|
|
32
|
+
"OPENVIKING_DEBUG",
|
|
33
|
+
"OPENVIKING_DEBUG_LOG",
|
|
34
|
+
"OPENVIKING_EXTRA_HEADERS",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A resolved connection as the environment of a child proxy process.
|
|
39
|
+
*
|
|
40
|
+
* The other way across the process boundary, for a host that cannot hand its
|
|
41
|
+
* MCP servers the inputs (DSH drops credential-shaped names from what a child
|
|
42
|
+
* inherits): the parent resolves once and the child reads the answer back. The
|
|
43
|
+
* forced `env` source makes that exact — the child reads no file, so a key the
|
|
44
|
+
* parent left empty stays empty instead of falling through to
|
|
45
|
+
* `server.root_api_key`. Every credential variable is written, the empty ones
|
|
46
|
+
* too, because the rest of the parent's environment still reaches the child:
|
|
47
|
+
* an `OPENVIKING_ACCOUNT` the parent's pinned chain ignored must not fill the
|
|
48
|
+
* gap there.
|
|
49
|
+
*/
|
|
50
|
+
export function forwardConnectionEnv(connection) {
|
|
51
|
+
const env = Object.fromEntries(CREDENTIAL_ENV_VARS.map((name) => [name, ""]));
|
|
52
|
+
return {
|
|
53
|
+
...env,
|
|
54
|
+
OPENVIKING_CREDENTIAL_SOURCE: "env",
|
|
55
|
+
OPENVIKING_URL: connection.baseUrl,
|
|
56
|
+
OPENVIKING_MCP_URL: connection.mcpUrl,
|
|
57
|
+
OPENVIKING_API_KEY: connection.apiKey || "",
|
|
58
|
+
OPENVIKING_ACCOUNT: connection.account || "",
|
|
59
|
+
OPENVIKING_USER: connection.user || "",
|
|
60
|
+
OPENVIKING_PEER_ID: connection.peerId || "",
|
|
61
|
+
OPENVIKING_AUTH_MODE: connection.authMode,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
export function trimSlash(value) {
|
|
19
66
|
return String(value || "").replace(/\/+$/, "");
|
|
20
67
|
}
|
|
@@ -45,30 +92,101 @@ export function defaultCredentialPaths(env = process.env) {
|
|
|
45
92
|
* Resolve the actor peer header for a long-lived MCP proxy process.
|
|
46
93
|
*
|
|
47
94
|
* MCP servers may start in the plugin directory rather than the active
|
|
48
|
-
* workspace, so their process cwd is not a reliable peer identity
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
95
|
+
* workspace, so their process cwd is not a reliable peer identity — and unlike
|
|
96
|
+
* a hook, a long-lived proxy cannot re-derive one per turn. Broad recall
|
|
97
|
+
* intentionally spans the authenticated user's peer workspaces and therefore
|
|
98
|
+
* leaves the actor header unset. Actor-scoped recall needs an explicit peer so
|
|
99
|
+
* isolation never depends on the proxy's launch directory.
|
|
100
|
+
*
|
|
101
|
+
* A missing one degrades to broad recall with a warning rather than refusing to
|
|
102
|
+
* start: the proxy is what carries every memory tool, and taking those away
|
|
103
|
+
* because a scope preference cannot be honoured costs the user far more than
|
|
104
|
+
* the wider search does. Only the harness whose parent process injects the peer
|
|
105
|
+
* (dsh, `mcp-env.mjs`) can satisfy this without the user setting it.
|
|
52
106
|
*/
|
|
53
107
|
export function resolveMcpActorPeerId({
|
|
54
108
|
peerId = "",
|
|
55
109
|
recallPeerScope = "all",
|
|
110
|
+
onWarn = null,
|
|
56
111
|
} = {}) {
|
|
57
112
|
if (recallPeerScope !== "actor") return "";
|
|
58
113
|
|
|
59
114
|
const explicitPeerId = String(peerId || "").trim();
|
|
60
115
|
if (explicitPeerId) return explicitPeerId;
|
|
61
116
|
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
+ "Set actor_peer_id in ovcli.conf or OPENVIKING_PEER_ID in the MCP environment."
|
|
65
|
-
);
|
|
117
|
+
const message = "OpenViking MCP: actor-scoped recall needs an explicit peer id, which an MCP proxy cannot "
|
|
118
|
+
+ "derive from its launch directory. Falling back to broad recall across every peer under this user. "
|
|
119
|
+
+ "Set actor_peer_id in ovcli.conf, or OPENVIKING_PEER_ID in the MCP server environment, to scope it.";
|
|
120
|
+
if (typeof onWarn === "function") onWarn(message);
|
|
121
|
+
else process.stderr.write(`${message}\n`);
|
|
122
|
+
return "";
|
|
66
123
|
}
|
|
67
124
|
|
|
68
125
|
function uniq(values) {
|
|
69
126
|
return [...new Set(values.filter(Boolean))];
|
|
70
127
|
}
|
|
71
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Parse `OPENVIKING_EXTRA_HEADERS` into a plain string→string map.
|
|
131
|
+
*
|
|
132
|
+
* Some private deployments sit behind gateways that require custom headers
|
|
133
|
+
* (a tenant/vault name, a signed token, a region hint) on every request. The
|
|
134
|
+
* proxy has no other seam to inject those: the credential file's own
|
|
135
|
+
* `extra_headers` field is intentionally not exported to MCP callers (see
|
|
136
|
+
* `workspace-config.mjs` `FORBIDDEN_KEYS`), so this env-only escape hatch keeps
|
|
137
|
+
* that boundary while letting an operator unblock a strict upstream.
|
|
138
|
+
*
|
|
139
|
+
* The value is a JSON object; anything else is ignored with a stderr warning
|
|
140
|
+
* rather than crashing the proxy — a bad env var must not take down the whole
|
|
141
|
+
* memory tool surface. Reserved names (`Content-Type`, `Accept`,
|
|
142
|
+
* `MCP-Protocol-Version`, `Mcp-Session-Id`, `Authorization`) are dropped: they
|
|
143
|
+
* are set by the proxy itself and letting the env override them would break
|
|
144
|
+
* session negotiation or expose the wrong credential.
|
|
145
|
+
*/
|
|
146
|
+
const RESERVED_HEADER_NAMES = new Set([
|
|
147
|
+
"content-type",
|
|
148
|
+
"accept",
|
|
149
|
+
"mcp-protocol-version",
|
|
150
|
+
"mcp-session-id",
|
|
151
|
+
"authorization",
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
export function parseExtraHeaders(raw, { onWarn = null } = {}) {
|
|
155
|
+
const source = String(raw || "").trim();
|
|
156
|
+
if (!source) return {};
|
|
157
|
+
const warn = (message) => {
|
|
158
|
+
if (typeof onWarn === "function") onWarn(message);
|
|
159
|
+
else process.stderr.write(`${message}\n`);
|
|
160
|
+
};
|
|
161
|
+
let parsed;
|
|
162
|
+
try {
|
|
163
|
+
parsed = JSON.parse(source);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
warn(`OPENVIKING_EXTRA_HEADERS is not valid JSON; ignoring (${err.message})`);
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
169
|
+
warn("OPENVIKING_EXTRA_HEADERS must be a JSON object of string values; ignoring");
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
174
|
+
const name = String(key || "").trim();
|
|
175
|
+
if (!name) continue;
|
|
176
|
+
if (RESERVED_HEADER_NAMES.has(name.toLowerCase())) {
|
|
177
|
+
warn(`OPENVIKING_EXTRA_HEADERS: ignoring reserved header "${name}"`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (value === null || value === undefined) continue;
|
|
181
|
+
if (typeof value === "object") {
|
|
182
|
+
warn(`OPENVIKING_EXTRA_HEADERS: header "${name}" must be a scalar; ignoring`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
out[name] = String(value);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
72
190
|
/**
|
|
73
191
|
* Shape a harness's loaded config into the object `createOpenVikingMcpProxy`
|
|
74
192
|
* expects.
|
|
@@ -76,7 +194,9 @@ function uniq(values) {
|
|
|
76
194
|
* `mcpUrl` wins over `baseUrl` when both are given, so a harness that lets the
|
|
77
195
|
* user pin an explicit MCP URL keeps that behavior. `watchedPaths` are the
|
|
78
196
|
* harness's own extra files; the shared defaults are appended and the result
|
|
79
|
-
* deduplicated.
|
|
197
|
+
* deduplicated. `sendIdentityHeaders` carries the harness's resolved auth mode
|
|
198
|
+
* so an `api_key` server, which reads the identity out of the key and ignores
|
|
199
|
+
* the headers, never sees the operator's account and user on the wire.
|
|
80
200
|
*/
|
|
81
201
|
export function buildMcpProxyConfig({
|
|
82
202
|
baseUrl = "",
|
|
@@ -84,6 +204,7 @@ export function buildMcpProxyConfig({
|
|
|
84
204
|
apiKey = "",
|
|
85
205
|
account = "",
|
|
86
206
|
user = "",
|
|
207
|
+
sendIdentityHeaders = false,
|
|
87
208
|
peerId = "",
|
|
88
209
|
userAgent = "",
|
|
89
210
|
timeoutMs,
|
|
@@ -92,13 +213,18 @@ export function buildMcpProxyConfig({
|
|
|
92
213
|
credentialSource = "auto",
|
|
93
214
|
credentialPath = "",
|
|
94
215
|
watchedPaths = [],
|
|
216
|
+
extraHeaders = null,
|
|
95
217
|
env = process.env,
|
|
96
218
|
} = {}) {
|
|
219
|
+
const resolvedExtraHeaders = extraHeaders && typeof extraHeaders === "object" && !Array.isArray(extraHeaders)
|
|
220
|
+
? { ...extraHeaders }
|
|
221
|
+
: parseExtraHeaders(env.OPENVIKING_EXTRA_HEADERS);
|
|
97
222
|
return {
|
|
98
223
|
mcpUrl: mcpUrl || `${trimSlash(baseUrl)}/mcp`,
|
|
99
224
|
apiKey: apiKey || "",
|
|
100
225
|
account: account || "",
|
|
101
226
|
user: user || "",
|
|
227
|
+
sendIdentityHeaders: sendIdentityHeaders === true,
|
|
102
228
|
peerId: peerId || "",
|
|
103
229
|
userAgent: userAgent || "",
|
|
104
230
|
timeoutMs: Math.max(
|
|
@@ -110,5 +236,41 @@ export function buildMcpProxyConfig({
|
|
|
110
236
|
credentialSource: credentialSource || "auto",
|
|
111
237
|
credentialPath: credentialPath || "",
|
|
112
238
|
watchedPaths: uniq([...watchedPaths, ...defaultCredentialPaths(env)]),
|
|
239
|
+
extraHeaders: resolvedExtraHeaders,
|
|
113
240
|
};
|
|
114
241
|
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The proxy config for a resolved harness config, mapped in one place.
|
|
245
|
+
*
|
|
246
|
+
* Every proxy used to copy fields out of its loader by hand, and the copies
|
|
247
|
+
* drifted: two dropped `mcpUrl`, so `OPENVIKING_MCP_URL` reached the hooks and
|
|
248
|
+
* not the tools. `cfg` is what `buildPluginConfig` or `buildProxyConnection`
|
|
249
|
+
* returned. `peerId` replaces the actor peer only for a harness whose parent
|
|
250
|
+
* process resolved it (dsh); everywhere else a long-lived proxy may send one
|
|
251
|
+
* only when actor-scoped recall asks for it.
|
|
252
|
+
*/
|
|
253
|
+
export function toMcpProxyConfig(cfg, {
|
|
254
|
+
env = process.env,
|
|
255
|
+
peerId = undefined,
|
|
256
|
+
debug = cfg.debug,
|
|
257
|
+
debugLogPath = cfg.debugLogPath,
|
|
258
|
+
} = {}) {
|
|
259
|
+
return buildMcpProxyConfig({
|
|
260
|
+
baseUrl: cfg.baseUrl,
|
|
261
|
+
mcpUrl: cfg.mcpUrl,
|
|
262
|
+
apiKey: cfg.apiKey,
|
|
263
|
+
account: cfg.account,
|
|
264
|
+
user: cfg.user,
|
|
265
|
+
sendIdentityHeaders: cfg.sendIdentityHeaders,
|
|
266
|
+
peerId: peerId === undefined ? resolveMcpActorPeerId(cfg) : peerId,
|
|
267
|
+
userAgent: cfg.userAgent,
|
|
268
|
+
timeoutMs: cfg.timeoutMs,
|
|
269
|
+
debug,
|
|
270
|
+
debugLogPath,
|
|
271
|
+
credentialSource: cfg.credentialSource,
|
|
272
|
+
credentialPath: cfg.credentialPath || "",
|
|
273
|
+
watchedPaths: [cfg.cliPath, cfg.ovPath],
|
|
274
|
+
env,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { statSync } from "node:fs";
|
|
10
10
|
import { createInterface } from "node:readline";
|
|
11
11
|
|
|
12
|
+
import { buildOvHeaders } from "./ov-http.mjs";
|
|
13
|
+
|
|
12
14
|
const DEFAULT_PROTOCOL_VERSION = "2025-06-18";
|
|
13
15
|
const DELETE_TIMEOUT_MS = 2000;
|
|
14
16
|
const MAX_CONCURRENT_REQUESTS = 16;
|
|
@@ -240,21 +242,27 @@ export function createOpenVikingMcpProxy({
|
|
|
240
242
|
}
|
|
241
243
|
|
|
242
244
|
function headersForRequest(includeSession = true) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
245
|
+
const built = buildOvHeaders(proxyConfig, {
|
|
246
|
+
actorPeerId: proxyConfig.peerId,
|
|
247
|
+
extraHeaders: {
|
|
248
|
+
"Accept": "application/json, text/event-stream",
|
|
249
|
+
// Always the proxy's current version (default, then server-negotiated) —
|
|
250
|
+
// never the client's un-negotiated ask, which strict upstreams reject
|
|
251
|
+
// with HTTP 400 before initialize negotiation can run.
|
|
252
|
+
"MCP-Protocol-Version": protocolVersion,
|
|
253
|
+
...(includeSession && sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
// Operator-supplied extras (OPENVIKING_EXTRA_HEADERS) merged with the
|
|
257
|
+
// proxy's own headers winning — the env-only escape hatch fills gaps for
|
|
258
|
+
// strict private-gateway upstreams (tenant/vault names, region hints)
|
|
259
|
+
// without letting a stray env var override authentication or session
|
|
260
|
+
// negotiation.
|
|
261
|
+
const opExtras = proxyConfig.extraHeaders;
|
|
262
|
+
if (opExtras && typeof opExtras === "object") {
|
|
263
|
+
return { ...opExtras, ...built };
|
|
264
|
+
}
|
|
265
|
+
return built;
|
|
258
266
|
}
|
|
259
267
|
|
|
260
268
|
function writeMessage(obj) {
|
|
@@ -292,6 +300,18 @@ export function createOpenVikingMcpProxy({
|
|
|
292
300
|
);
|
|
293
301
|
}
|
|
294
302
|
const msg = err instanceof Error ? err.message : String(err);
|
|
303
|
+
if (err && err.name === "AbortError") {
|
|
304
|
+
// Client-side timeout, not an outage: the server may be healthy and
|
|
305
|
+
// still computing (rerank-inclusive find/search can legitimately take
|
|
306
|
+
// longer than the default budget). Keep -32001 for genuine
|
|
307
|
+
// connection failures so the two are not misdiagnosed as each other.
|
|
308
|
+
return errorResponse(
|
|
309
|
+
id,
|
|
310
|
+
-32004,
|
|
311
|
+
`OpenViking MCP request timed out after ${proxyConfig.timeoutMs}ms (${proxyConfig.mcpUrl}). The server may still be processing (rerank can be slow) — check /health or raise OPENVIKING_TIMEOUT_MS.`,
|
|
312
|
+
{ timeoutMs: proxyConfig.timeoutMs, mcpUrl: proxyConfig.mcpUrl, cause: msg },
|
|
313
|
+
);
|
|
314
|
+
}
|
|
295
315
|
return errorResponse(
|
|
296
316
|
id,
|
|
297
317
|
-32001,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* The one path from a hook to the OpenViking server.
|
|
4
|
+
*
|
|
5
|
+
* Every harness used to own its own AbortController, header block and envelope
|
|
6
|
+
* parser, and they drifted: one sent the api key twice, one named the operator
|
|
7
|
+
* to every proxy on the path whether or not the server was trusted with it, one
|
|
8
|
+
* read a different field for the same account. A server-side change now lands
|
|
9
|
+
* in one place instead of eight.
|
|
10
|
+
*
|
|
11
|
+
* `X-API-Key` is deliberately absent. The server takes `Authorization: Bearer`,
|
|
12
|
+
* and the identity headers go out only when the resolved config says this
|
|
13
|
+
* server is trusted with the operator's name.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// A request that aborts the instant it starts reads as a dead server. A config
|
|
17
|
+
// that asks for less than a second gets a second.
|
|
18
|
+
const MIN_TIMEOUT_MS = 1000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The headers every harness puts on every OpenViking request.
|
|
22
|
+
*
|
|
23
|
+
* `identityHeaders` overrides `cfg.sendIdentityHeaders` for the callers that
|
|
24
|
+
* decide per request rather than per config — the doctor probes both ways.
|
|
25
|
+
*/
|
|
26
|
+
export function buildOvHeaders(cfg = {}, { actorPeerId = "", identityHeaders, extraHeaders } = {}) {
|
|
27
|
+
const identity = identityHeaders === undefined ? Boolean(cfg.sendIdentityHeaders) : Boolean(identityHeaders);
|
|
28
|
+
const headers = { "Content-Type": "application/json" };
|
|
29
|
+
if (cfg.apiKey) headers["Authorization"] = `Bearer ${cfg.apiKey}`;
|
|
30
|
+
if (identity && cfg.account) headers["X-OpenViking-Account"] = cfg.account;
|
|
31
|
+
if (identity && cfg.user) headers["X-OpenViking-User"] = cfg.user;
|
|
32
|
+
if (actorPeerId) headers["X-OpenViking-Actor-Peer"] = actorPeerId;
|
|
33
|
+
if (cfg.userAgent) headers["User-Agent"] = cfg.userAgent;
|
|
34
|
+
return { ...headers, ...extraHeaders };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function responseTraceId(body) {
|
|
38
|
+
return body?.result?.trace_id || body?.error?.trace_id || body?.trace_id || undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the `fetchJSON(path, init, { timeoutMs, actorPeerId })` a harness talks
|
|
43
|
+
* through. It resolves to `{ ok, status, result, error, traceId }` and never
|
|
44
|
+
* throws: a hook that crashes on a closed port would take its host's turn with
|
|
45
|
+
* it, so a network error is `{ ok: false, status: 0 }` and the retry rules in
|
|
46
|
+
* `retryable.mjs` decide what happens next.
|
|
47
|
+
*
|
|
48
|
+
* `resolveActorPeerId` is a callback because Codex only knows its peer after
|
|
49
|
+
* loading session state under the lock, well after the client is built.
|
|
50
|
+
* `requireJsonBody` is for the capture hooks, which would rather retry a body
|
|
51
|
+
* they cannot parse than record the turn as sent.
|
|
52
|
+
*/
|
|
53
|
+
export function createOvHttp(cfg = {}, {
|
|
54
|
+
defaultTimeoutMs = 0,
|
|
55
|
+
resolveActorPeerId = () => "",
|
|
56
|
+
identityHeaders,
|
|
57
|
+
extraHeaders,
|
|
58
|
+
requireJsonBody = false,
|
|
59
|
+
} = {}) {
|
|
60
|
+
// dsh and pi name this field `endpoint`; every other harness names it `baseUrl`.
|
|
61
|
+
const baseUrl = cfg.baseUrl || cfg.endpoint || "";
|
|
62
|
+
return async function fetchJSON(path, init = {}, options = {}) {
|
|
63
|
+
const timeoutMs = Math.max(MIN_TIMEOUT_MS, Number(options.timeoutMs) || Number(defaultTimeoutMs) || 0);
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
let timedOut = false;
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
timedOut = true;
|
|
68
|
+
controller.abort();
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
try {
|
|
71
|
+
const headers = {
|
|
72
|
+
...buildOvHeaders(cfg, {
|
|
73
|
+
actorPeerId: options.actorPeerId ?? resolveActorPeerId(),
|
|
74
|
+
identityHeaders,
|
|
75
|
+
extraHeaders,
|
|
76
|
+
}),
|
|
77
|
+
...(init.headers || {}),
|
|
78
|
+
};
|
|
79
|
+
const response = await fetch(`${baseUrl}${path}`, { ...init, headers, signal: controller.signal });
|
|
80
|
+
const body = await response.json().catch(() => null);
|
|
81
|
+
if (requireJsonBody && !body) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
status: response.status,
|
|
85
|
+
result: null,
|
|
86
|
+
error: { message: "empty or invalid JSON response" },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const traceId = responseTraceId(body);
|
|
90
|
+
if (!response.ok || body?.status === "error") {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
status: response.status,
|
|
94
|
+
result: null,
|
|
95
|
+
error: body?.error || { message: `HTTP ${response.status}` },
|
|
96
|
+
traceId,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, status: response.status, result: body?.result ?? body ?? {}, traceId };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error?.message || String(error);
|
|
102
|
+
// This module owns the controller, so it says a timeout was a timeout
|
|
103
|
+
// rather than leaving callers to guess it from whatever fetch threw.
|
|
104
|
+
const failure = timedOut
|
|
105
|
+
? { name: "AbortError", aborted: true, message }
|
|
106
|
+
: { message };
|
|
107
|
+
return { ok: false, status: 0, result: null, error: failure };
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
package/shared/pending-queue.mjs
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* When the OpenViking server is temporarily unreachable, write operations
|
|
6
6
|
* (addMessage, commitSession) serialize their payloads to
|
|
7
|
-
* `~/.openviking/pending/` as JSON files.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `~/.openviking/pending/` as JSON files. The queue is replayed in small
|
|
8
|
+
* batches, either at session-start (consuming retry budgets, with
|
|
9
|
+
* maxRetries/TTL) or by a long-running drainer that passes
|
|
10
|
+
* `consumeRetries: false` so transient failures stay retryable.
|
|
10
11
|
*
|
|
11
12
|
* Each file contains: { type, sessionId, payload, createdAt, retries, dedupKey }
|
|
12
13
|
*
|
|
@@ -274,6 +275,23 @@ export async function claimForReplay(filename) {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Release a claimed file back to the queue without consuming a retry. The
|
|
280
|
+
* entry keeps its original filename, retry count, and createdAt position, so a
|
|
281
|
+
* later run retries it as if this attempt never happened.
|
|
282
|
+
*/
|
|
283
|
+
export async function releaseClaim(claimedFilename) {
|
|
284
|
+
if (!claimedFilename.endsWith(".processing")) return null;
|
|
285
|
+
const dir = getPendingDir();
|
|
286
|
+
const restored = pendingFromProcessingFilename(claimedFilename);
|
|
287
|
+
try {
|
|
288
|
+
await rename(join(dir, claimedFilename), join(dir, restored));
|
|
289
|
+
return restored;
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
277
295
|
/**
|
|
278
296
|
* Remove a pending entry after successful replay.
|
|
279
297
|
*/
|
|
@@ -350,9 +368,16 @@ export async function cleanStale() {
|
|
|
350
368
|
*
|
|
351
369
|
* @param {Function} fetchJSON - the configured fetchJSON from makeFetchJSON
|
|
352
370
|
* @param {Function} log - logger function
|
|
371
|
+
* @param {object} [options]
|
|
372
|
+
* @param {boolean} [options.consumeRetries=true] - when false, retryable
|
|
373
|
+
* failures release their claim instead of incrementing the retry count, so
|
|
374
|
+
* a background drainer can keep retrying a transient failure without
|
|
375
|
+
* burning the session-start retry budget. Exhausted and non-retryable
|
|
376
|
+
* entries are deleted exactly as in the default mode.
|
|
353
377
|
* @returns {{ replayed: number, failed: number, skipped: number, deferred: number }}
|
|
354
378
|
*/
|
|
355
|
-
export async function replayPending(fetchJSON, log) {
|
|
379
|
+
export async function replayPending(fetchJSON, log, options = {}) {
|
|
380
|
+
const consumeRetries = options.consumeRetries !== false;
|
|
356
381
|
const pending = await listPending();
|
|
357
382
|
|
|
358
383
|
if (pending.length === 0) {
|
|
@@ -427,7 +452,18 @@ export async function replayPending(fetchJSON, log) {
|
|
|
427
452
|
await dequeue(claimedFilename);
|
|
428
453
|
skipped++;
|
|
429
454
|
} else {
|
|
430
|
-
|
|
455
|
+
if (consumeRetries) {
|
|
456
|
+
await incrementRetry(claimedFilename, entry);
|
|
457
|
+
} else {
|
|
458
|
+
const released = await releaseClaim(claimedFilename);
|
|
459
|
+
log("pending-queue", {
|
|
460
|
+
action: released ? "replay-deferred" : "release-failed",
|
|
461
|
+
sessionId: entry.sessionId,
|
|
462
|
+
type: entry.type,
|
|
463
|
+
status: res?.result?.status || res?.status,
|
|
464
|
+
retries: entry.retries || 0,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
431
467
|
failed++;
|
|
432
468
|
if (entry.type === "addMessage") {
|
|
433
469
|
deferred += Math.max(0, pending.length - processed);
|