om-memory-system 3.6.0-next.40 → 3.6.0-next.43

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.
@@ -173,7 +173,7 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
173
173
  });
174
174
  console.log(`OMMS web app: ${server.getUrl()}`);
175
175
  // The Node HTTP adapter unrefs its socket for plugin use. Keep the CLI alive.
176
- const keeper = setInterval(() => { }, 60_000);
176
+ let keeper = setInterval(() => { }, 60_000);
177
177
  const stop = () => {
178
178
  clearInterval(keeper);
179
179
  process.off("SIGINT", stop);
@@ -191,6 +191,14 @@ export async function runWebCommand(args, options = {}, online, handover = {}) {
191
191
  process.off("SIGTERM", stop);
192
192
  await server.stop();
193
193
  },
194
+ // A restart whose copy failed serves again from this process.
195
+ resumeServer: async () => {
196
+ keeper = setInterval(() => { }, 60_000);
197
+ process.once("SIGINT", stop);
198
+ process.once("SIGTERM", stop);
199
+ await server.start();
200
+ },
201
+ baseUrl: server.getUrl(),
194
202
  loginItem: action === "--login-item",
195
203
  autostart: options,
196
204
  }));
@@ -1,10 +1,15 @@
1
1
  import type { WebAutostartOptions } from "../services/web-autostart.js";
2
+ /** The two choices the page offers; the web server passes them through unchanged. */
2
3
  export type PowerAction = "stop" | "restart";
3
4
  interface SpawnedChild {
4
5
  pid?: number;
5
6
  unref(): void;
6
- on(event: "error", listener: (error: Error) => void): unknown;
7
+ on(event: "error" | "exit", listener: () => void): unknown;
8
+ kill(signal?: NodeJS.Signals): boolean;
7
9
  }
