n2n-nexus 0.8.1 → 0.8.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.8.2] - 2026-10-22
6
+
7
+ ### Fixed
8
+
9
+ - Two daemons starting together on a fresh storage root: the loser no longer crashes with `SqliteError: database is locked` before reaching the port; database initialisation waits for the other process and the loser exits on `EADDRINUSE` as intended.
10
+ - Reads of the file store (`get_project`, `search_projects`, topology, global docs) share a read-write lock with the writes, so a read during `rename_project` no longer returns an entry with empty docs and tech stack.
11
+ - `sync_project_assets` and `update_project` write the manifest and docs first and the registry last, and restore the files they wrote when a later step fails; a failed create no longer leaves a project without a directory, and a failed update no longer mixes new and old fields.
12
+ - Tasks whose timestamps were written before 0.6.0 (`YYYY-MM-DD HH:MM:SS`) are converted to ISO 8601 on startup, so they are no longer purged up to a day early.
13
+ - `n2n-nexus uninstall` (and `install`) report a failing `mcp remove` when the server is still registered in the client's configuration, exit with code 1 and say what to do; "not registered" is still ignored.
14
+
5
15
  ## [0.8.1] - 2026-10-22
6
16
 
7
17
  ### Added
@@ -7,11 +7,10 @@
7
7
  */
8
8
  import { spawnSync } from "node:child_process";
9
9
  import fs from "node:fs";
