@remnic/server 9.54.5 → 9.54.7

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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @remnic/server — environment-sourced server config.
3
+ *
4
+ * Extracted from index.ts (issue #2029): keeps the env → server-config surface
5
+ * in one place and keeps index.ts under its structural size ceiling. Values are
6
+ * returned as raw strings; `parseServerConfig` coerces/validates them.
7
+ */
8
+
9
+ import type { ServerConfig } from "./index.js";
10
+
11
+ /**
12
+ * Read an env var by its current `REMNIC_` name, falling back to the legacy
13
+ * `ENGRAM_` name.
14
+ */
15
+ export function readCompatEnv(primary: string, legacy: string): string | undefined {
16
+ return process.env[primary] ?? process.env[legacy];
17
+ }
18
+
19
+ /**
20
+ * Collect server-config overrides sourced from environment variables. Merged
21
+ * over file config (file < env < cli) by the server startup path.
22
+ */
23
+ export function envOverrides(): Partial<ServerConfig["server"]> & { remnic?: Record<string, unknown> } {
24
+ const overrides: Record<string, unknown> = {};
25
+ const remnic: Record<string, unknown> = {};
26
+
27
+ const port = readCompatEnv("REMNIC_PORT", "ENGRAM_PORT");
28
+ const host = readCompatEnv("REMNIC_HOST", "ENGRAM_HOST");
29
+ const authToken = readCompatEnv("REMNIC_AUTH_TOKEN", "ENGRAM_AUTH_TOKEN");
30
+ const adminConsoleEnabled = readCompatEnv("REMNIC_ADMIN_CONSOLE_ENABLED", "ENGRAM_ADMIN_CONSOLE_ENABLED");
31
+ const adminConsolePublicDir = readCompatEnv("REMNIC_ADMIN_CONSOLE_PUBLIC_DIR", "ENGRAM_ADMIN_CONSOLE_PUBLIC_DIR");
32
+ const adminConsolePrefillToken = readCompatEnv("REMNIC_ADMIN_CONSOLE_PREFILL_TOKEN", "ENGRAM_ADMIN_CONSOLE_PREFILL_TOKEN");
33
+ const readinessOverride = process.env.REMNIC_READY_OVERRIDE;
34
+ const readinessDegradedAfterAttempts = readCompatEnv(
35
+ "REMNIC_READY_DEGRADED_AFTER_ATTEMPTS",
36
+ "ENGRAM_READY_DEGRADED_AFTER_ATTEMPTS",
37
+ );
38
+ // issue #2029: size the global write rate limit from env. The standalone
39
+ // server already honors `server.writeRateLimit*` in config; this makes it
40
+ // settable via the launchd/systemd environment without editing config.json.
41
+ const writeRateLimitMaxRequests = readCompatEnv(
42
+ "REMNIC_WRITE_RATE_LIMIT_MAX_REQUESTS",
43
+ "ENGRAM_WRITE_RATE_LIMIT_MAX_REQUESTS",
44
+ );
45
+ const writeRateLimitWindowMs = readCompatEnv(
46
+ "REMNIC_WRITE_RATE_LIMIT_WINDOW_MS",
47
+ "ENGRAM_WRITE_RATE_LIMIT_WINDOW_MS",
48
+ );
49
+ if (port) overrides.port = port;
50
+ if (host) overrides.host = host;
51
+ if (authToken) overrides.authToken = authToken;
52
+ if (adminConsoleEnabled) overrides.adminConsoleEnabled = adminConsoleEnabled;
53
+ if (adminConsolePublicDir) overrides.adminConsolePublicDir = adminConsolePublicDir;
54
+ if (adminConsolePrefillToken) overrides.adminConsolePrefillToken = adminConsolePrefillToken;
55
+ if (readinessOverride !== undefined) overrides.readinessOverride = readinessOverride;
56
+ if (readinessDegradedAfterAttempts !== undefined) overrides.readinessDegradedAfterAttempts = readinessDegradedAfterAttempts;
57
+ // Use `!== undefined` (not truthiness) so an explicitly-set empty/invalid
58
+ // value still reaches parseServerConfig and is rejected, rather than being
59
+ // silently dropped so file config wins (issue #2029 review).
60
+ if (writeRateLimitMaxRequests !== undefined) overrides.writeRateLimitMaxRequests = writeRateLimitMaxRequests;
61
+ if (writeRateLimitWindowMs !== undefined) overrides.writeRateLimitWindowMs = writeRateLimitWindowMs;
62
+
63
+ if (process.env.OPENAI_API_KEY) remnic.openaiApiKey = process.env.OPENAI_API_KEY;
64
+ const memoryDir = readCompatEnv("REMNIC_MEMORY_DIR", "ENGRAM_MEMORY_DIR");
65
+ if (memoryDir) remnic.memoryDir = memoryDir;
66
+
67
+ return { ...overrides, ...(Object.keys(remnic).length > 0 ? { remnic } : {}) };
68
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * @remnic/server — standalone startup readiness gate.
3
+ *
4
+ * Extracted from index.ts (issue #2215): owns the search warm-up loop that
5
+ * drives `/engram/v1/health` readiness, including the degraded-mode transition
6
+ * that keeps a functional daemon from reporting itself offline forever when
7
+ * warm-up cannot complete. Keeps index.ts under its structural size ceiling.
8
+ */
9
+
10
+ import { log } from "@remnic/core";
11
+
12
+ interface PromiseResolvers<T> {
13
+ promise: Promise<T>;
14
+ resolve: (value: T | PromiseLike<T>) => void;
15
+ reject: (reason?: unknown) => void;
16
+ }
17
+
18
+ type PromiseConstructorWithResolvers = PromiseConstructor & {
19
+ withResolvers<T>(): PromiseResolvers<T>;
20
+ };
21
+
22
+ /**
23
+ * Like `setTimeout` wrapped in a Promise, but respects an `AbortSignal`.
24
+ * Resolves immediately (without throwing) when the signal fires so the
25
+ * caller can check `signal.aborted` and exit cleanly.
26
+ */
27
+ export function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
28
+ if (signal.aborted) return Promise.resolve();
29
+ const { promise, resolve } = (Promise as PromiseConstructorWithResolvers).withResolvers<void>();
30
+ const timer = setTimeout(resolve, ms);
31
+ const onAbort = () => {
32
+ clearTimeout(timer);
33
+ resolve();
34
+ };
35
+ signal.addEventListener("abort", onAbort, { once: true });
36
+ return promise.finally(() => signal.removeEventListener("abort", onAbort));
37
+ }
38
+
39
+ const STARTUP_WARMUP_TIMEOUT_MS = 20_000;
40
+ const STARTUP_WARMUP_RETRY_INTERVAL_MS = 30_000;
41
+ /**
42
+ * Failed warm-up attempts before the init gate opens in degraded mode
43
+ * (issue #2215). The daemon keeps serving recall via fallback retrieval while
44
+ * search warm-up cannot complete (e.g. `qmd` missing from the service PATH),
45
+ * so a permanently-closed gate reports a working service as offline. After
46
+ * this many failed attempts the gate opens with `degraded: true` and warm-up
47
+ * retries continue in the background until they succeed.
48
+ */
49
+ export const STARTUP_DEGRADED_AFTER_ATTEMPTS = 3;
50
+
51
+ export interface StartupReadinessState {
52
+ ready: boolean;
53
+ warmupAttempts: number;
54
+ lastError?: string | null;
55
+ /** True when the gate opened before search warm-up completed (issue #2215). */
56
+ degraded?: boolean;
57
+ }
58
+
59
+ export type StartupReadinessOutcome = "warmed" | "cancelled" | "overridden" | "search-disabled";
60
+
61
+ class StartupWarmupDegradationError extends Error {
62
+ constructor(code: string) {
63
+ super(`startup search degraded: ${code}`);
64
+ this.name = "StartupWarmupDegradationError";
65
+ }
66
+ }
67
+
68
+ class StartupSyncPendingError extends Error {
69
+ constructor() {
70
+ super("startup search sync is not complete");
71
+ this.name = "StartupSyncPendingError";
72
+ }
73
+ }
74
+
75
+ export async function runStartupSearchWarmup(options: {
76
+ signal: AbortSignal;
77
+ isAvailable: () => boolean;
78
+ search: (onDegradation: (code: string) => void) => Promise<unknown>;
79
+ }): Promise<void> {
80
+ let degradationCode: string | undefined;
81
+ await options.search((code) => {
82
+ degradationCode = code;
83
+ });
84
+ if (options.signal.aborted) return;
85
+ if (degradationCode) throw new StartupWarmupDegradationError(degradationCode);
86
+ if (!options.isAvailable()) {
87
+ throw new StartupWarmupDegradationError("backend_unavailable");
88
+ }
89
+ }
90
+
91
+ export async function completeStartupReadiness(options: {
92
+ deferredReady: Promise<void>;
93
+ warmup: (signal: AbortSignal) => Promise<unknown>;
94
+ prepareWarmup?: (signal: AbortSignal) => Promise<boolean>;
95
+ state: StartupReadinessState;
96
+ timeoutMs?: number;
97
+ retryIntervalMs?: number;
98
+ /** Failed attempts before the gate opens degraded; 0 disables (strict gate). */
99
+ degradedAfterAttempts?: number;
100
+ override?: boolean;
101
+ skipWarmup?: () => boolean;
102
+ openGate: () => void;
103
+ shutdownSignal?: AbortSignal;
104
+ warn?: (message: string) => void;
105
+ info?: (message: string) => void;
106
+ error?: (message: string) => void;
107
+ }): Promise<StartupReadinessOutcome> {
108
+ const timeoutMs = options.timeoutMs ?? STARTUP_WARMUP_TIMEOUT_MS;
109
+ const retryIntervalMs = options.retryIntervalMs ?? STARTUP_WARMUP_RETRY_INTERVAL_MS;
110
+ const degradedAfterAttempts = options.degradedAfterAttempts ?? STARTUP_DEGRADED_AFTER_ATTEMPTS;
111
+ const warn = options.warn ?? ((message: string) => log.warn(message));
112
+ const info = options.info ?? ((message: string) => log.info(message));
113
+ const error = options.error ?? ((message: string) => log.error(message));
114
+
115
+ options.state.ready = false;
116
+ options.state.lastError = null;
117
+ options.state.degraded = false;
118
+ if (options.override) {
119
+ options.openGate();
120
+ options.state.ready = true;
121
+ error(
122
+ "CRITICAL: emergency readiness override enabled; exposing a cold search backend to traffic",
123
+ );
124
+ return "overridden";
125
+ }
126
+ if (options.skipWarmup?.()) {
127
+ options.openGate();
128
+ options.state.ready = true;
129
+ info("Standalone init gate opened without search warm-up (search intentionally disabled)");
130
+ return "search-disabled";
131
+ }
132
+
133
+ let removeDeferredShutdownListener: () => void = () => undefined;
134
+ const deferredShutdown = new Promise<"shutdown">((resolve) => {
135
+ if (options.shutdownSignal?.aborted) {
136
+ resolve("shutdown");
137
+ return;
138
+ }
139
+ const onDeferredShutdown = () => resolve("shutdown");
140
+ options.shutdownSignal?.addEventListener("abort", onDeferredShutdown, { once: true });
141
+ removeDeferredShutdownListener = () =>
142
+ options.shutdownSignal?.removeEventListener("abort", onDeferredShutdown);
143
+ });
144
+ try {
145
+ const deferredOutcome = await Promise.race([
146
+ options.deferredReady.then(() => "ready" as const),
147
+ deferredShutdown,
148
+ ]);
149
+ if (deferredOutcome === "shutdown") return "cancelled";
150
+ } catch (err) {
151
+ if (options.shutdownSignal?.aborted) return "cancelled";
152
+ options.state.lastError = err instanceof Error ? err.name : typeof err;
153
+ warn(`Standalone deferred initialization failed; warm-up retries will continue: ${err}`);
154
+ } finally {
155
+ removeDeferredShutdownListener();
156
+ }
157
+ if (options.shutdownSignal?.aborted) return "cancelled";
158
+
159
+ const lifecycleAbort = new AbortController();
160
+ const onShutdown = () => lifecycleAbort.abort(options.shutdownSignal?.reason);
161
+ options.shutdownSignal?.addEventListener("abort", onShutdown, { once: true });
162
+
163
+ try {
164
+ while (!lifecycleAbort.signal.aborted) {
165
+ if (options.skipWarmup?.()) {
166
+ options.state.degraded = false;
167
+ options.openGate();
168
+ options.state.ready = true;
169
+ info("Standalone init gate opened without search warm-up (search intentionally disabled)");
170
+ return "search-disabled";
171
+ }
172
+ options.state.warmupAttempts += 1;
173
+ const warmupAbort = new AbortController();
174
+ const onLifecycleAbort = () => warmupAbort.abort(lifecycleAbort.signal.reason);
175
+ lifecycleAbort.signal.addEventListener("abort", onLifecycleAbort, { once: true });
176
+ const timeout = (Promise as PromiseConstructorWithResolvers).withResolvers<never>();
177
+ let timedOut = false;
178
+ const timer = setTimeout(() => {
179
+ timedOut = true;
180
+ warmupAbort.abort();
181
+ timeout.reject(new Error(`startup warm-up timed out after ${timeoutMs}ms`));
182
+ }, timeoutMs);
183
+ timer.unref();
184
+
185
+ try {
186
+ const attempt = async () => {
187
+ if (options.prepareWarmup && !await options.prepareWarmup(warmupAbort.signal)) {
188
+ throw new StartupSyncPendingError();
189
+ }
190
+ return options.warmup(warmupAbort.signal);
191
+ };
192
+ await Promise.race([attempt(), timeout.promise]);
193
+ if (lifecycleAbort.signal.aborted) return "cancelled";
194
+ const recovered = options.state.degraded === true;
195
+ options.state.lastError = null;
196
+ options.state.degraded = false;
197
+ options.openGate();
198
+ options.state.ready = true;
199
+ info(
200
+ `Standalone init gate opened after search warm-up attempt ${options.state.warmupAttempts}${recovered ? " (recovered from degraded mode)" : ""}`,
201
+ );
202
+ return "warmed";
203
+ } catch (err) {
204
+ if (lifecycleAbort.signal.aborted) return "cancelled";
205
+ options.state.lastError = timedOut
206
+ ? "TimeoutError"
207
+ : err instanceof Error
208
+ ? err.name
209
+ : typeof err;
210
+ warn(
211
+ timedOut
212
+ ? `Standalone startup warm-up attempt ${options.state.warmupAttempts} timed out after ${timeoutMs}ms; retrying in ${retryIntervalMs}ms`
213
+ : `Standalone startup warm-up attempt ${options.state.warmupAttempts} failed (${options.state.lastError}); retrying in ${retryIntervalMs}ms`,
214
+ );
215
+ if (
216
+ degradedAfterAttempts > 0 &&
217
+ !options.state.ready &&
218
+ options.state.warmupAttempts >= degradedAfterAttempts
219
+ ) {
220
+ options.state.degraded = true;
221
+ options.openGate();
222
+ options.state.ready = true;
223
+ warn(
224
+ `Standalone init gate opened in DEGRADED mode after ${options.state.warmupAttempts} failed search warm-up attempts (${options.state.lastError}); recall keeps serving via fallback retrieval and warm-up retries continue in the background`,
225
+ );
226
+ }
227
+ } finally {
228
+ clearTimeout(timer);
229
+ lifecycleAbort.signal.removeEventListener("abort", onLifecycleAbort);
230
+ }
231
+
232
+ await abortableDelay(retryIntervalMs, lifecycleAbort.signal);
233
+ }
234
+ return "cancelled";
235
+ } finally {
236
+ options.shutdownSignal?.removeEventListener("abort", onShutdown);
237
+ }
238
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ EngramAccessService,
3
+ type Orchestrator,
4
+ type PluginConfig,
5
+ type SupportPassportExternalRequestHandler,
6
+ SupportPassportModelBridge,
7
+ composeSupportPassportExternalRequestHandlers,
8
+ } from "@remnic/core";
9
+
10
+ export interface SupportPassportServerRuntime {
11
+ service: EngramAccessService;
12
+ externalRequestHandler: SupportPassportExternalRequestHandler;
13
+ close(): void;
14
+ }
15
+
16
+ export function createSupportPassportServerRuntime(
17
+ orchestrator: Orchestrator,
18
+ config: PluginConfig,
19
+ fallbackHandler?: SupportPassportExternalRequestHandler
20
+ ): SupportPassportServerRuntime {
21
+ const bridge = config.supportPassport.enabled ? new SupportPassportModelBridge() : null;
22
+ return {
23
+ service: new EngramAccessService(orchestrator, {
24
+ supportPassportGatewayRoute: bridge?.route,
25
+ }),
26
+ externalRequestHandler: composeSupportPassportExternalRequestHandlers(bridge?.requestHandler, fallbackHandler),
27
+ close: () => bridge?.close(),
28
+ };
29
+ }