10
+ /** How long the old process waits for the copy to answer before it serves again. */
11
+ export declare const HANDOFF_WAIT_MS = 15000;
12
+ /** Injected so tests check the restart order without spawning or exiting. */
8
13
  export interface PowerDeps {
9
14
  spawn: (command: string, args: string[], options: {
10
15
  detached: true;
@@ -16,6 +21,12 @@ export interface PowerDeps {
16
21
  restartLoginItem: () => boolean | Promise<boolean>;
17
22
  /** Write the start lock, naming `pid`, so a host that starts meanwhile waits. */
18
23
  writeStartLock: (pid: number) => void | Promise<void>;
24
+ /** Remove the start lock when it names `pid`. */
25
+ removeStartLock: (pid: number) => void | Promise<void>;
26
+ /** True when an OMMS web app answers health at `baseUrl`. */
27
+ probe: (baseUrl: string) => Promise<boolean>;
28
+ sleep: (ms: number) => Promise<void>;
29
+ now: () => number;
19
30
  exit: (code: number) => void;
20
31
  /** The runtime and arguments of this process, for a detached copy. */
21
32
  execPath: string;
@@ -26,13 +37,17 @@ export interface PowerDeps {
26
37
  }
27
38
  /**
28
39
  * The callback the web server runs after it replies to Stop or Restart.
29
- * `stopServer` stops serving and any keep-alive timer. Restart starts a fresh
30
- * copy on the same port: the service manager for the login item, and a detached
31
- * copy otherwise. A detached child of the login item would die with its
32
- * launchd job or systemd control group.
40
+ * `stopServer` stops serving and any keep-alive timer; `resumeServer` undoes it.
41
+ * Restart starts a fresh copy on the same port: the service manager for the
42
+ * login item, and a detached copy otherwise. A detached child of the login item
43
+ * would die with its launchd job or systemd control group. A restart never
44
+ * leaves the port empty because the copy failed: the old process serves again.
33
45
  */
34
46
  export declare function createPowerAction(options: {
35
47
  stopServer: () => Promise<void>;
48
+ resumeServer: () => Promise<void>;
49
+ /** The URL this web app serves, where the copy must answer after the handoff. */
50
+ baseUrl: string;
36
51
  loginItem: boolean;
37
52
  autostart?: WebAutostartOptions;
38
53
  deps?: Partial<PowerDeps>;
@@ -1,15 +1,33 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
+ /** How long a fresh copy must stay up before the old process stops serving. */
4
+ const SPAWN_CHECK_MS = 1_000;
5
+ /** How long the old process waits for the copy to answer before it serves again. */
6
+ export const HANDOFF_WAIT_MS = 15_000;
7
+ const HANDOFF_POLL_MS = 250;
3
8
  function productionDeps(options) {
4
9
  return {
5
10
  spawn: nodeSpawn,
6
11
  restartLoginItem: async () => (await import("../services/web-autostart.js")).restartWebAutostart(options),
7
12
  writeStartLock: async (pid) => {
8
13
  const { nodeLockFs, startLockPath } = await import("../services/web-ensure.js");
9
- const path = startLockPath();
10
- nodeLockFs.remove(path);
11
- nodeLockFs.createExclusive(path, JSON.stringify({ pid, at: Date.now() }));
14
+ nodeLockFs.replace(startLockPath(), JSON.stringify({ pid, at: Date.now() }));
12
15
  },
16
+ removeStartLock: async (pid) => (await import("../services/web-ensure.js")).removeStartLockFor(pid),
17
+ probe: async (baseUrl) => {
18
+ try {
19
+ const response = await fetch(`${baseUrl}/api/health`, {
20
+ signal: AbortSignal.timeout(1_000),
21
+ });
22
+ const body = (await response.json());
23
+ return response.ok && body.success === true && body.status === "ok";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ },
29
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
30
+ now: () => Date.now(),
13
31
  exit: (code) => process.exit(code),
14
32
  execPath: process.execPath,
15
33
  args: process.argv.slice(1),
@@ -20,42 +38,104 @@ function productionDeps(options) {
20
38
  }
21
39
  /**
22
40
  * The callback the web server runs after it replies to Stop or Restart.
23
- * `stopServer` stops serving and any keep-alive timer. Restart starts a fresh
24
- * copy on the same port: the service manager for the login item, and a detached
25
- * copy otherwise. A detached child of the login item would die with its
26
- * launchd job or systemd control group.
41
+ * `stopServer` stops serving and any keep-alive timer; `resumeServer` undoes it.
42
+ * Restart starts a fresh copy on the same port: the service manager for the
43
+ * login item, and a detached copy otherwise. A detached child of the login item
44
+ * would die with its launchd job or systemd control group. A restart never
45
+ * leaves the port empty because the copy failed: the old process serves again.
27
46
  */
28
47
  export function createPowerAction(options) {
29
48
  const deps = { ...productionDeps(options.autostart ?? {}), ...options.deps };
49
+ const logFailure = (code) => Promise.resolve(deps.log("Web app restart failed", { code })).catch(() => undefined);
50
+ let stopped = false;
51
+ /** Start the copy, check it, stop serving, and wait until the copy answers. */
52
+ async function handOff() {
53
+ let child;
54
+ try {
55
+ child = deps.spawn(deps.execPath, deps.args, {
56
+ detached: true,
57
+ stdio: "ignore",
58
+ cwd: deps.cwd,
59
+ windowsHide: true,
60
+ });
61
+ }
62
+ catch {
63
+ return giveUp("spawn-error");
64
+ }
65
+ let failed = false;
66
+ let exited = false;
67
+ child.on("error", () => (failed = true));
68
+ child.on("exit", () => (exited = true));
69
+ child.unref();
70
+ const copyPid = child.pid;
71
+ if (!copyPid)
72
+ return giveUp("spawn-error");
73
+ // A missing script or a broken runtime ends the copy within this time.
74
+ await deps.sleep(SPAWN_CHECK_MS);
75
+ if (failed)
76
+ return giveUp("spawn-error");
77
+ if (exited)
78
+ return giveUp("copy-exit");
79
+ await deps.writeStartLock(copyPid);
80
+ if (!stopped) {
81
+ await options.stopServer();
82
+ stopped = true;
83
+ }
84
+ // The copy waits as a non-owner and takes the port within about 7 seconds.
85
+ const began = deps.now();
86
+ while (deps.now() - began < HANDOFF_WAIT_MS) {
87
+ if (exited)
88
+ return giveUp("copy-exit", copyPid);
89
+ if (await deps.probe(options.baseUrl))
90
+ return deps.exit(0);
91
+ await deps.sleep(HANDOFF_POLL_MS);
92
+ }
93
+ if (exited)
94
+ return giveUp("copy-exit", copyPid);
95
+ // A copy that never answers would wait as a non-owner for ever.
96
+ child.kill("SIGTERM");
97
+ return giveUp("handoff", copyPid);
98
+ }
99
+ /** Keep or resume serving on the old process, and release the lock it wrote. */
100
+ async function giveUp(code, copyPid) {
101
+ if (copyPid !== undefined)
102
+ await deps.removeStartLock(copyPid);
103
+ else if (!options.loginItem)
104
+ await deps.removeStartLock(deps.pid);
105
+ await logFailure(code);
106
+ if (!stopped)
107
+ return;
108
+ try {
109
+ await options.resumeServer();
110
+ stopped = false;
111
+ }
112
+ catch (error) {
113
+ await logFailure(error instanceof Error ? error.name : "resume-error");
114
+ deps.exit(1);
115
+ }
116
+ }
30
117
  return async (action) => {
31
118
  if (action === "stop") {
32
119
  await options.stopServer();
33
120
  deps.exit(0);
34
121
  return;
35
122
  }
123
+ stopped = false;
36
124
  try {
37
- // Hold other hosts off until the copy owns the port.
38
- if (!options.loginItem)
125
+ if (options.loginItem) {
126
+ await options.stopServer();
127
+ stopped = true;
128
+ if (await deps.restartLoginItem())
129
+ return deps.exit(0);
130
+ }
131
+ else {
132
+ // Hold other hosts off until the copy owns the port.
39
133
  await deps.writeStartLock(deps.pid);
40
- await options.stopServer();
41
- if (options.loginItem && (await deps.restartLoginItem()))
42
- return deps.exit(0);
43
- const child = deps.spawn(deps.execPath, deps.args, {
44
- detached: true,
45
- stdio: "ignore",
46
- cwd: deps.cwd,
47
- windowsHide: true,
48
- });
49
- child.on("error", () => undefined);
50
- child.unref();
51
- if (child.pid)
52
- await deps.writeStartLock(child.pid);
134
+ }
135
+ await handOff();
53
136
  }
54
137
  catch (error) {
55
- await Promise.resolve(deps.log("Web app restart failed", {
56
- code: error instanceof Error ? error.name : "unknown",
57
- })).catch(() => undefined);
138
+ await giveUp(error instanceof Error ? error.name : "unknown");
58
139
  }
59
- deps.exit(0);
60
140
  };
61
141
  }
@@ -12,6 +12,7 @@ function formatSearchResults(query, results, limit) {
12
12
  results: memoryResults.slice(0, limit || 10).map((result) => ({
13
13
  id: result.id,
14
14
  content: result.memory || result.chunk,
15
+ type: result.type,
15
16
  similarity: Math.round(result.similarity * 100),
16
17
  })),
17
18
  };
@@ -198,6 +199,7 @@ export async function executeMemoryOperation(args, context) {
198
199
  memories: result.memories?.map((memory) => ({
199
200
  id: memory.id,
200
201
  content: memory.summary,
202
+ type: memory.type,
201
203
  createdAt: memory.createdAt,
202
204
  })),
203
205
  };
@@ -1,5 +1,6 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
- import { isAbsolute } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
3
4
  import { CONFIG, refreshConfigIfChanged } from "../config.js";
4
5
  import { captureConversation } from "../core/capture.js";
5
6
  import { classifyCaptureFailure, quickRetryDelayMs } from "../core/capture-retry-policy.js";
@@ -12,6 +13,7 @@ import { log } from "../services/logger.js";
12
13
  import { isFullyPrivate, stripPrivateContent } from "../services/privacy.js";
13
14
  import { getTags } from "../services/tags.js";
14
15
  import { extractClaudeWindowsAfter, parseClaudeTranscript, } from "./claude-conversation.js";
16
+ import { defaultClaudeProjectsRoot } from "./claude-reader.js";
15
17
  // The web app's side of the Claude Code hooks (design decisions 4, 6, 7):
16
18
  // retrieval for SessionStart and UserPromptSubmit, a one-worker capture queue
17
19
  // for Stop, profile learning, and the once-per-process backfill start. Logs
@@ -61,6 +63,41 @@ function parseRetrieveRequest(body) {
61
63
  prompt: optionalString(input, "prompt"),
62
64
  };
63
65
  }
66
+ /**
67
+ * Resolves symlinks and returns the letter case on disk. Node's plain
68
+ * `realpathSync` keeps the case it is given, which fails the root check on a
69
+ * case-insensitive file system. A path that does not exist yet resolves
70
+ * through its nearest existing parent.
71
+ */
72
+ function realPathOrResolved(path) {
73
+ const absolute = resolve(path);
74
+ try {
75
+ return realpathSync.native(absolute);
76
+ }
77
+ catch {
78
+ const parent = dirname(absolute);
79
+ if (parent === absolute)
80
+ return absolute;
81
+ return join(realPathOrResolved(parent), basename(absolute));
82
+ }
83
+ }
84
+ /**
85
+ * Claude Code writes `<projects root>/<folder>/<session_id>.jsonl`. Accept only
86
+ * that shape, so the route cannot be used to read other `.jsonl` files. Returns
87
+ * the resolved path, which the capture then reads.
88
+ */
89
+ function checkedTranscriptPath(transcriptPath, sessionId) {
90
+ const fileName = `${sessionId}.jsonl`;
91
+ const real = realPathOrResolved(transcriptPath);
92
+ if (basename(transcriptPath) !== fileName || basename(real) !== fileName) {
93
+ throw new ClaudeHookRequestError("transcript_path must be named <session_id>.jsonl");
94
+ }
95
+ const root = realPathOrResolved(defaultClaudeProjectsRoot());
96
+ if (!real.startsWith(root + sep)) {
97
+ throw new ClaudeHookRequestError("transcript_path must be under the Claude projects folder");
98
+ }
99
+ return real;
100
+ }
64
101
  function parseCaptureRequest(body) {
65
102
  const input = objectBody(body);
66
103
  const transcriptPath = input.transcript_path;
@@ -69,9 +106,11 @@ function parseCaptureRequest(body) {
69
106
  !transcriptPath.endsWith(".jsonl")) {
70
107
  throw new ClaudeHookRequestError("transcript_path must be an absolute .jsonl path");
71
108
  }
109
+ const { sessionId, cwd } = sessionAndCwd(input);
72
110
  return {
73
- ...sessionAndCwd(input),
74
- transcriptPath,
111
+ sessionId,
112
+ cwd,
113
+ transcriptPath: checkedTranscriptPath(transcriptPath, sessionId),
75
114
  lastAssistantMessage: optionalString(input, "last_assistant_message"),
76
115
  stopHookActive: input.stop_hook_active === true,
77
116
  };
@@ -16,6 +16,7 @@ import { type ImportPathMap, type ImportProjectReport, type LazyImportSource } f
16
16
  * time.
17
17
  */
18
18
  export declare const CLAUDE_HEADER_READ_LIMIT: number;
19
+ /** `$CLAUDE_CONFIG_DIR/projects` when the variable is set, else `~/.claude/projects`. */
19
20
  export declare function defaultClaudeProjectsRoot(): string;
20
21
  export interface DiscoveredClaudeSession {
21
22
  file: string;
@@ -23,8 +23,10 @@ import { countImportableUnits, projectFilterTag, selectImportWindows, } from "./
23
23
  */
24
24
  export const CLAUDE_HEADER_READ_LIMIT = 1024 * 1024;
25
25
  const READ_CHUNK = 64 * 1024;
26
+ /** `$CLAUDE_CONFIG_DIR/projects` when the variable is set, else `~/.claude/projects`. */
26
27
  export function defaultClaudeProjectsRoot() {
27
- return join(homedir(), ".claude", "projects");
28
+ const configDir = process.env.CLAUDE_CONFIG_DIR?.trim();
29
+ return join(configDir || join(homedir(), ".claude"), "projects");
28
30
  }
29
31
  /** A missing folder is an empty history. Any other read error is reported, not hidden. */
30
32
  function readEntries(dir, unreadable) {
@@ -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,
@@ -64,6 +64,7 @@ export declare class LocalMemoryClient {
64
64
  memories: {
65
65
  id: any;
66
66
  summary: any;
67
+ type: string | undefined;
67
68
  createdAt: string;
68
69
  metadata: any;
69
70
  displayName: any;
@@ -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,
@@ -32,6 +32,7 @@ export interface SearchResult {
32
32
  similarity: number;
33
33
  createdAt?: number;
34
34
  tags?: string[];
35
+ type?: string;
35
36
  metadata?: Record<string, unknown>;
36
37
  displayName?: string;
37
38
  userName?: string;
@@ -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,
@@ -1,3 +1,4 @@
1
+ /** Each code maps to a host message or log code; none of them fails the session. */
1
2
  export type EnsureResult = "running" | "started" | "disabled" | "port-busy" | "no-runtime" | "start-timeout";
2
3
  /** Port and switch of the web app, as the caller read them from `CONFIG`. */
3
4
  export interface EnsureSettings {
@@ -14,13 +15,20 @@ type SpawnFn = (command: string, args: string[], options: {
14
15
  cwd: string;
15
16
  windowsHide: true;
16
17
  }) => SpawnedChild;
17
- /** The three file operations the start lock needs. */
18
+ /** The file operations the start lock needs. */
18
19
  export interface LockFs {
19
20
  /** Create the file only when it does not exist. Returns false when it does. */
20
21
  createExclusive(path: string, text: string): boolean;
21
22
  read(path: string): string | null;
22
23
  remove(path: string): void;
24
+ /** Hard-link `from` to `to`. False when `to` exists or `from` is missing. */
25
+ link(from: string, to: string): boolean;
26
+ /** Time since the file's change time, or null when it is missing. A link sets the change time. */
27
+ ageMs(path: string): number | null;
28
+ /** Replace the file in one step, so a reader never finds it missing. */
29
+ replace(path: string, text: string): void;
23
30
  }
31
+ /** Injected so tests run the start rule without a network, a process, or a real clock. */
24
32
  export interface EnsureDeps {
25
33
  fetch: typeof fetch;
26
34
  spawn: SpawnFn;
@@ -36,6 +44,7 @@ export interface EnsureDeps {
36
44
  pid: number;
37
45
  log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
38
46
  }
47
+ /** Hosts differ only in these values; the start rule itself has no host-specific code. */
39
48
  export interface EnsureOptions {
40
49
  settings: EnsureSettings;
41
50
  /** How long the caller may wait for the web app to answer. */
@@ -48,9 +57,18 @@ export interface EnsureOptions {
48
57
  export declare const START_LOCK_STALE_MS = 20000;
49
58
  /** Path of the start lock; the standalone command writes it for a restart copy. */
50
59
  export declare function startLockPath(home?: string): string;
60
+ /** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
51
61
  export declare const nodeLockFs: LockFs;
52
62
  /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
53
63
  export declare function removeStartLockFor(pid: number, path?: string, lockFs?: LockFs): void;
64
+ /** What taking the start lock needs; a test drives two callers through it step by step. */
65
+ export type LockDeps = Pick<EnsureDeps, "lockPath" | "lockFs" | "pid" | "now" | "pidAlive">;
66
+ /**
67
+ * Take the start lock. A stale lock is replaced by one caller only: the caller
68
+ * that hard-links it to a tombstone named after its text. Remove-then-create
69
+ * alone let two callers each remove the lock the other had just created.
70
+ */
71
+ export declare function takeStartLock(deps: LockDeps): boolean;
54
72
  /**
55
73
  * Use a running OMMS web app, or start one detached `om-memory-system web`.
56
74
  * Never throws; a failed check must not block or fail the host session.
@@ -1,5 +1,6 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
4
  import { homedir } from "node:os";
4
5
  import { dirname, join } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
@@ -11,6 +12,7 @@ export const START_LOCK_STALE_MS = 20_000;
11
12
  export function startLockPath(home = homedir()) {
12
13
  return join(home, ".omms", "web-start.lock");
13
14
  }
15
+ /** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
14
16
  export const nodeLockFs = {
15
17
  createExclusive(path, text) {
16
18
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -35,6 +37,32 @@ export const nodeLockFs = {
35
37
  remove(path) {
36
38
  rmSync(path, { force: true });
37
39
  },
40
+ link(from, to) {
41
+ try {
42
+ linkSync(from, to);
43
+ return true;
44
+ }
45
+ catch (error) {
46
+ const code = error.code;
47
+ if (code === "EEXIST" || code === "ENOENT")
48
+ return false;
49
+ throw error;
50
+ }
51
+ },
52
+ ageMs(path) {
53
+ try {
54
+ return Date.now() - statSync(path).ctimeMs;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ },
60
+ replace(path, text) {
61
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
62
+ const temp = `${path}.${process.pid}.tmp`;
63
+ writeFileSync(temp, text, { mode: 0o600 });
64
+ renameSync(temp, path);
65
+ },
38
66
  };
39
67
  /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
40
68
  export function removeStartLockFor(pid, path = startLockPath(), lockFs = nodeLockFs) {
@@ -104,19 +132,40 @@ function lockIsStale(text, deps) {
104
132
  return true;
105
133
  }
106
134
  }
107
- /** Take the start lock. A stale lock is removed and taken once more. */
108
- function takeLock(deps) {
135
+ /**
136
+ * Take the start lock. A stale lock is replaced by one caller only: the caller
137
+ * that hard-links it to a tombstone named after its text. Remove-then-create
138
+ * alone let two callers each remove the lock the other had just created.
139
+ */
140
+ export function takeStartLock(deps) {
109
141
  const mine = JSON.stringify({ pid: deps.pid, at: deps.now() });
110
142
  if (deps.lockFs.createExclusive(deps.lockPath, mine))
111
143
  return true;
112
144
  const seen = deps.lockFs.read(deps.lockPath);
145
+ if (seen === null)
146
+ return deps.lockFs.createExclusive(deps.lockPath, mine);
113
147
  if (!lockIsStale(seen, deps))
114
148
  return false;
115
- // Another caller may have replaced the stale lock since it was read.
116
- if (deps.lockFs.read(deps.lockPath) !== seen)
149
+ const tomb = `${deps.lockPath}.${createHash("sha256").update(seen).digest("hex").slice(0, 16)}.stale`;
150
+ if (!deps.lockFs.link(deps.lockPath, tomb)) {
151
+ // A takeover that crashed after its link leaves the tombstone behind. Clear
152
+ // it, and let the next caller do the takeover.
153
+ const age = deps.lockFs.ageMs(tomb);
154
+ if (age !== null && age > START_LOCK_STALE_MS)
155
+ deps.lockFs.remove(tomb);
117
156
  return false;
118
- deps.lockFs.remove(deps.lockPath);
119
- return deps.lockFs.createExclusive(deps.lockPath, mine);
157
+ }
158
+ try {
159
+ // A slow caller can link a lock that was replaced after it read it. That
160
+ // lock is live, so leave it.
161
+ if (deps.lockFs.read(tomb) !== seen)
162
+ return false;
163
+ deps.lockFs.remove(deps.lockPath);
164
+ return deps.lockFs.createExclusive(deps.lockPath, mine);
165
+ }
166
+ finally {
167
+ deps.lockFs.remove(tomb);
168
+ }
120
169
  }
121
170
  /**
122
171
  * Use a running OMMS web app, or start one detached `om-memory-system web`.
@@ -152,7 +201,7 @@ export async function ensureWebApp(options) {
152
201
  await logCode("no-runtime");
153
202
  return "no-runtime";
154
203
  }
155
- held = takeLock(deps);
204
+ held = takeStartLock(deps);
156
205
  if (!held)
157
206
  return await waitForOther(settings.baseUrl, wait, remaining, deps);
158
207
  // Another caller may have finished its start before this one took the lock.
@@ -58,6 +58,7 @@ export declare class WebServer {
58
58
  setOnStepAside(callback: () => void | Promise<void>): void;
59
59
  /** Standalone web apps register this to stop or restart from the page. */
60
60
  setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
61
+ /** Only a local caller may stop the web app; the token alone is not enough. */
61
62
  private handlePowerAction;
62
63
  private scheduleStepAside;
63
64
  private handleStepAside;
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import { Readable } from "node:stream";
@@ -143,6 +144,8 @@ export function nextFallbackPort(currentPort, failedTakeovers, maxFallbackPort,
143
144
  const STEP_ASIDE_HOLD_OFF_MS = 60_000;
144
145
  /** Lets the 202 reply reach the caller before the server stops. */
145
146
  const STEP_ASIDE_REPLY_GRACE_MS = 100;
147
+ /** Differs between processes, so the page can tell a restarted web app from the old one. */
148
+ const PROCESS_INSTANCE = randomUUID();
146
149
  export class WebServer {
147
150
  server = null;
148
151
  config;
@@ -217,6 +220,7 @@ export class WebServer {
217
220
  setOnPowerAction(callback) {
218
221
  this.onPowerActionCallback = callback;
219
222
  }
223
+ /** Only a local caller may stop the web app; the token alone is not enough. */
220
224
  handlePowerAction(action, remoteAddress) {
221
225
  const record = (outcome) => log("Web server power request", { action, outcome, ownVersion: packageVersion() });
222
226
  if (!isLoopbackAddress(remoteAddress)) {
@@ -440,6 +444,8 @@ export class WebServer {
440
444
  clearTimeout(timer);
441
445
  this.stepAsideTimer = null;
442
446
  this.holdOffTimer = null;
447
+ // A restart whose copy failed calls start() again on this server.
448
+ this.startPromise = null;
443
449
  if (!this.isOwner || !this.server) {
444
450
  return;
445
451
  }
@@ -591,10 +597,12 @@ export class WebServer {
591
597
  ? controls.resume(host, cwd)
592
598
  : controls.runNow(host, cwd));
593
599
  }
600
+ // The page shows the power button only when this says the caller may control the web app.
594
601
  if (path === "/api/web/status" && method === "GET") {
595
602
  return this.jsonResponse({
596
603
  version: packageVersion(),
597
604
  canControl: isLoopbackAddress(remoteAddress) && this.onPowerActionCallback !== null,
605
+ instance: PROCESS_INSTANCE,
598
606
  });
599
607
  }
600
608
  if ((path === "/api/web/stop" || path === "/api/web/restart") && method === "POST") {