10
- import os from "node:os";
11
- import path from "node:path";
12
10
  import { HOOK_CLIENTS, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
13
11
  import { pkg } from "../config/index.js";
14
- import { InstallError, VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
12
+ import { VERSIONED_NODE_DIR, configFile, isOurs, parseVersion, readConfig, satisfiesEngines } from "./install.js";
13
+ import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
15
14
  const ok = (text) => ({ ok: true, text });
16
15
  const bad = (text) => ({ ok: false, text });
17
16
  const VERSIONED_NOTE = "in a version directory that an upgrade may remove";
@@ -39,57 +38,6 @@ export function checkNode(node) {
39
38
  return ok(`${text}${VERSIONED_NODE_DIR.test(node) ? `, ${VERSIONED_NOTE}` : ""}`);
40
39
  }
41
40
  const checkEntry = (entry) => fs.existsSync(entry) ? ok("ok") : bad("missing");
42
- /** Where the client keeps the MCP server command. */
43
- export function mcpConfigFile(client) {
44
- if (client === "claude") {
45
- return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
46
- }
47
- return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
48
- }
49
- /** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
50
- export function readMcpCommand(client, file) {
51
- let text;
52
- try {
53
- text = fs.readFileSync(file, "utf8");
54
- }
55
- catch (error) {
56
- if (error.code === "ENOENT")
57
- return undefined;
58
- throw error;
59
- }
60
- if (client === "claude") {
61
- let parsed;
62
- try {
63
- parsed = JSON.parse(text);
64
- }
65
- catch (error) {
66
- throw new InstallError(`${file} is not valid JSON (${error.message}).`);
67
- }
68
- const servers = parsed?.mcpServers;
69
- const server = servers?.[MCP_SERVER_NAME];
70
- if (!server || typeof server.command !== "string")
71
- return undefined;
72
- return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
73
- }
74
- // Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
75
- const lines = text.split("\n");
76
- const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
77
- if (start === -1)
78
- return undefined;
79
- let command;
80
- let args = [];
81
- for (const line of lines.slice(start + 1)) {
82
- if (/^\s*\[/.test(line))
83
- break;
84
- const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
85
- if (c)
86
- command = JSON.parse(`"${c[1]}"`);
87
- const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
88
- if (a)
89
- args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
90
- }
91
- return command === undefined ? undefined : { command, args };
92
- }
93
41
  /** Check one client; prints the report and returns whether everything passed, or undefined when nothing is installed. */
94
42
  function doctorClient(client) {
95
43
  const hooksFile = configFile(client);
@@ -17,6 +17,7 @@ import path from "node:path";
17
17
  import { CLAUDE_PRE_TOOL_MATCHER, HOOK_STOP_TIMEOUT_SEC, HOOK_TIMEOUT_SEC, MCP_SERVER_NAME, isHookClient } from "../daemon/hook-contract.js";
18
18
  import { pkg } from "../config/index.js";
19
19
  import { entryScript } from "./autostart.js";
20
+ import { mcpConfigFile, readMcpCommand } from "./mcp-config.js";
20
21
  const EVENTS = {
21
22
  claude: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"],
22
23
  codex: ["SessionStart", "SessionEnd", "UserPromptSubmit", "PostToolUse", "Stop"]
@@ -187,6 +188,40 @@ function mcpAddArgs(client, node, entry, endpoint) {
187
188
  const scope = client === "claude" ? ["--scope", "user"] : [];
188
189
  return ["mcp", "add", ...scope, MCP_SERVER_NAME, ...env, "--", node, entry, "mcp"];
189
190
  }
191
+ /**
192
+ * Run `<client> mcp remove`. A failure is ignored only when `<client> mcp get`
193
+ * shows the server is not registered (never installed, or already removed);
194
+ * otherwise the removal really failed and we stop before `mcp add`.
195
+ */
196
+ function removeMcpServer(client, action, hooksRemoved) {
197
+ const removeArgs = mcpRemoveArgs(client);
198
+ try {
199
+ execFileSync(client, removeArgs, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] });
200
+ if (action === "uninstall")
201
+ process.stdout.write(`Ran: ${client} ${removeArgs.join(" ")}\n`);
202
+ return;
203
+ }
204
+ catch (error) {
205
+ // "Not registered" is the one failure to ignore; the registration is read from the
206
+ // client's configuration file (an unreadable file counts as still registered).
207
+ let registered = true;
208
+ try {
209
+ registered = readMcpCommand(client, mcpConfigFile(client)) !== undefined;
210
+ }
211
+ catch {
212
+ // Keep registered = true: the error below tells the user what to look at.
213
+ }
214
+ if (!registered)
215
+ return;
216
+ const failure = error;
217
+ const stderr = typeof failure.stderr === "string" ? failure.stderr.trim() : "";
218
+ const detail = stderr || (typeof failure.status === "number" ? `exit code ${failure.status}` : failure.message);
219
+ const state = action === "uninstall"
220
+ ? `${hooksRemoved ? "The hooks were removed; t" : "T"}he MCP server is still registered in ${mcpConfigFile(client)}.`
221
+ : "The hooks were written; the existing MCP server registration was left unchanged.";
222
+ throw new InstallError(`"${client} ${removeArgs.join(" ")}" failed: ${detail}\n${state} Fix the error and run "n2n-nexus ${action} ${client}" again.`);
223
+ }
224
+ }
190
225
  /** Install or uninstall; prints what it did and returns the exit code. */
191
226
  export function runInstall(options) {
192
227
  const { action, client, endpoint } = options;
@@ -211,15 +246,7 @@ export function runInstall(options) {
211
246
  writeConfig(file, config);
212
247
  process.stdout.write(`${action === "install" ? "Wrote hooks to" : "Removed hooks from"} ${file}\n`);
213
248
  }
214
- const removeArgs = mcpRemoveArgs(client);
215
- try {
216
- execFileSync(client, removeArgs, { stdio: "ignore" });
217
- if (action === "uninstall")
218
- process.stdout.write(`Ran: ${client} ${removeArgs.join(" ")}\n`);
219
- }
220
- catch {
221
- // Not registered yet (or already removed): nothing to undo.
222
- }
249
+ removeMcpServer(client, action, removed);
223
250
  if (action === "install" && node) {
224
251
  const addArgs = mcpAddArgs(client, node, entry, endpoint);
225
252
  execFileSync(client, addArgs, { stdio: ["ignore", "ignore", "inherit"] });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where Claude Code and Codex keep their MCP server registrations, and how to
3
+ * read the n2n-nexus entry: `~/.claude.json` (user scope) and
4
+ * `$CODEX_HOME/config.toml`. `claude mcp get` is not used for this because it
5
+ * looks in every scope, not only the user scope that install writes to, and
6
+ * it starts the server for a health check.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { MCP_SERVER_NAME } from "../daemon/hook-contract.js";
12
+ /** Where the client keeps the MCP server command. */
13
+ export function mcpConfigFile(client) {
14
+ if (client === "claude") {
15
+ return path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
16
+ }
17
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
18
+ }
19
+ /** The MCP server command the client has for n2n-nexus, or undefined when not registered. */
20
+ export function readMcpCommand(client, file) {
21
+ let text;
22
+ try {
23
+ text = fs.readFileSync(file, "utf8");
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT")
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ if (client === "claude") {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ }
35
+ catch (error) {
36
+ throw new Error(`${file} is not valid JSON (${error.message}).`, { cause: error });
37
+ }
38
+ const servers = parsed?.mcpServers;
39
+ const server = servers?.[MCP_SERVER_NAME];
40
+ if (!server || typeof server.command !== "string")
41
+ return undefined;
42
+ return { command: server.command, args: Array.isArray(server.args) ? server.args.map(String) : [] };
43
+ }
44
+ // Codex: a TOML table [mcp_servers.n2n-nexus] (the key may be quoted) with command = "..." and args = [...].
45
+ const lines = text.split("\n");
46
+ const start = lines.findIndex((line) => new RegExp(`^\\s*\\[mcp_servers\\.("?)${MCP_SERVER_NAME}\\1\\]\\s*$`).test(line));
47
+ if (start === -1)
48
+ return undefined;
49
+ let command;
50
+ let args = [];
51
+ for (const line of lines.slice(start + 1)) {
52
+ if (/^\s*\[/.test(line))
53
+ break;
54
+ const c = /^\s*command\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(line);
55
+ if (c)
56
+ command = JSON.parse(`"${c[1]}"`);
57
+ const a = /^\s*args\s*=\s*\[(.*)\]/.exec(line);
58
+ if (a)
59
+ args = [...a[1].matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`));
60
+ }
61
+ return command === undefined ? undefined : { command, args };
62
+ }
@@ -1,13 +1,14 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
- import { AsyncMutex } from "../utils/async-mutex.js";
3
+ import { AsyncRWLock } from "../utils/async-rwlock.js";
4
4
  import { FILE_ENCODING } from "../constants.js";
5
5
  /**
6
- * Serializes every file-store write inside the daemon. HTTP requests are
7
- * handled concurrently, so read-modify-write sequences on registry.json,
8
- * manifests and the docs index must be queued behind one lock.
6
+ * Guards the file store inside the daemon. HTTP requests are handled
7
+ * concurrently: writes take it exclusively, so read-modify-write sequences on
8
+ * registry.json, manifests and the docs index are queued; reads take it
9
+ * shared, so they never observe a multi-file write (such as a rename) halfway.
9
10
  */
10
- export const fileWriteLock = new AsyncMutex();
11
+ export const fileStoreLock = new AsyncRWLock();
11
12
  /**
12
13
  * Write to a temp file in the same directory, then rename over the target so
13
14
  * readers never observe a partially written file.
@@ -40,3 +41,48 @@ export function toDict(entries = []) {
40
41
  export function writeJsonAtomic(filePath, value) {
41
42
  return writeFileAtomic(filePath, JSON.stringify(value, null, 2));
42
43
  }
44
+ export async function snapshotFile(filePath) {
45
+ const dir = path.dirname(filePath);
46
+ let createsDir = false;
47
+ try {
48
+ await fs.access(dir);
49
+ }
50
+ catch (error) {
51
+ if (error.code !== "ENOENT")
52
+ throw error;
53
+ createsDir = true;
54
+ }
55
+ let original = null;
56
+ if (!createsDir) {
57
+ try {
58
+ original = await fs.readFile(filePath);
59
+ }
60
+ catch (error) {
61
+ if (error.code !== "ENOENT")
62
+ throw error;
63
+ }
64
+ }
65
+ return { path: filePath, original, createsDir };
66
+ }
67
+ /**
68
+ * Puts a file back as snapshotFile recorded it: original bytes rewritten,
69
+ * a file that did not exist removed, and a directory the write created
70
+ * removed with everything in it. Best effort: an undo failure is swallowed
71
+ * so the caller can rethrow the error that caused the rollback.
72
+ */
73
+ export async function restoreFile(snapshot) {
74
+ try {
75
+ if (snapshot.createsDir) {
76
+ await fs.rm(path.dirname(snapshot.path), { recursive: true, force: true });
77
+ }
78
+ else if (snapshot.original === null) {
79
+ await fs.rm(snapshot.path, { force: true });
80
+ }
81
+ else {
82
+ await writeFileAtomic(snapshot.path, snapshot.original);
83
+ }
84
+ }
85
+ catch {
86
+ // Best effort, see above.
87
+ }
88
+ }
@@ -4,7 +4,7 @@ import { NexusPaths } from "./paths.js";
4
4
  import { RegistryStorage } from "./registry.js";
5
5
  import { ProjectStorage } from "./projects.js";
6
6
  import { DocStorage } from "./docs.js";
7
- import { fileWriteLock, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
7
+ import { fileStoreLock, restoreFile, snapshotFile, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
8
8
  export const BLUEPRINT_PLACEHOLDER = "# Global Coordination Blueprint\n\nShared meeting space.";
9
9
  export class StorageManager {
10
10
  static initialized = false;
@@ -54,26 +54,51 @@ export class StorageManager {
54
54
  return false;
55
55
  }
56
56
  }
57
- // Every mutation of the file store goes through this lock; reads are
58
- // lock-free because writes are atomic (temp file + rename).
57
+ // Every mutation of the file store takes the lock exclusively and every
58
+ // public read takes it shared. Single files are written atomically (temp
59
+ // file + rename), but some writes span several files (a rename moves the
60
+ // directory before the registry switches ids), and a read between those
61
+ // steps would see a registry entry whose files are gone. Code inside
62
+ // write() or read() must call the sub-modules directly, never these locked
63
+ // StorageManager methods: the lock is not reentrant (a read inside a read
64
+ // deadlocks as soon as a writer is queued between them).
59
65
  static write(fn) {
60
- return fileWriteLock.withLock(fn);
66
+ return fileStoreLock.withWrite(fn);
67
+ }
68
+ static read(fn) {
69
+ return fileStoreLock.withRead(fn);
61
70
  }
62
71
  // --- Registry Methods ---
63
- static listRegistry() { return RegistryStorage.listRegistry(); }
64
- static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
72
+ static listRegistry() { return this.read(() => RegistryStorage.listRegistry()); }
73
+ static getProjectManifest(id) { return this.read(() => RegistryStorage.getProjectManifest(id)); }
65
74
  static saveProjectManifest(manifest) { return this.write(() => RegistryStorage.saveProjectManifest(manifest)); }
66
- /** Manifest and internal docs in one critical section, so a concurrent delete cannot interleave. */
75
+ /**
76
+ * Manifest and internal docs in one critical section, so a concurrent
77
+ * delete cannot interleave. Docs are written first and
78
+ * saveProjectManifest writes the registry last, so the registry only
79
+ * lists the project once all its files are in place. If anything fails,
80
+ * the docs file is put back to its original bytes (removed if it did not
81
+ * exist, with the project directory if this call created it) and the
82
+ * original error is rethrown; saveProjectManifest rolls back its own
83
+ * manifest write.
84
+ */
67
85
  static saveProject(manifest, docs) {
68
86
  return this.write(async () => {
69
- await RegistryStorage.saveProjectManifest(manifest);
70
- await ProjectStorage.saveProjectDocs(manifest.id, docs);
87
+ const snapshot = await snapshotFile(NexusPaths.projectDocPath(manifest.id));
88
+ try {
89
+ await ProjectStorage.saveProjectDocs(manifest.id, docs);
90
+ await RegistryStorage.saveProjectManifest(manifest);
91
+ }
92
+ catch (error) {
93
+ await restoreFile(snapshot);
94
+ throw error;
95
+ }
71
96
  });
72
97
  }
73
98
  static patchProjectManifest(id, patch) { return this.write(() => RegistryStorage.patchProjectManifest(id, patch)); }
74
- static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
99
+ static calculateTopology(projectId) { return this.read(() => RegistryStorage.calculateTopology(projectId)); }
75
100
  // --- Project Methods ---
76
- static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
101
+ static getProjectDocs(id) { return this.read(() => ProjectStorage.getProjectDocs(id)); }
77
102
  static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
78
103
  /**
79
104
  * The existence check runs inside the write lock: writeFileAtomic creates
@@ -107,10 +132,10 @@ export class StorageManager {
107
132
  });
108
133
  }
109
134
  // --- Doc Methods ---
110
- static listGlobalDocs() { return DocStorage.listDocs(); }
111
- static getGlobalDoc(docId) { return DocStorage.getDoc(docId); }
135
+ static listGlobalDocs() { return this.read(() => DocStorage.listDocs()); }
136
+ static getGlobalDoc(docId) { return this.read(() => DocStorage.getDoc(docId)); }
112
137
  static saveGlobalDoc(docId, title, content, updatedBy) { return this.write(() => DocStorage.saveDoc(docId, title, content, updatedBy)); }
113
138
  static deleteGlobalDoc(docId) { return this.write(() => DocStorage.deleteDoc(docId)); }
114
- static getGlobalBlueprint() { return DocStorage.getBlueprint(); }
139
+ static getGlobalBlueprint() { return this.read(() => DocStorage.getBlueprint()); }
115
140
  static saveGlobalBlueprint(content) { return this.write(() => DocStorage.saveBlueprint(content)); }
116
141
  }
@@ -1,6 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { NexusPaths } from "./paths.js";
3
- import { toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
3
+ import { restoreFile, snapshotFile, toDict, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
4
4
  const emptyRegistry = () => ({ projects: toDict() });
5
5
  async function readRegistry() {
6
6
  try {
@@ -91,8 +91,14 @@ export class RegistryStorage {
91
91
  static async listRegistry() {
92
92
  return readRegistry();
93
93
  }
94
+ /**
95
+ * Creates or replaces a project. Order: manifest first, registry last, so
96
+ * the registry never lists a project whose manifest was not written. If
97
+ * the registry write fails, the manifest is put back to its original
98
+ * bytes (removed if it did not exist, together with the project directory
99
+ * if this call created it) and the original error is rethrown.
100
+ */
94
101
  static async saveProjectManifest(manifest) {
95
- const projectDir = NexusPaths.projectDir(manifest.id);
96
102
  const manifestPath = NexusPaths.projectManifestPath(manifest.id);
97
103
  const registry = await readRegistry();
98
104
  registry.projects[manifest.id] = {
@@ -100,9 +106,15 @@ export class RegistryStorage {
100
106
  summary: manifest.description || "",
101
107
  lastActive: new Date().toISOString()
102
108
  };
103
- await writeRegistry(registry);
104
- await fs.mkdir(projectDir, { recursive: true });
109
+ const snapshot = await snapshotFile(manifestPath);
105
110
  await writeJsonAtomic(manifestPath, { ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() });
111
+ try {
112
+ await writeRegistry(registry);
113
+ }
114
+ catch (error) {
115
+ await restoreFile(snapshot);
116
+ throw error;
117
+ }
106
118
  }
107
119
  static async getProjectManifest(id) {
108
120
  const registry = await readRegistry();
@@ -128,6 +140,11 @@ export class RegistryStorage {
128
140
  feature_tier: m.feature_tier
129
141
  };
130
142
  }
143
+ /**
144
+ * Applies a patch to the manifest and the registry entry. Same order and
145
+ * rollback as saveProjectManifest: manifest first, registry last; if the
146
+ * registry write fails the manifest is restored to its original bytes.
147
+ */
131
148
  static async patchProjectManifest(id, patch) {
132
149
  const registry = await readRegistry();
133
150
  const existing = registry.projects[id];
@@ -139,7 +156,8 @@ export class RegistryStorage {
139
156
  name: patch.name ?? existing.name,
140
157
  lastActive: new Date().toISOString()
141
158
  };
142
- await writeRegistry(registry);
159
+ const manifestPath = NexusPaths.projectManifestPath(id);
160
+ const snapshot = await snapshotFile(manifestPath);
143
161
  const manifest = await readProjectManifest(id) || {};
144
162
  const patchedManifest = {
145
163
  id,
@@ -158,7 +176,14 @@ export class RegistryStorage {
158
176
  api_versions: patch.api_versions ?? manifest.api_versions,
159
177
  feature_tier: patch.feature_tier ?? manifest.feature_tier
160
178
  };
161
- await writeJsonAtomic(NexusPaths.projectManifestPath(id), patchedManifest);
179
+ await writeJsonAtomic(manifestPath, patchedManifest);
180
+ try {
181
+ await writeRegistry(registry);
182
+ }
183
+ catch (error) {
184
+ await restoreFile(snapshot);
185
+ throw error;
186
+ }
162
187
  }
163
188
  static async calculateTopology(projectId) {
164
189
  const registry = await readRegistry();
@@ -162,19 +162,64 @@ CREATE INDEX IF NOT EXISTS idx_tool_use_bindings_created ON tool_use_bindings(cr
162
162
  export function getDbPath() {
163
163
  return NexusPaths.dbFile;
164
164
  }
165
+ /** Longest a first start waits for another process that is initialising the same database. */
166
+ const INIT_BUSY_WAIT_MS = 10_000;
167
+ const INIT_RETRY_DELAY_MS = 50;
168
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
169
+ const isBusy = (error) => error?.code === "SQLITE_BUSY";
165
170
  /**
166
- * Initialize the SQLite database with WAL mode
171
+ * Open the database, switch it to WAL and create the schema. Throws SQLITE_BUSY
172
+ * (from the pragma or the schema statements) while another process is writing.
167
173
  */
168
- export function initDatabase() {
174
+ function openDatabase(dbPath) {
175
+ const candidate = new Database(dbPath);
176
+ try {
177
+ // WAL for better concurrent access. The pragma answers with the mode in effect;
178
+ // anything but "wal" (an in-memory database, a VFS without shared memory) would
179
+ // leave the daemon in rollback-journal mode, which the startup race cannot survive.
180
+ const mode = candidate.pragma("journal_mode = WAL", { simple: true });
181
+ if (mode !== "wal")
182
+ throw new Error(`WAL mode is not available for ${dbPath}: journal_mode stayed "${mode}".`);
183
+ candidate.pragma("foreign_keys = ON");
184
+ candidate.exec(SCHEMA);
185
+ return candidate;
186
+ }
187
+ catch (error) {
188
+ candidate.close();
189
+ throw error;
190
+ }
191
+ }
192
+ /**
193
+ * Initialize the SQLite database with WAL mode.
194
+ *
195
+ * Two daemons starting on a fresh root reach this together. SQLite answers the
196
+ * second one with SQLITE_BUSY at once (not through the busy timeout) while the
197
+ * first holds its write lock in rollback-journal mode, so the open is retried
198
+ * until the other process has finished; no retry starts after `busyWaitMs`.
199
+ */
200
+ export function initDatabase(options = {}) {
169
201
  if (db)
170
202
  return db;
171
203
  const dbPath = getDbPath();
172
- db = new Database(dbPath);
173
- // Enable WAL mode for better concurrent access
174
- db.pragma("journal_mode = WAL");
175
- db.pragma("foreign_keys = ON");
176
- // Initialize schema
177
- db.exec(SCHEMA);
204
+ const started = Date.now();
205
+ const deadline = started + (options.busyWaitMs ?? INIT_BUSY_WAIT_MS);
206
+ let opened;
207
+ for (;;) {
208
+ try {
209
+ opened = openDatabase(dbPath);
210
+ break;
211
+ }
212
+ catch (error) {
213
+ if (!isBusy(error))
214
+ throw error;
215
+ if (Date.now() >= deadline) {
216
+ error.message += ` (gave up after ${Date.now() - started} ms waiting for another process initialising ${dbPath})`;
217
+ throw error;
218
+ }
219
+ sleepSync(INIT_RETRY_DELAY_MS);
220
+ }
221
+ }
222
+ db = opened;
178
223
  // Migration: Add initiator column if it doesn't exist (Upgrade from v0.1.7)
179
224
  try {
180
225
  const columns = db.prepare("PRAGMA table_info(meetings)").all();
@@ -187,6 +232,21 @@ export function initDatabase() {
187
232
  catch (e) {
188
233
  console.error("[Nexus] Migration check failed:", e);
189
234
  }
235
+ // Migration: Convert task timestamps written by the pre-0.6.0 trigger and
236
+ // column defaults (datetime('now'), 'YYYY-MM-DD HH:MM:SS') to ISO 8601, so
237
+ // string comparisons against new Date().toISOString() order correctly.
238
+ try {
239
+ for (const column of ["created_at", "updated_at"]) {
240
+ const { changes } = db.prepare(`UPDATE tasks SET ${column} = strftime('%Y-%m-%dT%H:%M:%fZ', ${column})
241
+ WHERE ${column} NOT LIKE '%T%' AND strftime('%Y-%m-%dT%H:%M:%fZ', ${column}) IS NOT NULL`).run();
242
+ if (changes > 0) {
243
+ console.error(`[Nexus] Migrating database: Converted ${changes} 'tasks.${column}' value(s) to ISO 8601.`);
244
+ }
245
+ }
246
+ }
247
+ catch (e) {
248
+ console.error("[Nexus] Migration check failed:", e);
249
+ }
190
250
  // Initialize default state if not exists
191
251
  const stmt = db.prepare("INSERT OR IGNORE INTO meeting_state (key, value) VALUES (?, ?)");
192
252
  stmt.run("active_meetings", "[]");
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Readers-writer lock for async code. Any number of readers run together; a
3
+ * writer runs alone. Waiters are served in FIFO order and writers take
4
+ * priority: once a writer is queued, readers that arrive later queue behind
5
+ * it instead of joining the readers already running, so a steady stream of
6
+ * reads cannot starve a write.
7
+ */
8
+ export class AsyncRWLock {
9
+ readers = 0;
10
+ writing = false;
11
+ queue = [];
12
+ acquire(write) {
13
+ // A reader may join running readers only while nothing is queued (a queued entry is either a writer or a reader behind one).
14
+ const free = write ? !this.writing && this.readers === 0 : !this.writing && this.queue.length === 0;
15
+ if (free) {
16
+ if (write)
17
+ this.writing = true;
18
+ else
19
+ this.readers++;
20
+ return Promise.resolve();
21
+ }
22
+ return new Promise((resolve) => this.queue.push({ write, resolve }));
23
+ }
24
+ release(write) {
25
+ if (write)
26
+ this.writing = false;
27
+ else
28
+ this.readers--;
29
+ if (this.writing || this.readers > 0)
30
+ return;
31
+ // Lock is free: wake the next writer alone, or every reader up to the next queued writer.
32
+ const next = this.queue[0];
33
+ if (!next)
34
+ return;
35
+ if (next.write) {
36
+ this.queue.shift();
37
+ this.writing = true;
38
+ next.resolve();
39
+ return;
40
+ }
41
+ while (this.queue.length > 0 && !this.queue[0].write) {
42
+ this.readers++;
43
+ this.queue.shift().resolve();
44
+ }
45
+ }
46
+ async withRead(fn) {
47
+ await this.acquire(false);
48
+ try {
49
+ return await fn();
50
+ }
51
+ finally {
52
+ this.release(false);
53
+ }
54
+ }
55
+ async withWrite(fn) {
56
+ await this.acquire(true);
57
+ try {
58
+ return await fn();
59
+ }
60
+ finally {
61
+ this.release(true);
62
+ }
63
+ }
64
+ }
@@ -92,7 +92,7 @@ All data lives under the daemon's storage root (default `~/.n2n-nexus`):
92
92
 
93
93
  **SQLite WAL mode**: Only the daemon process opens the database. MCP adapters never touch it — they go through HTTP.
94
94
 
95
- **File-store writes**: HTTP requests are handled concurrently, so every mutation of `registry.json`, manifests and the docs index is queued behind one daemon-wide lock and written atomically (temp file + rename).
95
+ **File-store access**: HTTP requests are handled concurrently, so every mutation of `registry.json`, manifests and the docs index holds one daemon-wide read-write lock alone and is written atomically (temp file + rename); reads share the lock, so a rename or update is never observed half-done. A write orders its files so that `registry.json` goes last and restores what it had already written when a later step fails.
96
96
 
97
97
  **Activity log**: state changes made through the daemon (project sync/rename/delete, meeting start/end, doc sync, ...) are recorded as structured events in SQLite and read with `list_events` / `GET /api/events`; they are not mixed into chat streams.
98
98
 
@@ -92,7 +92,7 @@ Stop ◀── decision: block ◀─┘ 每次结
92
92
 
93
93
  **SQLite WAL 模式**:只有 daemon 进程打开数据库。MCP 适配器不直接访问,全部经由 HTTP。
94
94
 
95
- **文件存储写入**:HTTP 请求是并发处理的,因此对 `registry.json`、manifest 和文档索引的每次修改都排在一把 daemon 级写锁后面,并以原子方式写入(临时文件 + rename)。
95
+ **文件存储访问**:HTTP 请求是并发处理的,因此对 `registry.json`、manifest 和文档索引的每次修改都独占一把 daemon 级读写锁,并以原子方式写入(临时文件 + rename);读取共享这把锁,所以不会读到改名或更新进行到一半的状态。写入时各文件按 `registry.json` 最后写的顺序进行,后一步失败就还原已写的文件。
96
96
 
97
97
  **活动日志**:经由 daemon 发生的状态变更(项目同步/重命名/删除、会议开始/结束、文档同步等)以结构化事件记录在 SQLite 中,通过 `list_events` / `GET /api/events` 读取,不混入聊天流。
98
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n2n-nexus",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local-first MCP coordination hub from N2NS Lab for multi-AI assistant collaboration.",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -1,36 +0,0 @@
1
- /**
2
- * Simple mutex lock for preventing concurrent file writes.
3
- * Ensures that only one write operation can happen at a time.
4
- */
5
- export class AsyncMutex {
6
- locked = false;
7
- queue = [];
8
- async acquire() {
9
- if (!this.locked) {
10
- this.locked = true;
11
- return;
12
- }
13
- return new Promise((resolve) => {
14
- this.queue.push(resolve);
15
- });
16
- }
17
- release() {
18
- if (this.queue.length > 0) {
19
- const next = this.queue.shift();
20
- if (next)
21
- next();
22
- }
23
- else {
24
- this.locked = false;
25
- }
26
- }
27
- async withLock(fn) {
28
- await this.acquire();
29
- try {
30
- return await fn();
31
- }
32
- finally {
33
- this.release();
34
- }
35
- }
36
- }