n2n-nexus 0.4.2 → 0.6.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 +87 -0
- package/README.md +74 -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 +30 -14
- 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 +349 -542
- package/build/daemon/tool-outputs.js +155 -0
- package/build/daemon/tools.js +271 -0
- package/build/index.js +1 -1
- package/build/server/instance-id.js +19 -0
- package/build/server/nexus.js +152 -43
- 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 +44 -41
- package/build/storage/migrations.js +79 -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/sqlite-meeting.js +35 -23
- package/build/storage/sqlite.js +41 -24
- package/build/storage/tasks.js +29 -103
- package/build/types.js +1 -1
- package/docs/ARCHITECTURE.md +29 -20
- package/docs/ARCHITECTURE_zh.md +29 -20
- package/docs/ASSISTANT_GUIDE.md +70 -67
- package/docs/README_zh.md +25 -15
- package/llms.txt +11 -1
- 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/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;
|
|
@@ -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)
|
package/build/storage/sqlite.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
-
import { createRequire } from "module";
|
|
3
2
|
import { NexusPaths } from "./paths.js";
|
|
4
3
|
let db = null;
|
|
5
4
|
/**
|
|
@@ -50,6 +49,46 @@ CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp);
|
|
|
50
49
|
CREATE INDEX IF NOT EXISTS idx_decisions_meeting ON decisions(meeting_id);
|
|
51
50
|
CREATE INDEX IF NOT EXISTS idx_meetings_status ON meetings(status);
|
|
52
51
|
|
|
52
|
+
-- Global (non-meeting) chat stream
|
|
53
|
+
CREATE TABLE IF NOT EXISTS global_messages (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
sender TEXT NOT NULL,
|
|
56
|
+
text TEXT NOT NULL,
|
|
57
|
+
category TEXT CHECK(category IN ('MEETING_START', 'PROPOSAL', 'DECISION', 'UPDATE', 'CHAT')),
|
|
58
|
+
timestamp TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
-- Audit trail of state changes made through the daemon
|
|
62
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
timestamp TEXT NOT NULL,
|
|
65
|
+
actor TEXT NOT NULL,
|
|
66
|
+
action TEXT NOT NULL,
|
|
67
|
+
target TEXT,
|
|
68
|
+
detail TEXT
|
|
69
|
+
);
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_events_action ON events(action);
|
|
71
|
+
|
|
72
|
+
-- Tasks managed by assistants
|
|
73
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) DEFAULT 'pending',
|
|
76
|
+
progress REAL DEFAULT 0.0 CHECK(progress >= 0.0 AND progress <= 1.0),
|
|
77
|
+
source_meeting_id TEXT,
|
|
78
|
+
metadata TEXT,
|
|
79
|
+
result_uri TEXT,
|
|
80
|
+
error_message TEXT,
|
|
81
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
82
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
83
|
+
ttl INTEGER,
|
|
84
|
+
FOREIGN KEY (source_meeting_id) REFERENCES meetings(id) ON DELETE SET NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_meeting ON tasks(source_meeting_id);
|
|
88
|
+
-- updateTask sets updated_at itself in ISO 8601; the old trigger overwrote it
|
|
89
|
+
-- with datetime('now') in a different format, breaking timestamp comparisons.
|
|
90
|
+
DROP TRIGGER IF EXISTS tasks_updated_at;
|
|
91
|
+
|
|
53
92
|
-- Read cursors table (tracks each IDE's last read message per context)
|
|
54
93
|
CREATE TABLE IF NOT EXISTS read_cursors (
|
|
55
94
|
instance_id TEXT NOT NULL,
|
|
@@ -76,6 +115,7 @@ export function initDatabase() {
|
|
|
76
115
|
db = new Database(dbPath);
|
|
77
116
|
// Enable WAL mode for better concurrent access
|
|
78
117
|
db.pragma("journal_mode = WAL");
|
|
118
|
+
db.pragma("foreign_keys = ON");
|
|
79
119
|
// Initialize schema
|
|
80
120
|
db.exec(SCHEMA);
|
|
81
121
|
// Migration: Add initiator column if it doesn't exist (Upgrade from v0.1.7)
|
|
@@ -116,26 +156,3 @@ export function closeDatabase() {
|
|
|
116
156
|
console.error("[Nexus] SQLite database closed");
|
|
117
157
|
}
|
|
118
158
|
}
|
|
119
|
-
export function isSqliteAvailable() {
|
|
120
|
-
const require = createRequire(import.meta.url);
|
|
121
|
-
try {
|
|
122
|
-
// Try to load better-sqlite3
|
|
123
|
-
require("better-sqlite3");
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// Cleanup on process exit
|
|
131
|
-
process.on("exit", () => {
|
|
132
|
-
closeDatabase();
|
|
133
|
-
});
|
|
134
|
-
process.on("SIGINT", () => {
|
|
135
|
-
closeDatabase();
|
|
136
|
-
process.exit(0);
|
|
137
|
-
});
|
|
138
|
-
process.on("SIGTERM", () => {
|
|
139
|
-
closeDatabase();
|
|
140
|
-
process.exit(0);
|
|
141
|
-
});
|
package/build/storage/tasks.js
CHANGED
|
@@ -6,59 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { getDatabase } from "./sqlite.js";
|
|
8
8
|
import { TASK_CLEANUP_MAX_AGE_MS } from "../constants.js";
|
|
9
|
+
export const TASK_STATUSES = ["pending", "running", "completed", "failed", "cancelled"];
|
|
9
10
|
// Generate unique task ID
|
|
10
11
|
function generateTaskId() {
|
|
11
12
|
const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
|
|
12
13
|
const random = Math.random().toString(36).substring(2, 6);
|
|
13
14
|
return `task_${timestamp}_${random}`;
|
|
14
15
|
}
|
|
15
|
-
let initialized = false;
|
|
16
|
-
/**
|
|
17
|
-
* Initialize the tasks table (run migrations)
|
|
18
|
-
*/
|
|
19
|
-
export function initTasksTable() {
|
|
20
|
-
if (initialized)
|
|
21
|
-
return;
|
|
22
|
-
const db = getDatabase();
|
|
23
|
-
const TASKS_SCHEMA = `
|
|
24
|
-
CREATE TABLE IF NOT EXISTS tasks (
|
|
25
|
-
id TEXT PRIMARY KEY,
|
|
26
|
-
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled')) DEFAULT 'pending',
|
|
27
|
-
progress REAL DEFAULT 0.0 CHECK(progress >= 0.0 AND progress <= 1.0),
|
|
28
|
-
source_meeting_id TEXT,
|
|
29
|
-
metadata TEXT,
|
|
30
|
-
result_uri TEXT,
|
|
31
|
-
error_message TEXT,
|
|
32
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
34
|
-
ttl INTEGER,
|
|
35
|
-
FOREIGN KEY (source_meeting_id) REFERENCES meetings(id) ON DELETE SET NULL
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
39
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_meeting ON tasks(source_meeting_id);
|
|
40
|
-
`;
|
|
41
|
-
db.exec(TASKS_SCHEMA);
|
|
42
|
-
// Add trigger for auto-updating updated_at (separate exec to handle IF NOT EXISTS)
|
|
43
|
-
try {
|
|
44
|
-
db.exec(`
|
|
45
|
-
CREATE TRIGGER IF NOT EXISTS tasks_updated_at
|
|
46
|
-
AFTER UPDATE ON tasks
|
|
47
|
-
FOR EACH ROW
|
|
48
|
-
BEGIN
|
|
49
|
-
UPDATE tasks SET updated_at = datetime('now') WHERE id = OLD.id;
|
|
50
|
-
END;
|
|
51
|
-
`);
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
// Trigger may already exist in older SQLite versions without IF NOT EXISTS support
|
|
55
|
-
}
|
|
56
|
-
console.error("[Nexus] Tasks table initialized");
|
|
57
|
-
initialized = true;
|
|
58
|
-
}
|
|
59
|
-
export function resetTasksInit() {
|
|
60
|
-
initialized = false;
|
|
61
|
-
}
|
|
62
16
|
/**
|
|
63
17
|
* Create a new task
|
|
64
18
|
*/
|
|
@@ -73,14 +27,7 @@ export function createTask(input = {}) {
|
|
|
73
27
|
stmt.run(id, input.source_meeting_id || null, input.metadata ? JSON.stringify(input.metadata) : null, now, now, input.ttl || null);
|
|
74
28
|
return getTask(id);
|
|
75
29
|
}
|
|
76
|
-
|
|
77
|
-
* Get a task by ID
|
|
78
|
-
*/
|
|
79
|
-
export function getTask(id) {
|
|
80
|
-
const db = getDatabase();
|
|
81
|
-
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
82
|
-
if (!row)
|
|
83
|
-
return null;
|
|
30
|
+
function rowToTask(row) {
|
|
84
31
|
return {
|
|
85
32
|
id: row.id,
|
|
86
33
|
status: row.status,
|
|
@@ -94,6 +41,13 @@ export function getTask(id) {
|
|
|
94
41
|
ttl: row.ttl || null
|
|
95
42
|
};
|
|
96
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Get a task by ID
|
|
46
|
+
*/
|
|
47
|
+
export function getTask(id) {
|
|
48
|
+
const row = getDatabase().prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
49
|
+
return row ? rowToTask(row) : null;
|
|
50
|
+
}
|
|
97
51
|
/**
|
|
98
52
|
* Update task status and progress
|
|
99
53
|
*/
|
|
@@ -141,39 +95,7 @@ export function listTasks(status, limit = 50) {
|
|
|
141
95
|
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
142
96
|
params.push(limit);
|
|
143
97
|
const rows = db.prepare(sql).all(...params);
|
|
144
|
-
return rows.map(
|
|
145
|
-
id: row.id,
|
|
146
|
-
status: row.status,
|
|
147
|
-
progress: row.progress,
|
|
148
|
-
source_meeting_id: row.source_meeting_id || null,
|
|
149
|
-
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
150
|
-
result_uri: row.result_uri || null,
|
|
151
|
-
error_message: row.error_message || null,
|
|
152
|
-
created_at: row.created_at,
|
|
153
|
-
updated_at: row.updated_at,
|
|
154
|
-
ttl: row.ttl || null
|
|
155
|
-
}));
|
|
156
|
-
}
|
|
157
|
-
/**
|
|
158
|
-
* Get tasks by meeting ID
|
|
159
|
-
*/
|
|
160
|
-
export function getTasksByMeeting(meetingId) {
|
|
161
|
-
const db = getDatabase();
|
|
162
|
-
const rows = db.prepare(`
|
|
163
|
-
SELECT * FROM tasks WHERE source_meeting_id = ? ORDER BY created_at DESC
|
|
164
|
-
`).all(meetingId);
|
|
165
|
-
return rows.map(row => ({
|
|
166
|
-
id: row.id,
|
|
167
|
-
status: row.status,
|
|
168
|
-
progress: row.progress,
|
|
169
|
-
source_meeting_id: row.source_meeting_id || null,
|
|
170
|
-
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
171
|
-
result_uri: row.result_uri || null,
|
|
172
|
-
error_message: row.error_message || null,
|
|
173
|
-
created_at: row.created_at,
|
|
174
|
-
updated_at: row.updated_at,
|
|
175
|
-
ttl: row.ttl || null
|
|
176
|
-
}));
|
|
98
|
+
return rows.map(rowToTask);
|
|
177
99
|
}
|
|
178
100
|
/**
|
|
179
101
|
* Cancel a pending or running task
|
|
@@ -187,26 +109,30 @@ export function cancelTask(id) {
|
|
|
187
109
|
updateTask(id, { status: "cancelled" });
|
|
188
110
|
return true;
|
|
189
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Mark pending/running tasks whose TTL has elapsed as failed.
|
|
114
|
+
*/
|
|
115
|
+
export function expireTasks(now = Date.now()) {
|
|
116
|
+
return getDatabase().prepare(`
|
|
117
|
+
UPDATE tasks
|
|
118
|
+
SET status = 'failed', error_message = 'TTL expired', updated_at = ?
|
|
119
|
+
WHERE status IN ('pending', 'running')
|
|
120
|
+
AND ttl IS NOT NULL
|
|
121
|
+
AND strftime('%s', created_at) * 1000 + ttl < ?
|
|
122
|
+
`).run(new Date(now).toISOString(), now).changes;
|
|
123
|
+
}
|
|
190
124
|
/**
|
|
191
125
|
* Delete completed/failed/cancelled tasks older than specified age
|
|
192
126
|
*/
|
|
193
|
-
export function cleanupTasks(maxAgeMs = TASK_CLEANUP_MAX_AGE_MS) {
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
const result = db.prepare(`
|
|
127
|
+
export function cleanupTasks(maxAgeMs = TASK_CLEANUP_MAX_AGE_MS, now = Date.now()) {
|
|
128
|
+
const cutoff = new Date(now - maxAgeMs).toISOString();
|
|
129
|
+
return getDatabase().prepare(`
|
|
197
130
|
DELETE FROM tasks
|
|
198
131
|
WHERE status IN ('completed', 'failed', 'cancelled')
|
|
199
132
|
AND updated_at < ?
|
|
200
|
-
`).run(cutoff);
|
|
201
|
-
return result.changes;
|
|
133
|
+
`).run(cutoff).changes;
|
|
202
134
|
}
|
|
203
|
-
/**
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
export function getActiveTaskCount() {
|
|
207
|
-
const db = getDatabase();
|
|
208
|
-
const row = db.prepare(`
|
|
209
|
-
SELECT COUNT(*) as count FROM tasks WHERE status IN ('pending', 'running')
|
|
210
|
-
`).get();
|
|
211
|
-
return row.count;
|
|
135
|
+
/** Expire overdue tasks, then purge finished ones past the retention window. */
|
|
136
|
+
export function runTaskMaintenance(now = Date.now()) {
|
|
137
|
+
return { expired: expireTasks(now), removed: cleanupTasks(TASK_CLEANUP_MAX_AGE_MS, now) };
|
|
212
138
|
}
|
package/build/types.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export const MESSAGE_CATEGORIES = ["MEETING_START", "PROPOSAL", "DECISION", "UPDATE", "CHAT"];
|