om-memory-system 3.6.0-next.38 → 3.6.0-next.39

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.
@@ -1,3 +1,10 @@
1
1
  import type { WebAutostartOptions } from "../services/web-autostart.js";
2
+ /** Test seams for the handover in `web install`. */
3
+ export interface WebHandoverDeps {
4
+ fetch?: typeof fetch;
5
+ sleep?: (ms: number) => Promise<void>;
6
+ /** The version this command runs as. */
7
+ version?: string;
8
+ }
2
9
  /** Run the standalone web app or manage its per-user login item. */
3
- export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean>): Promise<number>;
10
+ export declare function runWebCommand(args: string[], options?: WebAutostartOptions, online?: () => Promise<boolean>, handover?: WebHandoverDeps): Promise<number>;
@@ -1,6 +1,95 @@
1
1
  import { homedir } from "node:os";
2
+ const POLL_MS = 250;
3
+ const POLL_LIMIT = 40;
4
+ const REQUEST_TIMEOUT_MS = 2_000;
5
+ /** The version an OMMS web app reports on `url`, or null when none answers with one. */
6
+ async function readWebVersion(fetchFn, url, headers) {
7
+ try {
8
+ const response = await fetchFn(`${url}/api/settings/version`, {
9
+ headers,
10
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
11
+ });
12
+ if (!response.ok)
13
+ return null;
14
+ const body = (await response.json());
15
+ return typeof body.running === "string" ? body.running : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ async function portAnswers(fetchFn, url) {
22
+ try {
23
+ await fetchFn(`${url}/api/health`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ /** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
31
+ async function negotiateOwner(ctx) {
32
+ const { compareVersions } = await import("../services/version-compare.js");
33
+ const owner = await readWebVersion(ctx.fetchFn, ctx.url, ctx.headers);
34
+ const order = owner === null ? null : compareVersions(owner, ctx.version);
35
+ // A version that cannot be placed is never stopped.
36
+ if (owner === null || order === null)
37
+ return { kind: "none" };
38
+ if (order === 0)
39
+ return { kind: "same", owner };
40
+ if (order > 0)
41
+ return { kind: "newer", owner };
42
+ try {
43
+ const reply = await ctx.fetchFn(`${ctx.url}/api/web/step-aside`, {
44
+ method: "POST",
45
+ headers: { ...ctx.headers, "content-type": "application/json" },
46
+ body: JSON.stringify({ version: ctx.version }),
47
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
48
+ });
49
+ if (reply.status !== 202)
50
+ return { kind: "stuck", owner };
51
+ }
52
+ catch {
53
+ return { kind: "stuck", owner };
54
+ }
55
+ // If the old app is slow to stop, the login item still takes the port later.
56
+ for (let i = 0; i < POLL_LIMIT && (await portAnswers(ctx.fetchFn, ctx.url)); i++) {
57
+ await ctx.sleep(POLL_MS);
58
+ }
59
+ return { kind: "handed" };
60
+ }
61
+ /** Print which version serves the port after `web install`. */
62
+ async function announceHandover(ctx, handover) {
63
+ const { url, port, version } = ctx;
64
+ if (handover.kind === "stuck") {
65
+ console.log(`OMMS ${handover.owner} holds port ${port} and cannot hand it over. Stop the web app (Ctrl+C in its terminal, or quit the session that runs it), then run om-memory-system web install again.`);
66
+ }
67
+ else if (handover.kind === "newer") {
68
+ console.log(`OMMS ${handover.owner} already serves port ${port}. It is newer than this command (${version}). Update the global command: npm i -g om-memory-system`);
69
+ }
70
+ else if (handover.kind === "same") {
71
+ console.log(`OMMS web app: ${url} (version ${handover.owner})`);
72
+ }
73
+ else if (handover.kind === "none") {
74
+ console.log(`OMMS web app: ${url}`);
75
+ }
76
+ else {
77
+ let serving = null;
78
+ for (let i = 0; i < POLL_LIMIT && !serving; i++) {
79
+ serving = await readWebVersion(ctx.fetchFn, url, ctx.headers);
80
+ if (!serving)
81
+ await ctx.sleep(POLL_MS);
82
+ }
83
+ if (!serving)
84
+ console.log(`OMMS web app: ${url}`);
85
+ else if (serving === version)
86
+ console.log(`OMMS web app: ${url} (version ${serving})`);
87
+ else
88
+ console.log(`OMMS web app: ${url} (version ${serving}). This is not the version of this command (${version}).`);
89
+ }
90
+ }
2
91
  /** Run the standalone web app or manage its per-user login item. */
3
- export async function runWebCommand(args, options = {}, online) {
92
+ export async function runWebCommand(args, options = {}, online, handover = {}) {
4
93
  const home = options.home ?? homedir();
5
94
  const config = await import("../config.js");
6
95
  config.initConfig(home);
@@ -21,12 +110,33 @@ export async function runWebCommand(args, options = {}, online) {
21
110
  const { readGlobalConfigRevision, writeGlobalConfigKeys } = await import("../services/global-config-writer.js");
22
111
  await writeGlobalConfigKeys({ webServerAutoStart: action === "install" }, readGlobalConfigRevision());
23
112
  config.initConfig(home);
113
+ let context;
114
+ let outcome = { kind: "none" };
115
+ if (action === "install") {
116
+ const [{ getOrCreateAuthToken, AUTH_HEADER }, { packageVersion }] = await Promise.all([
117
+ import("../services/auth-token.js"),
118
+ import("../services/package-version.js"),
119
+ ]);
120
+ const apiToken = config.CONFIG.webServerApiToken;
121
+ context = {
122
+ fetchFn: handover.fetch ?? fetch,
123
+ sleep: handover.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
124
+ url,
125
+ port: config.CONFIG.webServerPort,
126
+ headers: {
127
+ [AUTH_HEADER]: getOrCreateAuthToken(),
128
+ ...(apiToken ? { authorization: `Bearer ${apiToken}` } : {}),
129
+ },
130
+ version: handover.version ?? packageVersion(),
131
+ };
132
+ outcome = await negotiateOwner(context);
133
+ }
24
134
  const result = action === "install"
25
135
  ? installWebAutostart({ ...options, start: true })
26
136
  : removeWebAutostart({ ...options, start: true });
27
137
  console.log(`OMMS login item: ${result.state}`);
28
- if (action === "install" && result.state === "installed")
29
- console.log(`OMMS web app: ${url}`);
138
+ if (context && result.state === "installed")
139
+ await announceHandover(context, outcome);
30
140
  return action === "install" && result.state !== "installed" ? 1 : 0;
31
141
  }
32
142
  if (action === "status") {
@@ -72,5 +182,11 @@ export async function runWebCommand(args, options = {}, online) {
72
182
  };
73
183
  process.once("SIGINT", stop);
74
184
  process.once("SIGTERM", stop);
185
+ // A newer `web install` asked this web app to give up the port.
186
+ server.setOnStepAside(async () => {
187
+ clearInterval(keeper);
188
+ await server.stop();
189
+ process.exit(0);
190
+ });
75
191
  return 0;
76
192
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
3
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
4
+ * version cannot be parsed, such as `unknown`.
5
+ */
6
+ export declare function compareVersions(a: string, b: string): number | null;
7
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
8
+ export declare function isOlderVersion(a: string, b: string): boolean;
@@ -0,0 +1,58 @@
1
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
2
+ function parse(version) {
3
+ const match = SEMVER.exec(version.trim());
4
+ if (!match)
5
+ return null;
6
+ return {
7
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
8
+ pre: match[4] ? match[4].split(".") : [],
9
+ };
10
+ }
11
+ function comparePart(a, b) {
12
+ const aNumeric = /^\d+$/.test(a);
13
+ const bNumeric = /^\d+$/.test(b);
14
+ if (aNumeric && bNumeric)
15
+ return Math.sign(Number(a) - Number(b));
16
+ // A numeric part is older than a text part.
17
+ if (aNumeric)
18
+ return -1;
19
+ if (bNumeric)
20
+ return 1;
21
+ return a < b ? -1 : a > b ? 1 : 0;
22
+ }
23
+ /**
24
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
25
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
26
+ * version cannot be parsed, such as `unknown`.
27
+ */
28
+ export function compareVersions(a, b) {
29
+ const left = parse(a);
30
+ const right = parse(b);
31
+ if (!left || !right)
32
+ return null;
33
+ for (let i = 0; i < 3; i++) {
34
+ const diff = left.core[i] - right.core[i];
35
+ if (diff)
36
+ return Math.sign(diff);
37
+ }
38
+ // A release is newer than any of its prereleases.
39
+ if (!left.pre.length || !right.pre.length)
40
+ return right.pre.length - left.pre.length;
41
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
42
+ const l = left.pre[i];
43
+ const r = right.pre[i];
44
+ if (l === undefined)
45
+ return -1;
46
+ if (r === undefined)
47
+ return 1;
48
+ const diff = comparePart(l, r);
49
+ if (diff)
50
+ return diff;
51
+ }
52
+ return 0;
53
+ }
54
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
55
+ export function isOlderVersion(a, b) {
56
+ const order = compareVersions(a, b);
57
+ return order !== null && order < 0;
58
+ }
@@ -1,4 +1,6 @@
1
1
  export declare function isLoopbackHost(host: string): boolean;
2
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
3
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
2
4
  /** A URL needs brackets around an IPv6 literal such as ::1. */
3
5
  export declare function webServerUrl(host: string, port: number): string;
4
6
  export declare function assertWebServerNetworkAuth(host: string, apiToken?: string, basicAuthEnabled?: boolean): void;
@@ -3,6 +3,16 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0
3
3
  export function isLoopbackHost(host) {
4
4
  return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
5
5
  }
6
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
7
+ export function isLoopbackAddress(address) {
8
+ if (!address)
9
+ return false;
10
+ const value = address
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/^::ffff:/, "");
14
+ return value === "::1" || /^127(?:\.\d{1,3}){3}$/.test(value);
15
+ }
6
16
  /** A URL needs brackets around an IPv6 literal such as ::1. */
7
17
  export function webServerUrl(host, port) {
8
18
  const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -23,6 +23,8 @@ interface WebServerConfig {
23
23
  enabled: boolean;
24
24
  auth?: WebAuth;
25
25
  apiToken?: string;
26
+ /** How long a stepped-aside server waits before it may take the port back. */
27
+ stepAsideHoldOffMs?: number;
26
28
  }
27
29
  export declare class WebServer {
28
30
  private server;
@@ -32,6 +34,9 @@ export declare class WebServer {
32
34
  private healthCheckInterval;
33
35
  private onTakeoverCallback;
34
36
  private onPortsExhaustedCallback;
37
+ private onStepAsideCallback;
38
+ private stepAsideTimer;
39
+ private holdOffTimer;
35
40
  private portsExhaustedNotified;
36
41
  private takeoverFailures;
37
42
  private readonly maxFallbackPort;
@@ -46,6 +51,10 @@ export declare class WebServer {
46
51
  constructor(config: WebServerConfig);
47
52
  setOnTakeoverCallback(callback: () => Promise<void>): void;
48
53
  setOnPortsExhaustedCallback(callback: () => void): void;
54
+ /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
55
+ setOnStepAside(callback: () => void | Promise<void>): void;
56
+ private scheduleStepAside;
57
+ private handleStepAside;
49
58
  start(): Promise<void>;
50
59
  private _start;
51
60
  private startHealthCheckLoop;
@@ -5,7 +5,9 @@ import { join, dirname, extname, normalize, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { log } from "./logger.js";
7
7
  import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js";
8
- import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
8
+ import { assertWebServerNetworkAuth, authorizeApiRequest, isLoopbackAddress, isLoopbackHost, webServerUrl, } from "./web-api-auth.js";
9
+ import { packageVersion } from "./package-version.js";
10
+ import { isOlderVersion } from "./version-compare.js";
9
11
  import { getOrCreateAuthToken, isAuthorizedApiRequest } from "./auth-token.js";
10
12
  import { WebAuth } from "./web-auth.js";
11
13
  import { NODE_HTTP_IDLE_TIMEOUT_MS } from "./request-timeouts.js";
@@ -34,7 +36,7 @@ function serveFetch(opts) {
34
36
  const bunHandle = globalThis.Bun.serve({
35
37
  port: opts.port,
36
38
  hostname: opts.hostname,
37
- fetch: opts.fetch,
39
+ fetch: (req, server) => opts.fetch(req, server.requestIP(req)?.address),
38
40
  });
39
41
  return Promise.resolve({ stop: () => bunHandle.stop() });
40
42
  }
@@ -67,7 +69,7 @@ function serveFetch(opts) {
67
69
  signal: abortController.signal,
68
70
  ...(hasBody ? { duplex: "half" } : {}),
69
71
  });
70
- const webRes = await opts.fetch(webReq);
72
+ const webRes = await opts.fetch(webReq, req.socket.remoteAddress);
71
73
  if (destroyed)
72
74
  return;
73
75
  res.statusCode = webRes.status;
@@ -138,6 +140,9 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
138
140
  }
139
141
  return currentPort + 1;
140
142
  }
143
+ const STEP_ASIDE_HOLD_OFF_MS = 60_000;
144
+ /** Lets the 202 reply reach the caller before the server stops. */
145
+ const STEP_ASIDE_REPLY_GRACE_MS = 100;
141
146
  export class WebServer {
142
147
  server = null;
143
148
  config;
@@ -146,6 +151,9 @@ export class WebServer {
146
151
  healthCheckInterval = null;
147
152
  onTakeoverCallback = null;
148
153
  onPortsExhaustedCallback = null;
154
+ onStepAsideCallback = null;
155
+ stepAsideTimer = null;
156
+ holdOffTimer = null;
149
157
  portsExhaustedNotified = false;
150
158
  takeoverFailures = 0;
151
159
  maxFallbackPort;
@@ -199,6 +207,57 @@ export class WebServer {
199
207
  setOnPortsExhaustedCallback(callback) {
200
208
  this.onPortsExhaustedCallback = callback;
201
209
  }
210
+ /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
211
+ setOnStepAside(callback) {
212
+ this.onStepAsideCallback = callback;
213
+ }
214
+ scheduleStepAside() {
215
+ if (this.stepAsideTimer)
216
+ return;
217
+ this.stepAsideTimer = setTimeout(async () => {
218
+ this.stepAsideTimer = null;
219
+ if (this.onStepAsideCallback) {
220
+ try {
221
+ await this.onStepAsideCallback();
222
+ }
223
+ catch (error) {
224
+ log("Step-aside callback error", { error: String(error) });
225
+ }
226
+ return;
227
+ }
228
+ // Inside a host session: stop serving, keep the session alive, and let the
229
+ // newer web app bind the port before the takeover loop can run again.
230
+ await this.stop();
231
+ this.startPromise = null;
232
+ this.holdOffTimer = setTimeout(() => {
233
+ this.holdOffTimer = null;
234
+ this.startHealthCheckLoop();
235
+ }, this.config.stepAsideHoldOffMs ?? STEP_ASIDE_HOLD_OFF_MS);
236
+ this.holdOffTimer.unref();
237
+ }, STEP_ASIDE_REPLY_GRACE_MS);
238
+ this.stepAsideTimer.unref();
239
+ }
240
+ handleStepAside(remoteAddress, body) {
241
+ const callerVersion = body && typeof body.version === "string"
242
+ ? body.version.slice(0, 40)
243
+ : "";
244
+ const record = (outcome) => log("Web server step-aside request", {
245
+ outcome,
246
+ ownVersion: packageVersion(),
247
+ callerVersion,
248
+ });
249
+ if (!isLoopbackAddress(remoteAddress)) {
250
+ record("refused_auth");
251
+ return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
252
+ }
253
+ if (!callerVersion || !isOlderVersion(packageVersion(), callerVersion)) {
254
+ record("refused_not_newer");
255
+ return this.jsonResponse({ success: false, error: "Caller is not newer" }, 409);
256
+ }
257
+ record("stepped_aside");
258
+ this.scheduleStepAside();
259
+ return this.jsonResponse({ success: true }, 202);
260
+ }
202
261
  async start() {
203
262
  if (this.startPromise) {
204
263
  return this.startPromise;
@@ -334,6 +393,11 @@ export class WebServer {
334
393
  }
335
394
  async stop() {
336
395
  this.stopHealthCheckLoop();
396
+ for (const timer of [this.stepAsideTimer, this.holdOffTimer])
397
+ if (timer)
398
+ clearTimeout(timer);
399
+ this.stepAsideTimer = null;
400
+ this.holdOffTimer = null;
337
401
  if (!this.isOwner || !this.server) {
338
402
  return;
339
403
  }
@@ -380,7 +444,7 @@ export class WebServer {
380
444
  }
381
445
  }
382
446
  // --- HTTP request handling ---
383
- async handleRequest(req) {
447
+ async handleRequest(req, remoteAddress) {
384
448
  const url = new URL(req.url);
385
449
  const path = url.pathname;
386
450
  const method = req.method;
@@ -406,6 +470,13 @@ export class WebServer {
406
470
  ? authorizeApiRequest(req, this.config.apiToken)
407
471
  : null;
408
472
  if (!this.config.apiToken || configuredTokenFailure) {
473
+ if (path === "/api/web/step-aside") {
474
+ log("Web server step-aside request", {
475
+ outcome: "refused_auth",
476
+ ownVersion: packageVersion(),
477
+ callerVersion: "",
478
+ });
479
+ }
409
480
  return (configuredTokenFailure ??
410
481
  this.jsonResponse({ success: false, error: "Unauthorized" }, 401));
411
482
  }
@@ -471,6 +542,10 @@ export class WebServer {
471
542
  ? controls.resume(host, cwd)
472
543
  : controls.runNow(host, cwd));
473
544
  }
545
+ if (path === "/api/web/step-aside" && method === "POST") {
546
+ const body = await req.json().catch(() => null);
547
+ return this.handleStepAside(remoteAddress, body);
548
+ }
474
549
  if (path === "/api/settings/version" && method === "GET") {
475
550
  const [{ packageVersion }, { globalCommandVersion }] = await Promise.all([
476
551
  import("./package-version.js"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "om-memory-system",
3
- "version": "3.6.0-next.38",
3
+ "version": "3.6.0-next.39",
4
4
  "description": "Opinionated modular memory system for coding agents (OpenCode + Pi)",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",