n2n-nexus 0.4.2 → 0.7.0
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 +114 -0
- package/README.md +77 -117
- package/assets/n2n-nexus-banner.jpg +0 -0
- package/assets/n2n-nexus-icon.png +0 -0
- package/assets/n2n-nexus-logo.png +0 -0
- package/build/client/nexus-client.js +44 -21
- package/build/config/cli.js +0 -3
- package/build/config/index.js +0 -10
- package/build/constants.js +7 -11
- package/build/daemon/index.js +35 -19
- package/build/daemon/rate-limit.js +62 -0
- package/build/daemon/server.js +422 -545
- package/build/daemon/tool-outputs.js +177 -0
- package/build/daemon/tools.js +321 -0
- package/build/index.js +1 -1
- package/build/server/instance-id.js +19 -0
- package/build/server/nexus.js +243 -47
- package/build/storage/direct-messages.js +89 -0
- package/build/storage/docs.js +14 -7
- package/build/storage/events.js +31 -0
- package/build/storage/fs-utils.js +42 -0
- package/build/storage/global-messages.js +44 -0
- package/build/storage/index.js +47 -41
- package/build/storage/migrations.js +86 -0
- package/build/storage/paths.js +0 -3
- package/build/storage/projects.js +4 -5
- package/build/storage/registry.js +67 -25
- package/build/storage/sessions.js +77 -0
- package/build/storage/sqlite-meeting.js +35 -23
- package/build/storage/sqlite.js +73 -24
- package/build/storage/tasks.js +29 -103
- package/build/types.js +1 -1
- package/docs/ARCHITECTURE.md +36 -25
- package/docs/ARCHITECTURE_zh.md +37 -26
- package/docs/ASSISTANT_GUIDE.md +77 -65
- package/docs/README_zh.md +34 -19
- package/llms.txt +12 -2
- package/package.json +24 -14
- package/build/storage/logs.js +0 -60
- package/build/storage/meetings.js +0 -276
- package/build/storage/store.js +0 -153
package/build/storage/index.js
CHANGED
|
@@ -3,38 +3,28 @@ import { FILE_ENCODING } from "../constants.js";
|
|
|
3
3
|
import { NexusPaths } from "./paths.js";
|
|
4
4
|
import { RegistryStorage } from "./registry.js";
|
|
5
5
|
import { ProjectStorage } from "./projects.js";
|
|
6
|
-
import { LogStorage } from "./logs.js";
|
|
7
6
|
import { DocStorage } from "./docs.js";
|
|
7
|
+
import { fileWriteLock, writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
|
|
8
|
+
export const BLUEPRINT_PLACEHOLDER = "# Global Coordination Blueprint\n\nShared meeting space.";
|
|
8
9
|
export class StorageManager {
|
|
9
10
|
static initialized = false;
|
|
10
11
|
// --- Path Proxies (Backward Compatibility) ---
|
|
11
12
|
static get globalDir() { return NexusPaths.globalDir; }
|
|
12
13
|
static get globalBlueprint() { return NexusPaths.globalBlueprint; }
|
|
13
|
-
static get globalDiscussion() { return NexusPaths.globalDiscussion; }
|
|
14
14
|
static get projectsRoot() { return NexusPaths.projectsRoot; }
|
|
15
15
|
static get registryFile() { return NexusPaths.registryFile; }
|
|
16
|
-
static get archivesDir() { return NexusPaths.archivesDir; }
|
|
17
16
|
static async init() {
|
|
18
17
|
if (this.initialized)
|
|
19
18
|
return;
|
|
20
19
|
await fs.mkdir(NexusPaths.root, { recursive: true });
|
|
21
20
|
await fs.mkdir(this.globalDir, { recursive: true });
|
|
22
21
|
await fs.mkdir(this.projectsRoot, { recursive: true });
|
|
23
|
-
await fs.mkdir(this.archivesDir, { recursive: true });
|
|
24
22
|
// Self-healing initialization for critical files
|
|
25
23
|
await this.loadJsonSafe(this.registryFile, { projects: {} });
|
|
26
|
-
await this.loadJsonSafe(this.globalDiscussion, []);
|
|
27
24
|
if (!await this.exists(this.globalBlueprint)) {
|
|
28
|
-
await
|
|
25
|
+
await writeFileAtomic(this.globalBlueprint, BLUEPRINT_PLACEHOLDER);
|
|
29
26
|
}
|
|
30
27
|
await DocStorage.init();
|
|
31
|
-
try {
|
|
32
|
-
const { initTasksTable } = await import("./tasks.js");
|
|
33
|
-
initTasksTable();
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
/* tasks module is optional */
|
|
37
|
-
}
|
|
38
28
|
this.initialized = true;
|
|
39
29
|
}
|
|
40
30
|
static resetInit() {
|
|
@@ -43,7 +33,7 @@ export class StorageManager {
|
|
|
43
33
|
static async loadJsonSafe(filePath, defaultValue) {
|
|
44
34
|
try {
|
|
45
35
|
if (!await this.exists(filePath)) {
|
|
46
|
-
await
|
|
36
|
+
await writeJsonAtomic(filePath, defaultValue);
|
|
47
37
|
return defaultValue;
|
|
48
38
|
}
|
|
49
39
|
const content = await fs.readFile(filePath, FILE_ENCODING);
|
|
@@ -51,7 +41,7 @@ export class StorageManager {
|
|
|
51
41
|
}
|
|
52
42
|
catch {
|
|
53
43
|
console.warn(`[Nexus Storage] Repairing corrupted file: ${filePath}`);
|
|
54
|
-
await
|
|
44
|
+
await writeJsonAtomic(filePath, defaultValue);
|
|
55
45
|
return defaultValue;
|
|
56
46
|
}
|
|
57
47
|
}
|
|
@@ -64,42 +54,58 @@ export class StorageManager {
|
|
|
64
54
|
return false;
|
|
65
55
|
}
|
|
66
56
|
}
|
|
57
|
+
// Every mutation of the file store goes through this lock; reads are
|
|
58
|
+
// lock-free because writes are atomic (temp file + rename).
|
|
59
|
+
static write(fn) {
|
|
60
|
+
return fileWriteLock.withLock(fn);
|
|
61
|
+
}
|
|
67
62
|
// --- Registry Methods ---
|
|
68
63
|
static listRegistry() { return RegistryStorage.listRegistry(); }
|
|
69
64
|
static getProjectManifest(id) { return RegistryStorage.getProjectManifest(id); }
|
|
70
|
-
static saveProjectManifest(manifest) { return RegistryStorage.saveProjectManifest(manifest); }
|
|
71
|
-
|
|
65
|
+
static saveProjectManifest(manifest) { return this.write(() => RegistryStorage.saveProjectManifest(manifest)); }
|
|
66
|
+
/** Manifest and internal docs in one critical section, so a concurrent delete cannot interleave. */
|
|
67
|
+
static saveProject(manifest, docs) {
|
|
68
|
+
return this.write(async () => {
|
|
69
|
+
await RegistryStorage.saveProjectManifest(manifest);
|
|
70
|
+
await ProjectStorage.saveProjectDocs(manifest.id, docs);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
static patchProjectManifest(id, patch) { return this.write(() => RegistryStorage.patchProjectManifest(id, patch)); }
|
|
72
74
|
static calculateTopology(projectId) { return RegistryStorage.calculateTopology(projectId); }
|
|
73
75
|
// --- Project Methods ---
|
|
74
76
|
static getProjectDocs(id) { return ProjectStorage.getProjectDocs(id); }
|
|
75
|
-
static saveProjectDocs(id, content) { return ProjectStorage.saveProjectDocs(id, content); }
|
|
76
|
-
static saveAsset(id, fileName, content) { return ProjectStorage.saveAsset(id, fileName, content); }
|
|
77
|
-
static
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
static saveProjectDocs(id, content) { return this.write(() => ProjectStorage.saveProjectDocs(id, content)); }
|
|
78
|
+
static saveAsset(id, fileName, content) { return this.write(() => ProjectStorage.saveAsset(id, fileName, content)); }
|
|
79
|
+
static deleteProject(id) {
|
|
80
|
+
return this.write(async () => {
|
|
81
|
+
await ProjectStorage.deleteProject(id);
|
|
82
|
+
await RegistryStorage.deleteProject(id);
|
|
83
|
+
});
|
|
80
84
|
}
|
|
81
|
-
static
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
static renameProject(oldId, newId) {
|
|
86
|
+
return this.write(async () => {
|
|
87
|
+
if (!await RegistryStorage.getProjectManifest(oldId))
|
|
88
|
+
throw new Error(`Project '${oldId}' not found.`);
|
|
89
|
+
const renamed = await RegistryStorage.renameProject(oldId, newId);
|
|
90
|
+
if (renamed === 0)
|
|
91
|
+
throw new Error(`Project '${oldId}' could not be renamed.`);
|
|
92
|
+
try {
|
|
93
|
+
const result = await ProjectStorage.renameProject(oldId, newId);
|
|
94
|
+
if (result === 0)
|
|
95
|
+
throw new Error(`Project '${oldId}' could not be renamed.`);
|
|
96
|
+
return result;
|
|
87
97
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
await RegistryStorage.renameProject(newId, oldId).catch(() => { });
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
94
103
|
}
|
|
95
|
-
// --- Log Methods ---
|
|
96
|
-
static addGlobalLog(from, text, category) { return LogStorage.addLog(from, text, category); }
|
|
97
|
-
static getRecentLogs(count = 10) { return LogStorage.getLogs(count); }
|
|
98
|
-
static pruneGlobalLogs(count) { return LogStorage.pruneLogs(count); }
|
|
99
|
-
static clearGlobalLogs() { return LogStorage.clearLogs(); }
|
|
100
104
|
// --- Doc Methods ---
|
|
101
105
|
static listGlobalDocs() { return DocStorage.listDocs(); }
|
|
102
106
|
static getGlobalDoc(docId) { return DocStorage.getDoc(docId); }
|
|
103
|
-
static saveGlobalDoc(docId, title, content, updatedBy) { return DocStorage.saveDoc(docId, title, content, updatedBy); }
|
|
104
|
-
static deleteGlobalDoc(docId) { return DocStorage.deleteDoc(docId); }
|
|
107
|
+
static saveGlobalDoc(docId, title, content, updatedBy) { return this.write(() => DocStorage.saveDoc(docId, title, content, updatedBy)); }
|
|
108
|
+
static deleteGlobalDoc(docId) { return this.write(() => DocStorage.deleteDoc(docId)); }
|
|
109
|
+
static getGlobalBlueprint() { return DocStorage.getBlueprint(); }
|
|
110
|
+
static saveGlobalBlueprint(content) { return this.write(() => DocStorage.saveBlueprint(content)); }
|
|
105
111
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { NexusPaths } from "./paths.js";
|
|
4
|
+
import { GlobalMessageStore } from "./global-messages.js";
|
|
5
|
+
import { DocStorage } from "./docs.js";
|
|
6
|
+
import { BLUEPRINT_PLACEHOLDER } from "./index.js";
|
|
7
|
+
import { getDatabase } from "./sqlite.js";
|
|
8
|
+
import { MESSAGE_CATEGORIES } from "../types.js";
|
|
9
|
+
import { FILE_ENCODING } from "../constants.js";
|
|
10
|
+
/**
|
|
11
|
+
* One-time upgrades of data written by earlier releases. Each step is
|
|
12
|
+
* idempotent: migrated artifacts are retired or completion is persisted.
|
|
13
|
+
*/
|
|
14
|
+
/** 0.5.0 kept the global chat in global/discussion.json; import it into SQLite. */
|
|
15
|
+
export async function importLegacyDiscussion() {
|
|
16
|
+
const file = path.join(NexusPaths.globalDir, "discussion.json");
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = await fs.readFile(file, FILE_ENCODING);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (GlobalMessageStore.count() > 0)
|
|
25
|
+
return 0;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
console.warn(`[n2n-nexus] Skipping unreadable legacy chat log: ${file}`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(entries))
|
|
35
|
+
return 0;
|
|
36
|
+
const insert = getDatabase().prepare("INSERT INTO global_messages (sender, text, category, timestamp) VALUES (?, ?, ?, ?)");
|
|
37
|
+
const imported = getDatabase().transaction((rows) => {
|
|
38
|
+
let n = 0;
|
|
39
|
+
for (const row of rows) {
|
|
40
|
+
if (!row || typeof row !== "object")
|
|
41
|
+
continue;
|
|
42
|
+
const e = row;
|
|
43
|
+
if (typeof e.text !== "string")
|
|
44
|
+
continue;
|
|
45
|
+
const category = MESSAGE_CATEGORIES.includes(String(e.category)) ? e.category : null;
|
|
46
|
+
insert.run(typeof e.from === "string" && e.from ? e.from : "unknown", e.text, category, typeof e.timestamp === "string" && e.timestamp ? e.timestamp : new Date().toISOString());
|
|
47
|
+
n++;
|
|
48
|
+
}
|
|
49
|
+
return n;
|
|
50
|
+
})(entries);
|
|
51
|
+
await fs.rename(file, `${file}.migrated`);
|
|
52
|
+
console.error(`[n2n-nexus] Imported ${imported} legacy global messages from discussion.json`);
|
|
53
|
+
return imported;
|
|
54
|
+
}
|
|
55
|
+
/** Title 0.5.0's update_global_strategy gave the doc it wrote; a user doc with another title is left alone. */
|
|
56
|
+
export const LEGACY_STRATEGY_TITLE = "Global Collaboration Strategy";
|
|
57
|
+
const LEGACY_STRATEGY_MIGRATION = "migration:legacy_strategy";
|
|
58
|
+
/**
|
|
59
|
+
* 0.5.0's update_global_strategy saved the strategy as the global doc
|
|
60
|
+
* "strategy"; it now lives in global/blueprint.md. Move it there unless the
|
|
61
|
+
* blueprint has already been written. Record completion even when there
|
|
62
|
+
* is nothing to move, so later user documents cannot become legacy data.
|
|
63
|
+
*/
|
|
64
|
+
export async function importLegacyStrategy() {
|
|
65
|
+
const db = getDatabase();
|
|
66
|
+
if (db.prepare("SELECT 1 FROM meeting_state WHERE key = ?").get(LEGACY_STRATEGY_MIGRATION))
|
|
67
|
+
return false;
|
|
68
|
+
const docs = await DocStorage.listDocs();
|
|
69
|
+
let moved = false;
|
|
70
|
+
if (docs.strategy?.title === LEGACY_STRATEGY_TITLE) {
|
|
71
|
+
const legacy = await DocStorage.getDoc("strategy");
|
|
72
|
+
const current = await DocStorage.getBlueprint();
|
|
73
|
+
if (legacy && (!current || current === BLUEPRINT_PLACEHOLDER)) {
|
|
74
|
+
await DocStorage.saveBlueprint(legacy);
|
|
75
|
+
await DocStorage.deleteDoc("strategy");
|
|
76
|
+
moved = true;
|
|
77
|
+
console.error("[n2n-nexus] Moved legacy global doc 'strategy' into global/blueprint.md");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
db.prepare("INSERT INTO meeting_state (key, value) VALUES (?, ?)").run(LEGACY_STRATEGY_MIGRATION, "complete");
|
|
81
|
+
return moved;
|
|
82
|
+
}
|
|
83
|
+
export async function runMigrations() {
|
|
84
|
+
await importLegacyDiscussion();
|
|
85
|
+
await importLegacyStrategy();
|
|
86
|
+
}
|
package/build/storage/paths.js
CHANGED
|
@@ -4,10 +4,8 @@ export class NexusPaths {
|
|
|
4
4
|
static get root() { return getRootPath(); }
|
|
5
5
|
static get globalDir() { return path.join(this.root, "global"); }
|
|
6
6
|
static get globalBlueprint() { return path.join(this.globalDir, "blueprint.md"); }
|
|
7
|
-
static get globalDiscussion() { return path.join(this.globalDir, "discussion.json"); }
|
|
8
7
|
static get projectsRoot() { return path.join(this.root, "projects"); }
|
|
9
8
|
static get registryFile() { return path.join(this.root, "registry.json"); }
|
|
10
|
-
static get archivesDir() { return path.join(this.root, "archives"); }
|
|
11
9
|
static assertSafeSegment(value, label) {
|
|
12
10
|
if (!/^[a-zA-Z0-9._-]+$/.test(value) || value === "." || value === "..") {
|
|
13
11
|
throw new Error(`Invalid ${label}: '${value}'. Use only letters, numbers, dots, underscores, and hyphens.`);
|
|
@@ -19,7 +17,6 @@ export class NexusPaths {
|
|
|
19
17
|
static projectManifestPath(id) { return path.join(this.projectDir(id), "manifest.json"); }
|
|
20
18
|
static projectAssetsDir(id) { return path.join(this.projectDir(id), "assets"); }
|
|
21
19
|
static get dbFile() { return path.join(this.root, "nexus.db"); }
|
|
22
|
-
static taskFile() { return path.join(this.root, "tasks.json"); }
|
|
23
20
|
static docsDir() { return path.join(this.globalDir, "docs"); }
|
|
24
21
|
static get docsIndexFile() { return path.join(this.globalDir, "docs_index.json"); }
|
|
25
22
|
static docFile(docId) { return path.join(this.docsDir(), `${this.assertSafeSegment(docId, "document id")}.md`); }
|
|
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { NexusPaths } from "./paths.js";
|
|
4
4
|
import { FILE_ENCODING } from "../constants.js";
|
|
5
|
+
import { writeFileAtomic, writeJsonAtomic } from "./fs-utils.js";
|
|
5
6
|
export class ProjectStorage {
|
|
6
7
|
static projectFilePath(projectId) {
|
|
7
8
|
return {
|
|
@@ -22,15 +23,13 @@ export class ProjectStorage {
|
|
|
22
23
|
}
|
|
23
24
|
static async saveProjectDocs(projectId, content) {
|
|
24
25
|
const paths = this.projectFilePath(projectId);
|
|
25
|
-
await
|
|
26
|
-
await fs.writeFile(paths.docsPath, content, FILE_ENCODING);
|
|
26
|
+
await writeFileAtomic(paths.docsPath, content);
|
|
27
27
|
}
|
|
28
28
|
static async saveAsset(projectId, fileName, content) {
|
|
29
29
|
const safeName = this.sanitizeFileName(fileName);
|
|
30
30
|
const paths = this.projectFilePath(projectId);
|
|
31
|
-
await fs.mkdir(paths.assetsDir, { recursive: true });
|
|
32
31
|
const assetPath = path.join(paths.assetsDir, safeName);
|
|
33
|
-
await
|
|
32
|
+
await writeFileAtomic(assetPath, content);
|
|
34
33
|
return path.relative(NexusPaths.root, assetPath);
|
|
35
34
|
}
|
|
36
35
|
static async deleteProject(projectId) {
|
|
@@ -60,7 +59,7 @@ export class ProjectStorage {
|
|
|
60
59
|
const manifestRaw = await fs.readFile(newPath.manifestPath, FILE_ENCODING);
|
|
61
60
|
const manifest = JSON.parse(manifestRaw);
|
|
62
61
|
manifest.id = newId;
|
|
63
|
-
await
|
|
62
|
+
await writeJsonAtomic(newPath.manifestPath, manifest);
|
|
64
63
|
}
|
|
65
64
|
catch {
|
|
66
65
|
// Manifest is optional for some flows.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { NexusPaths } from "./paths.js";
|
|
3
|
-
|
|
3
|
+
import { toDict, writeJsonAtomic } from "./fs-utils.js";
|
|
4
|
+
const emptyRegistry = () => ({ projects: toDict() });
|
|
4
5
|
async function readRegistry() {
|
|
5
6
|
try {
|
|
6
7
|
const raw = await fs.readFile(NexusPaths.registryFile, "utf-8");
|
|
@@ -8,17 +9,17 @@ async function readRegistry() {
|
|
|
8
9
|
return sanitizeRegistry(parsed);
|
|
9
10
|
}
|
|
10
11
|
catch {
|
|
11
|
-
return
|
|
12
|
+
return emptyRegistry();
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
function sanitizeRegistry(data) {
|
|
15
16
|
if (!data || typeof data !== "object")
|
|
16
|
-
return
|
|
17
|
+
return emptyRegistry();
|
|
17
18
|
const root = data;
|
|
18
19
|
if (!root.projects || typeof root.projects !== "object")
|
|
19
|
-
return
|
|
20
|
+
return emptyRegistry();
|
|
20
21
|
return {
|
|
21
|
-
projects:
|
|
22
|
+
projects: toDict(Object.entries(root.projects).map(([id, item]) => {
|
|
22
23
|
if (!item || typeof item !== "object") {
|
|
23
24
|
return [id, {
|
|
24
25
|
summary: "",
|
|
@@ -35,8 +36,7 @@ function sanitizeRegistry(data) {
|
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
38
|
async function writeRegistry(data) {
|
|
38
|
-
await
|
|
39
|
-
await fs.writeFile(NexusPaths.registryFile, JSON.stringify(data, null, 2), "utf-8");
|
|
39
|
+
await writeJsonAtomic(NexusPaths.registryFile, data);
|
|
40
40
|
}
|
|
41
41
|
async function readProjectManifest(id) {
|
|
42
42
|
try {
|
|
@@ -47,6 +47,46 @@ async function readProjectManifest(id) {
|
|
|
47
47
|
return null;
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
const RELATION_TYPES = ["dependency", "parent", "child", "related"];
|
|
51
|
+
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
52
|
+
const objects = (v) => Array.isArray(v) ? v.filter((x) => !!x && typeof x === "object" && !Array.isArray(x)) : [];
|
|
53
|
+
const stringRecord = (v) => {
|
|
54
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
55
|
+
return undefined;
|
|
56
|
+
return Object.fromEntries(Object.entries(v).filter(([, x]) => typeof x === "string"));
|
|
57
|
+
};
|
|
58
|
+
/** Relations with a string targetId; an unknown or missing type reads as "related". */
|
|
59
|
+
export function normalizeRelations(v) {
|
|
60
|
+
return objects(v)
|
|
61
|
+
.filter((r) => typeof r.targetId === "string" && r.targetId)
|
|
62
|
+
.map((r) => ({
|
|
63
|
+
targetId: r.targetId,
|
|
64
|
+
type: RELATION_TYPES.includes(r.type) ? r.type : "related"
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
function normalizeManifestFields(raw) {
|
|
68
|
+
const m = (raw ?? {});
|
|
69
|
+
const billing = m.billing && typeof m.billing === "object"
|
|
70
|
+
? { plain: str(m.billing.plain), price: str(m.billing.price) }
|
|
71
|
+
: undefined;
|
|
72
|
+
const tiers = ["standard", "advanced", "team"];
|
|
73
|
+
return {
|
|
74
|
+
name: typeof m.name === "string" ? m.name : undefined,
|
|
75
|
+
description: typeof m.description === "string" ? m.description : undefined,
|
|
76
|
+
techStack: Array.isArray(m.techStack) ? m.techStack.filter((t) => typeof t === "string") : [],
|
|
77
|
+
relations: normalizeRelations(m.relations),
|
|
78
|
+
lastUpdated: typeof m.lastUpdated === "string" && m.lastUpdated ? m.lastUpdated : undefined,
|
|
79
|
+
repositoryUrl: str(m.repositoryUrl),
|
|
80
|
+
localPath: typeof m.localPath === "string" && m.localPath ? m.localPath : undefined,
|
|
81
|
+
endpoints: objects(m.endpoints).map((e) => ({ name: str(e.name), url: str(e.url), description: str(e.description) })),
|
|
82
|
+
apiSpec: objects(m.apiSpec).map((e) => ({ method: str(e.method), path: str(e.path), summary: str(e.summary) })),
|
|
83
|
+
billing,
|
|
84
|
+
apiDependencies: stringRecord(m.apiDependencies),
|
|
85
|
+
gatewayCompatibility: typeof m.gatewayCompatibility === "string" ? m.gatewayCompatibility : undefined,
|
|
86
|
+
api_versions: stringRecord(m.api_versions),
|
|
87
|
+
feature_tier: tiers.includes(m.feature_tier) ? m.feature_tier : undefined
|
|
88
|
+
};
|
|
89
|
+
}
|
|
50
90
|
export class RegistryStorage {
|
|
51
91
|
static async listRegistry() {
|
|
52
92
|
return readRegistry();
|
|
@@ -62,29 +102,30 @@ export class RegistryStorage {
|
|
|
62
102
|
};
|
|
63
103
|
await writeRegistry(registry);
|
|
64
104
|
await fs.mkdir(projectDir, { recursive: true });
|
|
65
|
-
await
|
|
105
|
+
await writeJsonAtomic(manifestPath, { ...manifest, lastUpdated: manifest.lastUpdated || new Date().toISOString() });
|
|
66
106
|
}
|
|
67
107
|
static async getProjectManifest(id) {
|
|
68
108
|
const registry = await readRegistry();
|
|
69
109
|
const entry = registry.projects[id];
|
|
70
110
|
if (!entry)
|
|
71
111
|
return null;
|
|
72
|
-
const
|
|
112
|
+
const m = normalizeManifestFields(await readProjectManifest(id));
|
|
73
113
|
return {
|
|
74
114
|
id,
|
|
75
|
-
name: entry.name ||
|
|
76
|
-
description: entry.summary ||
|
|
77
|
-
techStack:
|
|
78
|
-
relations:
|
|
79
|
-
lastUpdated:
|
|
80
|
-
repositoryUrl:
|
|
81
|
-
localPath:
|
|
82
|
-
endpoints:
|
|
83
|
-
apiSpec:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
name: entry.name || m.name || id,
|
|
116
|
+
description: entry.summary || m.description || "",
|
|
117
|
+
techStack: m.techStack,
|
|
118
|
+
relations: m.relations,
|
|
119
|
+
lastUpdated: m.lastUpdated || entry.lastActive,
|
|
120
|
+
repositoryUrl: m.repositoryUrl,
|
|
121
|
+
localPath: m.localPath || NexusPaths.projectDir(id),
|
|
122
|
+
endpoints: m.endpoints,
|
|
123
|
+
apiSpec: m.apiSpec,
|
|
124
|
+
billing: m.billing,
|
|
125
|
+
apiDependencies: m.apiDependencies,
|
|
126
|
+
gatewayCompatibility: m.gatewayCompatibility,
|
|
127
|
+
api_versions: m.api_versions,
|
|
128
|
+
feature_tier: m.feature_tier
|
|
88
129
|
};
|
|
89
130
|
}
|
|
90
131
|
static async patchProjectManifest(id, patch) {
|
|
@@ -111,12 +152,13 @@ export class RegistryStorage {
|
|
|
111
152
|
localPath: patch.localPath ?? manifest.localPath ?? NexusPaths.projectDir(id),
|
|
112
153
|
endpoints: patch.endpoints ?? manifest.endpoints ?? [],
|
|
113
154
|
apiSpec: patch.apiSpec ?? manifest.apiSpec ?? [],
|
|
155
|
+
billing: patch.billing ?? manifest.billing,
|
|
114
156
|
apiDependencies: patch.apiDependencies ?? manifest.apiDependencies,
|
|
115
157
|
gatewayCompatibility: patch.gatewayCompatibility ?? manifest.gatewayCompatibility,
|
|
116
158
|
api_versions: patch.api_versions ?? manifest.api_versions,
|
|
117
159
|
feature_tier: patch.feature_tier ?? manifest.feature_tier
|
|
118
160
|
};
|
|
119
|
-
await
|
|
161
|
+
await writeJsonAtomic(NexusPaths.projectManifestPath(id), patchedManifest);
|
|
120
162
|
}
|
|
121
163
|
static async calculateTopology(projectId) {
|
|
122
164
|
const registry = await readRegistry();
|
|
@@ -127,7 +169,7 @@ export class RegistryStorage {
|
|
|
127
169
|
name: manifest?.name || project.name || id,
|
|
128
170
|
summary: project.summary,
|
|
129
171
|
lastActive: project.lastActive,
|
|
130
|
-
relations: manifest?.relations
|
|
172
|
+
relations: normalizeRelations(manifest?.relations)
|
|
131
173
|
};
|
|
132
174
|
}));
|
|
133
175
|
const nodeMap = new Map(projectRecords.map((record) => [record.id, record]));
|
|
@@ -222,7 +264,7 @@ export class RegistryStorage {
|
|
|
222
264
|
manifest.relations = relations;
|
|
223
265
|
}
|
|
224
266
|
if (changed) {
|
|
225
|
-
await
|
|
267
|
+
await writeJsonAtomic(NexusPaths.projectManifestPath(manifestId), manifest);
|
|
226
268
|
}
|
|
227
269
|
}
|
|
228
270
|
return 1;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getDatabase } from "./sqlite.js";
|
|
2
|
+
export const SESSION_LEASE_MS = 60_000;
|
|
3
|
+
function toSession(row, now) {
|
|
4
|
+
return {
|
|
5
|
+
sessionId: row.session_id, instanceId: row.instance_id, name: row.name,
|
|
6
|
+
clientName: row.client_name, workspace: row.workspace, projectId: row.project_id,
|
|
7
|
+
inbound: row.inbound, reception: "pull",
|
|
8
|
+
online: row.connected === 1 && now - Date.parse(row.last_seen_at) < SESSION_LEASE_MS,
|
|
9
|
+
createdAt: row.created_at, lastSeenAt: row.last_seen_at
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export class SessionStore {
|
|
13
|
+
static register(input, now = Date.now()) {
|
|
14
|
+
const db = getDatabase();
|
|
15
|
+
return db.transaction(() => {
|
|
16
|
+
const existing = this.get(input.sessionId, now);
|
|
17
|
+
if (existing && existing.instanceId !== input.instanceId)
|
|
18
|
+
throw new Error("Session belongs to another instance");
|
|
19
|
+
const timestamp = new Date(now).toISOString();
|
|
20
|
+
if (existing) {
|
|
21
|
+
db.prepare("UPDATE sessions SET client_name = ?, workspace = ?, connected = 1, last_seen_at = ? WHERE session_id = ?")
|
|
22
|
+
.run(input.clientName ?? existing.clientName, input.workspace ?? existing.workspace, timestamp, input.sessionId);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
db.prepare("INSERT INTO sessions (session_id, instance_id, name, client_name, workspace, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
26
|
+
.run(input.sessionId, input.instanceId, `session-${input.sessionId.slice(0, 8)}`, input.clientName ?? "", input.workspace ?? "", timestamp, timestamp);
|
|
27
|
+
}
|
|
28
|
+
return this.get(input.sessionId, now);
|
|
29
|
+
})();
|
|
30
|
+
}
|
|
31
|
+
static get(sessionId, now = Date.now()) {
|
|
32
|
+
const row = getDatabase().prepare("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
|
|
33
|
+
return row ? toSession(row, now) : null;
|
|
34
|
+
}
|
|
35
|
+
static require(sessionId, instanceId) {
|
|
36
|
+
const session = this.get(sessionId);
|
|
37
|
+
if (!session)
|
|
38
|
+
throw new Error(`Unknown session: ${sessionId}`);
|
|
39
|
+
if (instanceId !== undefined && session.instanceId !== instanceId)
|
|
40
|
+
throw new Error("Session belongs to another instance");
|
|
41
|
+
return session;
|
|
42
|
+
}
|
|
43
|
+
static list(options = {}, now = Date.now()) {
|
|
44
|
+
const clauses = [];
|
|
45
|
+
const params = [];
|
|
46
|
+
if (options.projectId !== undefined) {
|
|
47
|
+
clauses.push("project_id = ?");
|
|
48
|
+
params.push(options.projectId);
|
|
49
|
+
}
|
|
50
|
+
if (options.onlineOnly) {
|
|
51
|
+
clauses.push("connected = 1 AND last_seen_at > ?");
|
|
52
|
+
params.push(new Date(now - SESSION_LEASE_MS).toISOString());
|
|
53
|
+
}
|
|
54
|
+
const rows = getDatabase().prepare(`SELECT * FROM sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY last_seen_at DESC, rowid DESC LIMIT ?`)
|
|
55
|
+
.all(...params, options.limit ?? 100);
|
|
56
|
+
return rows.map(row => toSession(row, now));
|
|
57
|
+
}
|
|
58
|
+
static update(sessionId, changes, now = Date.now()) {
|
|
59
|
+
const db = getDatabase();
|
|
60
|
+
return db.transaction(() => {
|
|
61
|
+
const existing = this.require(sessionId);
|
|
62
|
+
db.prepare("UPDATE sessions SET name = ?, project_id = ?, inbound = ? WHERE session_id = ?")
|
|
63
|
+
.run(changes.name ?? existing.name, changes.projectId === undefined ? existing.projectId : changes.projectId, changes.inbound ?? existing.inbound, sessionId);
|
|
64
|
+
if (changes.inbound === "refuse") {
|
|
65
|
+
db.prepare("UPDATE direct_messages SET status = 'expired', reason = 'Message TTL elapsed' WHERE to_session_id = ? AND status IN ('accepted', 'fetched') AND expires_at <= ?")
|
|
66
|
+
.run(sessionId, new Date(now).toISOString());
|
|
67
|
+
db.prepare("UPDATE direct_messages SET status = 'refused', reason = 'recipient_refused' WHERE to_session_id = ? AND status IN ('accepted', 'fetched')")
|
|
68
|
+
.run(sessionId);
|
|
69
|
+
}
|
|
70
|
+
return this.get(sessionId, now);
|
|
71
|
+
})();
|
|
72
|
+
}
|
|
73
|
+
static disconnect(sessionId, instanceId) {
|
|
74
|
+
this.require(sessionId, instanceId);
|
|
75
|
+
getDatabase().prepare("UPDATE sessions SET connected = 0 WHERE session_id = ?").run(sessionId);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -154,13 +154,7 @@ export class SqliteMeetingStore {
|
|
|
154
154
|
const activeMeetings = this.getActiveMeetingIds().filter(id => id !== meetingId);
|
|
155
155
|
this.updateState("active_meetings", JSON.stringify(activeMeetings));
|
|
156
156
|
this.updateState("default_meeting", activeMeetings.length > 0 ? activeMeetings[activeMeetings.length - 1] : "");
|
|
157
|
-
|
|
158
|
-
const updatedMeeting = this.getMeeting(meetingId);
|
|
159
|
-
// Suggest sync targets based on participants
|
|
160
|
-
const suggestedSyncTargets = updatedMeeting.participants
|
|
161
|
-
.map(p => p.split("@")[1])
|
|
162
|
-
.filter((v, i, a) => v && v !== "Global" && a.indexOf(v) === i);
|
|
163
|
-
return { meeting: updatedMeeting, suggestedSyncTargets };
|
|
157
|
+
return this.getMeeting(meetingId);
|
|
164
158
|
}
|
|
165
159
|
/**
|
|
166
160
|
* Archive a meeting
|
|
@@ -182,13 +176,17 @@ export class SqliteMeetingStore {
|
|
|
182
176
|
/**
|
|
183
177
|
* Reopen a closed or archived meeting
|
|
184
178
|
*/
|
|
185
|
-
static reopenMeeting(meetingId,
|
|
179
|
+
static reopenMeeting(meetingId, callerId) {
|
|
186
180
|
const db = getDatabase();
|
|
187
181
|
const meeting = this.getMeeting(meetingId);
|
|
188
182
|
if (!meeting)
|
|
189
183
|
throw new Error(`Meeting '${meetingId}' not found.`);
|
|
190
184
|
if (meeting.status === "active")
|
|
191
185
|
throw new Error(`Meeting '${meetingId}' is already active.`);
|
|
186
|
+
// Permission check: Only initiator can reopen (same rule as end/archive)
|
|
187
|
+
if (callerId && meeting.initiator && meeting.initiator !== callerId) {
|
|
188
|
+
throw new Error(`Permission denied: Only initiator (${meeting.initiator}) can reopen this meeting.`);
|
|
189
|
+
}
|
|
192
190
|
// Update status to active
|
|
193
191
|
const stmt = db.prepare("UPDATE meetings SET status = 'active', closed_at = NULL WHERE id = ?");
|
|
194
192
|
stmt.run(meetingId);
|
|
@@ -204,33 +202,47 @@ export class SqliteMeetingStore {
|
|
|
204
202
|
/**
|
|
205
203
|
* List meetings with optional status filter
|
|
206
204
|
*/
|
|
207
|
-
static listMeetings(status) {
|
|
205
|
+
static listMeetings(status, limit = 50) {
|
|
208
206
|
const db = getDatabase();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return
|
|
207
|
+
const rows = db.prepare(`
|
|
208
|
+
SELECT m.id, m.topic, m.status, m.initiator, m.participants, m.created_at, m.closed_at, m.summary,
|
|
209
|
+
(SELECT COUNT(*) FROM messages WHERE meeting_id = m.id) AS message_count,
|
|
210
|
+
(SELECT COUNT(*) FROM decisions WHERE meeting_id = m.id) AS decision_count
|
|
211
|
+
FROM meetings m
|
|
212
|
+
WHERE (? IS NULL OR m.status = ?)
|
|
213
|
+
ORDER BY m.created_at DESC
|
|
214
|
+
LIMIT ?
|
|
215
|
+
`).all(status ?? null, status ?? null, limit);
|
|
216
|
+
return rows.map(m => ({
|
|
219
217
|
id: m.id,
|
|
220
218
|
topic: m.topic,
|
|
221
219
|
status: m.status,
|
|
220
|
+
initiator: m.initiator || "Unknown",
|
|
222
221
|
startTime: m.created_at,
|
|
223
|
-
|
|
222
|
+
endTime: m.closed_at || undefined,
|
|
223
|
+
participants: JSON.parse(m.participants),
|
|
224
|
+
messageCount: m.message_count,
|
|
225
|
+
decisionCount: m.decision_count,
|
|
226
|
+
summary: m.summary || undefined
|
|
224
227
|
}));
|
|
225
228
|
}
|
|
226
229
|
/**
|
|
227
|
-
*
|
|
230
|
+
* Id of the current default meeting, if it is still active.
|
|
231
|
+
* Cheap: does not load the meeting's messages.
|
|
228
232
|
*/
|
|
229
|
-
static
|
|
233
|
+
static getActiveMeetingId() {
|
|
230
234
|
const defaultId = this.getState("default_meeting");
|
|
231
235
|
if (!defaultId)
|
|
232
236
|
return null;
|
|
233
|
-
|
|
237
|
+
const row = getDatabase().prepare("SELECT id FROM meetings WHERE id = ? AND status = 'active'").get(defaultId);
|
|
238
|
+
return row?.id ?? null;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the current active meeting
|
|
242
|
+
*/
|
|
243
|
+
static getActiveMeeting() {
|
|
244
|
+
const id = this.getActiveMeetingId();
|
|
245
|
+
return id ? this.getMeeting(id) : null;
|
|
234
246
|
}
|
|
235
247
|
/**
|
|
236
248
|
* Get recent messages from a meeting (incremental read based on instance cursor)
|