om-memory-system 3.6.0-next.39 → 3.6.0-next.42
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/adapters/claude-code/hook-client.d.ts +4 -12
- package/dist/adapters/claude-code/hook-client.js +21 -52
- package/dist/adapters/pi/extension.js +18 -0
- package/dist/cli/web-command.js +12 -0
- package/dist/cli/web-power.d.ts +40 -0
- package/dist/cli/web-power.js +61 -0
- package/dist/core/memory-operations.js +2 -0
- package/dist/importer/claude-hook-api.js +42 -3
- package/dist/importer/claude-reader.d.ts +1 -0
- package/dist/importer/claude-reader.js +3 -1
- package/dist/importer/import-readiness.d.ts +1 -1
- package/dist/importer/import-readiness.js +6 -1
- package/dist/importer/settings-health.js +24 -15
- package/dist/index.js +32 -99
- package/dist/services/api-handlers.js +1 -1
- package/dist/services/client.d.ts +1 -0
- package/dist/services/client.js +1 -0
- package/dist/services/turso/types.d.ts +1 -0
- package/dist/services/turso/vector-search.js +3 -2
- package/dist/services/web-autostart.d.ts +6 -0
- package/dist/services/web-autostart.js +31 -0
- package/dist/services/web-ensure.d.ts +59 -0
- package/dist/services/web-ensure.js +214 -0
- package/dist/services/web-server.d.ts +6 -0
- package/dist/services/web-server.js +58 -0
- package/dist/web/assets/index-Ctzj_Zg0.js +124 -0
- package/dist/web/assets/index-DG7RjW5B.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BFiVixOK.js +0 -124
- package/dist/web/assets/index-D5MY5Kjs.css +0 -2
package/dist/index.js
CHANGED
|
@@ -9,11 +9,9 @@ import { OPENCODE_IMPORT_COMMAND, OPENCODE_IMPORT_DESCRIPTION, latestUserMessage
|
|
|
9
9
|
import { buildRecentMemoriesSection, formatMemoriesForCompaction } from "./core/retrieval.js";
|
|
10
10
|
import { performUserProfileLearning } from "./adapters/opencode/profile-learning.js";
|
|
11
11
|
import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
|
|
12
|
-
import { startWebServer, WebServer } from "./services/web-server.js";
|
|
13
12
|
import { pruneTraces } from "./services/capture-diagnostics.js";
|
|
14
13
|
import { ensureTursoReady } from "./services/turso/ready.js";
|
|
15
14
|
import { tursoConnectionManager } from "./services/turso/connection-manager.js";
|
|
16
|
-
import { WebAuth } from "./services/web-auth.js";
|
|
17
15
|
import { isConfigured, CONFIG, initConfigWithLegacyMigration } from "./config.js";
|
|
18
16
|
import { resolveOpencodeHostModel } from "./services/ai/live-model-choice.js";
|
|
19
17
|
import { log } from "./services/logger.js";
|
|
@@ -193,7 +191,6 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
193
191
|
logAutoCaptureProviderStatus();
|
|
194
192
|
const tags = getTags(directory);
|
|
195
193
|
const autoCaptureHost = createOpenCodeAutoCaptureHost(ctx);
|
|
196
|
-
let webServer = null;
|
|
197
194
|
let idleTimeout = null;
|
|
198
195
|
const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("omms.plugin.warmedup");
|
|
199
196
|
if (!globalThis[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) {
|
|
@@ -211,8 +208,8 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
211
208
|
await configureOpencodeHostTransport(ctx);
|
|
212
209
|
// Shared profile dedup, conflict, description, and cleanup calls use OpenCode's host model.
|
|
213
210
|
registerOpencodeProfileModel();
|
|
214
|
-
//
|
|
215
|
-
// Awaited so
|
|
211
|
+
// In-process backfill, capture, and the capture retry drain use OpenCode's models.
|
|
212
|
+
// Awaited so those paths never run before the registry is filled.
|
|
216
213
|
await import("./adapters/opencode/backfill-startup.js")
|
|
217
214
|
.then(({ registerOpencodeImportModels, registerOpencodeCaptureRetryDrain }) => {
|
|
218
215
|
registerOpencodeImportModels();
|
|
@@ -290,96 +287,36 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
290
287
|
}
|
|
291
288
|
}
|
|
292
289
|
}
|
|
293
|
-
if (CONFIG.webServerEnabled &&
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
body: {
|
|
314
|
-
title: "Memory Explorer",
|
|
315
|
-
message: "Took over web server ownership",
|
|
316
|
-
variant: "success",
|
|
317
|
-
duration: 3000,
|
|
318
|
-
},
|
|
319
|
-
})
|
|
320
|
-
.catch(() => { });
|
|
321
|
-
}
|
|
322
|
-
});
|
|
323
|
-
webServer.setOnPortsExhaustedCallback(() => {
|
|
324
|
-
if (ctx.client?.tui) {
|
|
325
|
-
ctx.client.tui
|
|
326
|
-
.showToast({
|
|
327
|
-
body: {
|
|
328
|
-
title: "Memory Explorer",
|
|
329
|
-
message: `Web UI unavailable: ports ${CONFIG.webServerPort}-${CONFIG.webServerPort + 10} are held by non-responsive processes`,
|
|
330
|
-
variant: "error",
|
|
331
|
-
duration: 5000,
|
|
332
|
-
},
|
|
333
|
-
})
|
|
334
|
-
.catch(() => { });
|
|
290
|
+
if (CONFIG.webServerEnabled &&
|
|
291
|
+
tursoReadyForWeb &&
|
|
292
|
+
process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
|
|
293
|
+
const showToast = (message, variant, duration) => {
|
|
294
|
+
if (!ctx.client?.tui)
|
|
295
|
+
return;
|
|
296
|
+
ctx.client.tui
|
|
297
|
+
.showToast({ body: { title: "Memory Explorer", message, variant, duration } })
|
|
298
|
+
.catch(() => { });
|
|
299
|
+
};
|
|
300
|
+
// The shared web app serves the page. This plugin starts it only when none runs.
|
|
301
|
+
void Promise.all([import("./services/web-ensure.js"), import("./services/web-api-auth.js")])
|
|
302
|
+
.then(([{ ensureWebApp }, { webServerUrl }]) => {
|
|
303
|
+
const baseUrl = webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort);
|
|
304
|
+
return ensureWebApp({
|
|
305
|
+
settings: { enabled: CONFIG.webServerEnabled, baseUrl },
|
|
306
|
+
budgetMs: 10_000,
|
|
307
|
+
}).then((result) => {
|
|
308
|
+
if (result === "running" || result === "started") {
|
|
309
|
+
showToast(`Web UI available at ${baseUrl}`, "info", 3000);
|
|
335
310
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (ctx.client?.tui) {
|
|
339
|
-
ctx.client.tui
|
|
340
|
-
.showToast({
|
|
341
|
-
body: {
|
|
342
|
-
title: "Memory Explorer",
|
|
343
|
-
message: webAuth.isEnabled()
|
|
344
|
-
? `Web UI started at ${url} (auth required)`
|
|
345
|
-
: `Web UI started at ${url}`,
|
|
346
|
-
variant: "success",
|
|
347
|
-
duration: 5000,
|
|
348
|
-
},
|
|
349
|
-
})
|
|
350
|
-
.catch(() => { });
|
|
311
|
+
else if (result === "port-busy") {
|
|
312
|
+
showToast(`Web UI unavailable: another program uses port ${CONFIG.webServerPort}`, "error", 5000);
|
|
351
313
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (ctx.client?.tui) {
|
|
355
|
-
ctx.client.tui
|
|
356
|
-
.showToast({
|
|
357
|
-
body: {
|
|
358
|
-
title: "Memory Explorer",
|
|
359
|
-
message: `Web UI available at ${url}`,
|
|
360
|
-
variant: "info",
|
|
361
|
-
duration: 3000,
|
|
362
|
-
},
|
|
363
|
-
})
|
|
364
|
-
.catch(() => { });
|
|
314
|
+
else if (result === "no-runtime") {
|
|
315
|
+
showToast("Web UI unavailable: no Node or Bun runtime found to start it", "error", 5000);
|
|
365
316
|
}
|
|
366
|
-
}
|
|
317
|
+
});
|
|
367
318
|
})
|
|
368
|
-
.catch((error) => {
|
|
369
|
-
log("Web server failed to start", { error: String(error) });
|
|
370
|
-
if (ctx.client?.tui) {
|
|
371
|
-
ctx.client.tui
|
|
372
|
-
.showToast({
|
|
373
|
-
body: {
|
|
374
|
-
title: "Memory Explorer Error",
|
|
375
|
-
message: `Failed to start: ${String(error)}`,
|
|
376
|
-
variant: "error",
|
|
377
|
-
duration: 5000,
|
|
378
|
-
},
|
|
379
|
-
})
|
|
380
|
-
.catch(() => { });
|
|
381
|
-
}
|
|
382
|
-
});
|
|
319
|
+
.catch((error) => log("Web app start failed", { error: String(error) }));
|
|
383
320
|
}
|
|
384
321
|
let cleanedUp = false;
|
|
385
322
|
const cleanupPlugin = async () => {
|
|
@@ -390,8 +327,6 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
390
327
|
clearTimeout(idleTimeout);
|
|
391
328
|
idleTimeout = null;
|
|
392
329
|
}
|
|
393
|
-
if (webServer)
|
|
394
|
-
await webServer.stop();
|
|
395
330
|
if (memoryClient)
|
|
396
331
|
await memoryClient.close();
|
|
397
332
|
};
|
|
@@ -607,12 +542,10 @@ export const OmmsPlugin = async (ctx) => {
|
|
|
607
542
|
idleTimeout = setTimeout(async () => {
|
|
608
543
|
try {
|
|
609
544
|
await performAutoCapture(autoCaptureHost, sessionID, directory);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
await cleanupService.runCleanup();
|
|
615
|
-
}
|
|
545
|
+
await performUserProfileLearning(ctx, directory);
|
|
546
|
+
const { cleanupService } = await import("./services/cleanup-service.js");
|
|
547
|
+
if (await cleanupService.shouldRunCleanup())
|
|
548
|
+
await cleanupService.runCleanup();
|
|
616
549
|
}
|
|
617
550
|
catch (error) {
|
|
618
551
|
log("Idle processing error", { error: String(error) });
|
|
@@ -496,7 +496,7 @@ export async function handleSearch(query, tag, page = 1, pageSize = 20) {
|
|
|
496
496
|
type: "memory",
|
|
497
497
|
id: r.id,
|
|
498
498
|
content: r.memory,
|
|
499
|
-
memoryType: r.metadata?.type,
|
|
499
|
+
memoryType: r.type ?? r.metadata?.type,
|
|
500
500
|
tags: r.tags,
|
|
501
501
|
createdAt: safeToISOString(r.createdAt ?? r.metadata?.createdAt),
|
|
502
502
|
updatedAt: r.metadata?.updatedAt ? safeToISOString(r.metadata.updatedAt) : undefined,
|
package/dist/services/client.js
CHANGED
|
@@ -204,6 +204,7 @@ export class LocalMemoryClient {
|
|
|
204
204
|
const memories = allMemories.slice(0, limit).map((r) => ({
|
|
205
205
|
id: r.id,
|
|
206
206
|
summary: r.content,
|
|
207
|
+
type: r.type ? String(r.type) : undefined,
|
|
207
208
|
createdAt: safeToISOString(r.created_at),
|
|
208
209
|
metadata: safeJSONParse(r.metadata),
|
|
209
210
|
displayName: r.display_name,
|
|
@@ -109,7 +109,7 @@ export class TursoVectorSearch {
|
|
|
109
109
|
// hits still get a full hybrid score (missing ANN side is not forced to 0).
|
|
110
110
|
const rows = await db.all(containerTag === ""
|
|
111
111
|
? `
|
|
112
|
-
SELECT id, content, tags, created_at, metadata, container_tag,
|
|
112
|
+
SELECT id, content, tags, type, created_at, metadata, container_tag,
|
|
113
113
|
display_name, user_name, user_email, project_path, project_name,
|
|
114
114
|
git_repo_url, is_pinned,
|
|
115
115
|
vector_distance_cos(vector, vector32(?)) AS content_dist,
|
|
@@ -120,7 +120,7 @@ export class TursoVectorSearch {
|
|
|
120
120
|
WHERE id IN (${placeholders})
|
|
121
121
|
`
|
|
122
122
|
: `
|
|
123
|
-
SELECT id, content, tags, created_at, metadata, container_tag,
|
|
123
|
+
SELECT id, content, tags, type, created_at, metadata, container_tag,
|
|
124
124
|
display_name, user_name, user_email, project_path, project_name,
|
|
125
125
|
git_repo_url, is_pinned,
|
|
126
126
|
vector_distance_cos(vector, vector32(?)) AS content_dist,
|
|
@@ -158,6 +158,7 @@ export class TursoVectorSearch {
|
|
|
158
158
|
similarity,
|
|
159
159
|
createdAt: Number(row.created_at),
|
|
160
160
|
tags: memoryTagsStr ? memoryTagsStr.split(",") : [],
|
|
161
|
+
type: row.type ? String(row.type) : undefined,
|
|
161
162
|
metadata: parseMetadata(row.metadata),
|
|
162
163
|
containerTag: String(row.container_tag),
|
|
163
164
|
displayName: row.display_name ? String(row.display_name) : undefined,
|
|
@@ -24,6 +24,12 @@ export declare function webAutostartStatus(options?: WebAutostartOptions): WebAu
|
|
|
24
24
|
export declare function installWebAutostart(options?: WebAutostartOptions): WebAutostartStatus;
|
|
25
25
|
/** Remove only an item carrying the OMMS marker. */
|
|
26
26
|
export declare function removeWebAutostart(options?: WebAutostartOptions): WebAutostartStatus;
|
|
27
|
+
/**
|
|
28
|
+
* Restart the login item through the platform service manager. A detached child
|
|
29
|
+
* of the item would die with its launchd job or systemd control group, so the
|
|
30
|
+
* service manager does the restart. Returns false when it cannot.
|
|
31
|
+
*/
|
|
32
|
+
export declare function restartWebAutostart(options?: WebAutostartOptions): boolean;
|
|
27
33
|
/** Host start checks the global settings. A failed item cannot block a session. */
|
|
28
34
|
export declare function reconcileWebAutostart(config?: Pick<typeof CONFIG, "webServerAutoStart" | "webServerEnabled">, options?: WebAutostartOptions): void;
|
|
29
35
|
export {};
|
|
@@ -211,6 +211,37 @@ export function removeWebAutostart(options = {}) {
|
|
|
211
211
|
unlinkSync(path);
|
|
212
212
|
return { state: "not-installed", path };
|
|
213
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Restart the login item through the platform service manager. A detached child
|
|
216
|
+
* of the item would die with its launchd job or systemd control group, so the
|
|
217
|
+
* service manager does the restart. Returns false when it cannot.
|
|
218
|
+
*/
|
|
219
|
+
export function restartWebAutostart(options = {}) {
|
|
220
|
+
const home = options.home ?? homedir();
|
|
221
|
+
const platform = options.platform ?? process.platform;
|
|
222
|
+
try {
|
|
223
|
+
if (platform === "darwin") {
|
|
224
|
+
runCommand(options, home, "launchctl", [
|
|
225
|
+
"kickstart",
|
|
226
|
+
"-k",
|
|
227
|
+
`gui/${process.getuid?.() ?? 0}/${NAME}`,
|
|
228
|
+
]);
|
|
229
|
+
}
|
|
230
|
+
else if (platform === "linux") {
|
|
231
|
+
runCommand(options, home, "systemctl", ["--user", "restart", "omms-web.service"]);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
log("OMMS login item restart failed", {
|
|
240
|
+
code: error instanceof Error ? error.name : "unknown",
|
|
241
|
+
});
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
214
245
|
/** Host start checks the global settings. A failed item cannot block a session. */
|
|
215
246
|
export function reconcileWebAutostart(config = CONFIG, options = {}) {
|
|
216
247
|
if (process.env.OMMS_DISABLE_WEB_AUTOSTART === "1")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type EnsureResult = "running" | "started" | "disabled" | "port-busy" | "no-runtime" | "start-timeout";
|
|
2
|
+
/** Port and switch of the web app, as the caller read them from `CONFIG`. */
|
|
3
|
+
export interface EnsureSettings {
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
}
|
|
7
|
+
interface SpawnedChild {
|
|
8
|
+
unref(): void;
|
|
9
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
10
|
+
}
|
|
11
|
+
type SpawnFn = (command: string, args: string[], options: {
|
|
12
|
+
detached: true;
|
|
13
|
+
stdio: "ignore";
|
|
14
|
+
cwd: string;
|
|
15
|
+
windowsHide: true;
|
|
16
|
+
}) => SpawnedChild;
|
|
17
|
+
/** The three file operations the start lock needs. */
|
|
18
|
+
export interface LockFs {
|
|
19
|
+
/** Create the file only when it does not exist. Returns false when it does. */
|
|
20
|
+
createExclusive(path: string, text: string): boolean;
|
|
21
|
+
read(path: string): string | null;
|
|
22
|
+
remove(path: string): void;
|
|
23
|
+
}
|
|
24
|
+
export interface EnsureDeps {
|
|
25
|
+
fetch: typeof fetch;
|
|
26
|
+
spawn: SpawnFn;
|
|
27
|
+
sleep: (ms: number) => Promise<void>;
|
|
28
|
+
now: () => number;
|
|
29
|
+
resolveRuntime: () => Promise<string | null> | string | null;
|
|
30
|
+
/** The `om-memory-system` script that `web` is run from. */
|
|
31
|
+
cliScript: string;
|
|
32
|
+
lockPath: string;
|
|
33
|
+
lockFs: LockFs;
|
|
34
|
+
pidAlive: (pid: number) => boolean;
|
|
35
|
+
/** The pid written into the lock. */
|
|
36
|
+
pid: number;
|
|
37
|
+
log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
export interface EnsureOptions {
|
|
40
|
+
settings: EnsureSettings;
|
|
41
|
+
/** How long the caller may wait for the web app to answer. */
|
|
42
|
+
budgetMs: number;
|
|
43
|
+
/** `false` starts the web app and returns at once (session starts). Default `true`. */
|
|
44
|
+
wait?: boolean;
|
|
45
|
+
deps?: Partial<EnsureDeps>;
|
|
46
|
+
}
|
|
47
|
+
/** A lock older than this is stale. Also the longest a background start holds it. */
|
|
48
|
+
export declare const START_LOCK_STALE_MS = 20000;
|
|
49
|
+
/** Path of the start lock; the standalone command writes it for a restart copy. */
|
|
50
|
+
export declare function startLockPath(home?: string): string;
|
|
51
|
+
export declare const nodeLockFs: LockFs;
|
|
52
|
+
/** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
|
|
53
|
+
export declare function removeStartLockFor(pid: number, path?: string, lockFs?: LockFs): void;
|
|
54
|
+
/**
|
|
55
|
+
* Use a running OMMS web app, or start one detached `om-memory-system web`.
|
|
56
|
+
* Never throws; a failed check must not block or fail the host session.
|
|
57
|
+
*/
|
|
58
|
+
export declare function ensureWebApp(options: EnsureOptions): Promise<EnsureResult>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const HEALTH_PROBE_MS = 1_000;
|
|
7
|
+
const POLL_INTERVAL_MS = 250;
|
|
8
|
+
/** A lock older than this is stale. Also the longest a background start holds it. */
|
|
9
|
+
export const START_LOCK_STALE_MS = 20_000;
|
|
10
|
+
/** Path of the start lock; the standalone command writes it for a restart copy. */
|
|
11
|
+
export function startLockPath(home = homedir()) {
|
|
12
|
+
return join(home, ".omms", "web-start.lock");
|
|
13
|
+
}
|
|
14
|
+
export const nodeLockFs = {
|
|
15
|
+
createExclusive(path, text) {
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(path, text, { flag: "wx", mode: 0o600 });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === "EEXIST")
|
|
23
|
+
return false;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
read(path) {
|
|
28
|
+
try {
|
|
29
|
+
return readFileSync(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
remove(path) {
|
|
36
|
+
rmSync(path, { force: true });
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
/** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
|
|
40
|
+
export function removeStartLockFor(pid, path = startLockPath(), lockFs = nodeLockFs) {
|
|
41
|
+
try {
|
|
42
|
+
const held = JSON.parse(lockFs.read(path) ?? "null");
|
|
43
|
+
if (held?.pid === pid)
|
|
44
|
+
lockFs.remove(path);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* An unreadable lock is stale and the next caller replaces it. */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function processAlive(pid) {
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// EPERM means the process exists but belongs to someone else.
|
|
57
|
+
return error.code === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function productionDefaults() {
|
|
61
|
+
return {
|
|
62
|
+
fetch: globalThis.fetch,
|
|
63
|
+
spawn: nodeSpawn,
|
|
64
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
65
|
+
now: () => Date.now(),
|
|
66
|
+
resolveRuntime: async () => (await import("./web-autostart.js")).resolveWebRuntime(),
|
|
67
|
+
// From dist/services/ to the CLI entry point.
|
|
68
|
+
cliScript: fileURLToPath(new URL("../cli/index.js", import.meta.url)),
|
|
69
|
+
lockPath: startLockPath(),
|
|
70
|
+
lockFs: nodeLockFs,
|
|
71
|
+
pidAlive: processAlive,
|
|
72
|
+
pid: process.pid,
|
|
73
|
+
log: async (message, data) => (await import("./logger.js")).log(message, data),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** An OMMS reply is `{ success: true, status: "ok" }`; another local service must not count. */
|
|
77
|
+
async function probe(baseUrl, timeoutMs, deps) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await deps.fetch(`${baseUrl}/api/health`, {
|
|
80
|
+
method: "GET",
|
|
81
|
+
signal: AbortSignal.timeout(Math.max(1, timeoutMs)),
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return "other";
|
|
85
|
+
const body = (await response.json());
|
|
86
|
+
return body.success === true && body.status === "ok" ? "omms" : "other";
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
// A reply that is not JSON came from a program that answered; a refused or
|
|
90
|
+
// timed out connection means nothing answers.
|
|
91
|
+
return error instanceof SyntaxError ? "other" : "none";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function lockIsStale(text, deps) {
|
|
95
|
+
if (text === null)
|
|
96
|
+
return true;
|
|
97
|
+
try {
|
|
98
|
+
const { pid, at } = JSON.parse(text);
|
|
99
|
+
if (typeof pid !== "number" || typeof at !== "number")
|
|
100
|
+
return true;
|
|
101
|
+
return !deps.pidAlive(pid) || deps.now() - at > START_LOCK_STALE_MS;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Take the start lock. A stale lock is removed and taken once more. */
|
|
108
|
+
function takeLock(deps) {
|
|
109
|
+
const mine = JSON.stringify({ pid: deps.pid, at: deps.now() });
|
|
110
|
+
if (deps.lockFs.createExclusive(deps.lockPath, mine))
|
|
111
|
+
return true;
|
|
112
|
+
const seen = deps.lockFs.read(deps.lockPath);
|
|
113
|
+
if (!lockIsStale(seen, deps))
|
|
114
|
+
return false;
|
|
115
|
+
// Another caller may have replaced the stale lock since it was read.
|
|
116
|
+
if (deps.lockFs.read(deps.lockPath) !== seen)
|
|
117
|
+
return false;
|
|
118
|
+
deps.lockFs.remove(deps.lockPath);
|
|
119
|
+
return deps.lockFs.createExclusive(deps.lockPath, mine);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Use a running OMMS web app, or start one detached `om-memory-system web`.
|
|
123
|
+
* Never throws; a failed check must not block or fail the host session.
|
|
124
|
+
*/
|
|
125
|
+
export async function ensureWebApp(options) {
|
|
126
|
+
const { settings, budgetMs, wait = true } = options;
|
|
127
|
+
if (!settings.enabled)
|
|
128
|
+
return "disabled";
|
|
129
|
+
const deps = { ...productionDefaults(), ...options.deps };
|
|
130
|
+
const began = deps.now();
|
|
131
|
+
const remaining = () => budgetMs - (deps.now() - began);
|
|
132
|
+
const probeMs = () => budgetMs > 0 ? Math.min(HEALTH_PROBE_MS, Math.max(1, remaining())) : HEALTH_PROBE_MS;
|
|
133
|
+
const logCode = async (code) => {
|
|
134
|
+
try {
|
|
135
|
+
await deps.log("Web app ensure", { code });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* A failed log line must not break the host. */
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
let held = false;
|
|
142
|
+
try {
|
|
143
|
+
const first = await probe(settings.baseUrl, probeMs(), deps);
|
|
144
|
+
if (first === "omms")
|
|
145
|
+
return "running";
|
|
146
|
+
if (first === "other") {
|
|
147
|
+
await logCode("port-busy");
|
|
148
|
+
return "port-busy";
|
|
149
|
+
}
|
|
150
|
+
const runtime = await deps.resolveRuntime();
|
|
151
|
+
if (!runtime) {
|
|
152
|
+
await logCode("no-runtime");
|
|
153
|
+
return "no-runtime";
|
|
154
|
+
}
|
|
155
|
+
held = takeLock(deps);
|
|
156
|
+
if (!held)
|
|
157
|
+
return await waitForOther(settings.baseUrl, wait, remaining, deps);
|
|
158
|
+
// Another caller may have finished its start before this one took the lock.
|
|
159
|
+
const second = await probe(settings.baseUrl, probeMs(), deps);
|
|
160
|
+
if (second === "omms")
|
|
161
|
+
return "running";
|
|
162
|
+
if (second === "other") {
|
|
163
|
+
await logCode("port-busy");
|
|
164
|
+
return "port-busy";
|
|
165
|
+
}
|
|
166
|
+
const child = deps.spawn(runtime, [deps.cliScript, "web"], {
|
|
167
|
+
detached: true,
|
|
168
|
+
stdio: "ignore",
|
|
169
|
+
cwd: homedir(),
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
});
|
|
172
|
+
// A missing runtime reports through this event; polling then runs out.
|
|
173
|
+
child.on("error", () => undefined);
|
|
174
|
+
child.unref();
|
|
175
|
+
if (!wait) {
|
|
176
|
+
// Keep the lock until the web app answers, so a second host waits for it.
|
|
177
|
+
const releaseWhenUp = pollUntilUp(settings.baseUrl, START_LOCK_STALE_MS, deps)
|
|
178
|
+
.catch(() => false)
|
|
179
|
+
.finally(() => removeStartLockFor(deps.pid, deps.lockPath, deps.lockFs));
|
|
180
|
+
void releaseWhenUp;
|
|
181
|
+
held = false;
|
|
182
|
+
return "started";
|
|
183
|
+
}
|
|
184
|
+
const up = await pollUntilUp(settings.baseUrl, remaining(), deps);
|
|
185
|
+
return up ? "started" : "start-timeout";
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
await logCode("error");
|
|
189
|
+
return "start-timeout";
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (held)
|
|
193
|
+
removeStartLockFor(deps.pid, deps.lockPath, deps.lockFs);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** A caller without the lock never spawns. It waits for the caller that holds it. */
|
|
197
|
+
async function waitForOther(baseUrl, wait, remaining, deps) {
|
|
198
|
+
if (!wait)
|
|
199
|
+
return "started";
|
|
200
|
+
return (await pollUntilUp(baseUrl, remaining(), deps)) ? "running" : "start-timeout";
|
|
201
|
+
}
|
|
202
|
+
/** Poll health every 250 ms until an OMMS web app answers or the budget ends. */
|
|
203
|
+
async function pollUntilUp(baseUrl, budgetMs, deps) {
|
|
204
|
+
const began = deps.now();
|
|
205
|
+
const remaining = () => budgetMs - (deps.now() - began);
|
|
206
|
+
while (remaining() > 0) {
|
|
207
|
+
await deps.sleep(Math.min(POLL_INTERVAL_MS, remaining()));
|
|
208
|
+
if (remaining() <= 0)
|
|
209
|
+
break;
|
|
210
|
+
if ((await probe(baseUrl, Math.min(HEALTH_PROBE_MS, remaining()), deps)) === "omms")
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
@@ -26,6 +26,7 @@ interface WebServerConfig {
|
|
|
26
26
|
/** How long a stepped-aside server waits before it may take the port back. */
|
|
27
27
|
stepAsideHoldOffMs?: number;
|
|
28
28
|
}
|
|
29
|
+
type PowerAction = "stop" | "restart";
|
|
29
30
|
export declare class WebServer {
|
|
30
31
|
private server;
|
|
31
32
|
private config;
|
|
@@ -35,6 +36,8 @@ export declare class WebServer {
|
|
|
35
36
|
private onTakeoverCallback;
|
|
36
37
|
private onPortsExhaustedCallback;
|
|
37
38
|
private onStepAsideCallback;
|
|
39
|
+
private onPowerActionCallback;
|
|
40
|
+
private powerActionRunning;
|
|
38
41
|
private stepAsideTimer;
|
|
39
42
|
private holdOffTimer;
|
|
40
43
|
private portsExhaustedNotified;
|
|
@@ -53,6 +56,9 @@ export declare class WebServer {
|
|
|
53
56
|
setOnPortsExhaustedCallback(callback: () => void): void;
|
|
54
57
|
/** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
|
|
55
58
|
setOnStepAside(callback: () => void | Promise<void>): void;
|
|
59
|
+
/** Standalone web apps register this to stop or restart from the page. */
|
|
60
|
+
setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
|
|
61
|
+
private handlePowerAction;
|
|
56
62
|
private scheduleStepAside;
|
|
57
63
|
private handleStepAside;
|
|
58
64
|
start(): Promise<void>;
|