@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.
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
- package/src/index.ts +1207 -0
- package/src/oauth.ts +1078 -0
- package/src/server-env.ts +68 -0
- package/src/startup-readiness.ts +238 -0
- package/src/support-passport-runtime.ts +29 -0
|
@@ -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
|
+
}
|