recallx 1.1.0 → 1.2.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.
@@ -0,0 +1,249 @@
1
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { AppError } from "./errors.js";
5
+ const BACKUP_MANIFEST_FILE = "manifest.json";
6
+ const SESSION_METADATA_FILE = "workspace-session.json";
7
+ const LOCK_METADATA_FILE = "workspace.lock.json";
8
+ const WORKSPACE_ACTIVITY_THRESHOLD_MS = 10 * 60 * 1000;
9
+ function readJsonFile(filePath) {
10
+ if (!existsSync(filePath)) {
11
+ return null;
12
+ }
13
+ try {
14
+ return JSON.parse(readFileSync(filePath, "utf8"));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function writeJsonFile(filePath, value) {
21
+ mkdirSync(path.dirname(filePath), { recursive: true });
22
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
23
+ }
24
+ function sanitizeLabel(value, fallback) {
25
+ const trimmed = value?.trim();
26
+ if (!trimmed) {
27
+ return fallback;
28
+ }
29
+ return trimmed.replace(/[<>:"/\\|?*\x00-\x1f]+/g, "-").replace(/\s+/g, " ").trim() || fallback;
30
+ }
31
+ function sessionMetadataPath(paths) {
32
+ return path.join(paths.configDir, SESSION_METADATA_FILE);
33
+ }
34
+ function lockMetadataPath(paths) {
35
+ return path.join(paths.configDir, LOCK_METADATA_FILE);
36
+ }
37
+ function readSessionMetadata(paths) {
38
+ return readJsonFile(sessionMetadataPath(paths));
39
+ }
40
+ function readLockMetadata(paths) {
41
+ return readJsonFile(lockMetadataPath(paths));
42
+ }
43
+ function buildSafetyWarnings(previous, lock, machineId, sessionId) {
44
+ const warnings = [];
45
+ const isSameActiveSession = previous?.sessionId === sessionId &&
46
+ previous.machineId === machineId &&
47
+ lock?.sessionId === sessionId &&
48
+ lock.machineId === machineId;
49
+ if (lock && !isSameActiveSession) {
50
+ warnings.push({
51
+ code: "active_lock",
52
+ message: `Workspace lock marker is still present from ${lock.machineId}. Another session may still be active.`
53
+ });
54
+ }
55
+ if (previous &&
56
+ !isSameActiveSession &&
57
+ (!previous.lastCleanCloseAt || previous.lastCleanCloseAt < previous.lastOpenedAt)) {
58
+ warnings.push({
59
+ code: "unclean_shutdown",
60
+ message: "The previous session does not appear to have closed cleanly. Create a backup before heavy edits."
61
+ });
62
+ }
63
+ if (previous &&
64
+ previous.machineId !== machineId &&
65
+ Date.now() - Date.parse(previous.lastOpenedAt) <= WORKSPACE_ACTIVITY_THRESHOLD_MS) {
66
+ warnings.push({
67
+ code: "recent_other_machine",
68
+ message: `This workspace was opened recently on ${previous.machineId}. Treat multi-device access as single-writer only.`
69
+ });
70
+ }
71
+ return warnings;
72
+ }
73
+ export function beginWorkspaceSession(paths, params) {
74
+ const machineId = os.hostname() || "unknown-machine";
75
+ const previous = readSessionMetadata(paths);
76
+ const lock = readLockMetadata(paths);
77
+ const warnings = buildSafetyWarnings(previous, lock, machineId, params.sessionId);
78
+ const nextSession = {
79
+ machineId,
80
+ sessionId: params.sessionId,
81
+ appVersion: params.appVersion,
82
+ lastOpenedAt: params.now,
83
+ lastCleanCloseAt: previous?.lastCleanCloseAt ?? null
84
+ };
85
+ const nextLock = {
86
+ machineId,
87
+ sessionId: params.sessionId,
88
+ appVersion: params.appVersion,
89
+ lockUpdatedAt: params.now
90
+ };
91
+ writeJsonFile(sessionMetadataPath(paths), nextSession);
92
+ writeJsonFile(lockMetadataPath(paths), nextLock);
93
+ return {
94
+ machineId,
95
+ sessionId: params.sessionId,
96
+ lastOpenedAt: params.now,
97
+ lastCleanCloseAt: nextSession.lastCleanCloseAt,
98
+ lockPresent: true,
99
+ lockUpdatedAt: params.now,
100
+ activeSessionMachineId: machineId,
101
+ warnings
102
+ };
103
+ }
104
+ export function endWorkspaceSession(paths, params) {
105
+ const machineId = os.hostname() || "unknown-machine";
106
+ const previous = readSessionMetadata(paths);
107
+ const nextSession = {
108
+ machineId,
109
+ sessionId: params.sessionId,
110
+ appVersion: params.appVersion,
111
+ lastOpenedAt: previous?.lastOpenedAt ?? params.now,
112
+ lastCleanCloseAt: params.now
113
+ };
114
+ writeJsonFile(sessionMetadataPath(paths), nextSession);
115
+ const lock = readLockMetadata(paths);
116
+ if (lock?.sessionId === params.sessionId && existsSync(lockMetadataPath(paths))) {
117
+ unlinkSync(lockMetadataPath(paths));
118
+ }
119
+ }
120
+ function formatArtifactSize(bytes) {
121
+ if (bytes < 1024) {
122
+ return `${bytes} B`;
123
+ }
124
+ if (bytes < 1024 * 1024) {
125
+ return `${(bytes / 1024).toFixed(1)} KB`;
126
+ }
127
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
128
+ }
129
+ function copyWorkspaceSnapshot(sourcePaths, backupDir) {
130
+ mkdirSync(backupDir, { recursive: true });
131
+ if (existsSync(sourcePaths.dbPath)) {
132
+ copyFileSync(sourcePaths.dbPath, path.join(backupDir, "workspace.db"));
133
+ }
134
+ for (const [from, name] of [
135
+ [sourcePaths.artifactsDir, "artifacts"],
136
+ [sourcePaths.exportsDir, "exports"],
137
+ ]) {
138
+ if (existsSync(from)) {
139
+ cpSync(from, path.join(backupDir, name), { recursive: true });
140
+ }
141
+ }
142
+ if (existsSync(sourcePaths.configDir)) {
143
+ const targetConfigDir = path.join(backupDir, "config");
144
+ mkdirSync(targetConfigDir, { recursive: true });
145
+ for (const entry of readdirSync(sourcePaths.configDir, { withFileTypes: true })) {
146
+ if (entry.name === SESSION_METADATA_FILE || entry.name === LOCK_METADATA_FILE) {
147
+ continue;
148
+ }
149
+ const sourcePath = path.join(sourcePaths.configDir, entry.name);
150
+ const destinationPath = path.join(targetConfigDir, entry.name);
151
+ if (entry.isDirectory()) {
152
+ cpSync(sourcePath, destinationPath, { recursive: true });
153
+ }
154
+ else {
155
+ copyFileSync(sourcePath, destinationPath);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ export function createWorkspaceBackup(paths, params) {
161
+ const id = `${params.now.replace(/[-:.TZ]/g, "").slice(0, 14)}-${sanitizeLabel(params.label, "snapshot").replace(/\s+/g, "-").toLowerCase()}`;
162
+ const backupDir = path.join(paths.backupsDir, id);
163
+ if (existsSync(backupDir)) {
164
+ throw new AppError(409, "BACKUP_EXISTS", `Backup already exists: ${id}`);
165
+ }
166
+ copyWorkspaceSnapshot(paths, backupDir);
167
+ const manifest = {
168
+ id,
169
+ label: sanitizeLabel(params.label, "Manual snapshot"),
170
+ createdAt: params.now,
171
+ backupPath: backupDir,
172
+ workspaceRoot: paths.root,
173
+ workspaceName: params.workspaceName,
174
+ appVersion: params.appVersion
175
+ };
176
+ writeJsonFile(path.join(backupDir, BACKUP_MANIFEST_FILE), manifest);
177
+ return manifest;
178
+ }
179
+ export function listWorkspaceBackups(paths) {
180
+ if (!existsSync(paths.backupsDir)) {
181
+ return [];
182
+ }
183
+ return readdirSync(paths.backupsDir, { withFileTypes: true })
184
+ .filter((entry) => entry.isDirectory())
185
+ .map((entry) => readJsonFile(path.join(paths.backupsDir, entry.name, BACKUP_MANIFEST_FILE)))
186
+ .filter((entry) => Boolean(entry))
187
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
188
+ }
189
+ export function restoreWorkspaceBackup(paths, params) {
190
+ const manifest = listWorkspaceBackups(paths).find((item) => item.id === params.backupId);
191
+ if (!manifest) {
192
+ throw new AppError(404, "BACKUP_NOT_FOUND", `Backup ${params.backupId} not found.`);
193
+ }
194
+ const targetRoot = path.resolve(params.targetRootPath);
195
+ if (targetRoot === path.resolve(paths.root)) {
196
+ throw new AppError(400, "INVALID_INPUT", "Restore target must be a different workspace root.");
197
+ }
198
+ if (existsSync(targetRoot)) {
199
+ const existingEntries = readdirSync(targetRoot);
200
+ if (existingEntries.length > 0) {
201
+ throw new AppError(409, "RESTORE_TARGET_NOT_EMPTY", "Restore target root must be empty or not exist.");
202
+ }
203
+ }
204
+ else {
205
+ mkdirSync(targetRoot, { recursive: true });
206
+ }
207
+ for (const entry of ["workspace.db", "artifacts", "exports", "config"]) {
208
+ const sourcePath = path.join(manifest.backupPath, entry);
209
+ const destinationPath = path.join(targetRoot, entry);
210
+ if (!existsSync(sourcePath)) {
211
+ continue;
212
+ }
213
+ if (entry === "workspace.db") {
214
+ copyFileSync(sourcePath, destinationPath);
215
+ }
216
+ else {
217
+ cpSync(sourcePath, destinationPath, { recursive: true });
218
+ }
219
+ }
220
+ return manifest;
221
+ }
222
+ export function exportWorkspaceSnapshot(paths, params) {
223
+ const id = `${params.now.replace(/[-:.TZ]/g, "").slice(0, 14)}-workspace-export`;
224
+ const extension = params.format === "json" ? "json" : "md";
225
+ const exportPath = path.join(paths.exportsDir, `${id}.${extension}`);
226
+ const body = params.format === "json" ? `${JSON.stringify(params.payload, null, 2)}\n` : `${params.markdown}\n`;
227
+ writeFileSync(exportPath, body, "utf8");
228
+ const manifest = {
229
+ id,
230
+ format: params.format,
231
+ createdAt: params.now,
232
+ exportPath,
233
+ workspaceRoot: paths.root,
234
+ workspaceName: params.workspaceName,
235
+ appVersion: params.appVersion
236
+ };
237
+ writeJsonFile(path.join(paths.exportsDir, `${id}.manifest.json`), manifest);
238
+ return manifest;
239
+ }
240
+ export function summarizeBackupRecord(record) {
241
+ const stats = existsSync(record.backupPath) ? statSync(record.backupPath) : null;
242
+ const sizeLabel = stats ? formatArtifactSize(stats.size) : "unknown";
243
+ return `${record.label} (${record.id}) at ${record.createdAt} -> ${record.backupPath} [${sizeLabel}]`;
244
+ }
245
+ export function removeWorkspaceDirectory(rootPath) {
246
+ if (existsSync(rootPath)) {
247
+ rmSync(rootPath, { recursive: true, force: true });
248
+ }
249
+ }
@@ -1,10 +1,13 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import path from "node:path";
3
4
  import { workspaceInfo } from "./config.js";
4
5
  import { openDatabase } from "./db.js";
5
6
  import { AppError } from "./errors.js";
6
7
  import { bootstrapAutomaticGovernance } from "./governance.js";
7
8
  import { RecallXRepository } from "./repositories.js";
9
+ import { beginWorkspaceSession, createWorkspaceBackup, endWorkspaceSession, exportWorkspaceSnapshot, listWorkspaceBackups, restoreWorkspaceBackup, } from "./workspace-ops.js";
10
+ import { importIntoWorkspace, previewImportIntoWorkspace } from "./workspace-import.js";
8
11
  import { defaultWorkspaceName, ensureWorkspace } from "./workspace.js";
9
12
  import { RECALLX_VERSION } from "../shared/version.js";
10
13
  export class WorkspaceSessionManager {
@@ -12,6 +15,7 @@ export class WorkspaceSessionManager {
12
15
  authMode;
13
16
  currentState;
14
17
  history = new Map();
18
+ processSessionId = randomUUID();
15
19
  constructor(serverConfig, initialWorkspaceRoot, authMode) {
16
20
  this.serverConfig = serverConfig;
17
21
  this.authMode = authMode;
@@ -44,12 +48,97 @@ export class WorkspaceSessionManager {
44
48
  requireExistingRoot: true,
45
49
  });
46
50
  }
51
+ listBackups() {
52
+ return listWorkspaceBackups(this.currentState.paths);
53
+ }
54
+ createBackup(label) {
55
+ return createWorkspaceBackup(this.currentState.paths, {
56
+ workspaceName: this.currentState.workspaceInfo.workspaceName,
57
+ appVersion: RECALLX_VERSION,
58
+ label,
59
+ now: new Date().toISOString(),
60
+ });
61
+ }
62
+ exportWorkspace(format) {
63
+ const repository = this.currentState.repository;
64
+ const payload = {
65
+ workspace: this.currentState.workspaceInfo,
66
+ nodes: repository.listAllNodes(),
67
+ relations: repository.listAllRelations(),
68
+ activities: repository.listAllActivities(),
69
+ artifacts: repository.listAllArtifacts(),
70
+ integrations: repository.listIntegrations(),
71
+ settings: repository.getSettings(),
72
+ };
73
+ const markdown = [
74
+ `# ${this.currentState.workspaceInfo.workspaceName}`,
75
+ "",
76
+ `- exportedAt: ${new Date().toISOString()}`,
77
+ `- workspaceRoot: ${this.currentState.workspaceRoot}`,
78
+ `- nodes: ${payload.nodes.length}`,
79
+ `- relations: ${payload.relations.length}`,
80
+ `- activities: ${payload.activities.length}`,
81
+ `- artifacts: ${payload.artifacts.length}`,
82
+ `- integrations: ${payload.integrations.length}`,
83
+ "",
84
+ "## Recent Nodes",
85
+ ...payload.nodes.slice(0, 20).map((node) => `- ${node.title ?? node.id} (${node.type})`),
86
+ ].join("\n");
87
+ return exportWorkspaceSnapshot(this.currentState.paths, {
88
+ workspaceName: this.currentState.workspaceInfo.workspaceName,
89
+ appVersion: RECALLX_VERSION,
90
+ now: new Date().toISOString(),
91
+ format,
92
+ payload,
93
+ markdown,
94
+ });
95
+ }
96
+ previewImportWorkspace(format, sourcePath, label, options) {
97
+ return previewImportIntoWorkspace({
98
+ repository: this.currentState.repository,
99
+ format,
100
+ sourcePath,
101
+ label,
102
+ options,
103
+ now: new Date().toISOString(),
104
+ });
105
+ }
106
+ importWorkspace(format, sourcePath, label, options) {
107
+ const backup = this.createBackup(label ? `before-import ${label}` : "before-import");
108
+ return importIntoWorkspace({
109
+ repository: this.currentState.repository,
110
+ paths: this.currentState.paths,
111
+ format,
112
+ sourcePath,
113
+ label,
114
+ options,
115
+ now: new Date().toISOString(),
116
+ backup,
117
+ });
118
+ }
119
+ restoreBackup(backupId, targetRootPath, workspaceName) {
120
+ const autoBackup = this.createBackup(workspaceName ? `before-restore ${workspaceName}` : "before-restore");
121
+ const manifest = restoreWorkspaceBackup(this.currentState.paths, {
122
+ backupId,
123
+ targetRootPath,
124
+ });
125
+ return {
126
+ workspace: this.swapWorkspace(targetRootPath, {
127
+ workspaceName: workspaceName?.trim() || manifest.workspaceName,
128
+ requireExistingRoot: true,
129
+ }),
130
+ autoBackup,
131
+ };
132
+ }
133
+ shutdown() {
134
+ this.closeState(this.currentState);
135
+ }
47
136
  swapWorkspace(rootPath, options) {
48
137
  const nextState = this.loadWorkspace(rootPath, options);
49
138
  const previousState = this.currentState;
50
139
  this.currentState = nextState;
51
140
  this.remember(nextState);
52
- previousState.db.close();
141
+ this.closeState(previousState);
53
142
  return this.getWorkspaceCatalogItem(nextState);
54
143
  }
55
144
  loadWorkspace(rootPath, options) {
@@ -61,9 +150,10 @@ export class WorkspaceSessionManager {
61
150
  const db = openDatabase(paths);
62
151
  const repository = new RecallXRepository(db, resolvedRoot);
63
152
  const storedSettings = repository.getSettings(["workspace.name"]);
64
- const resolvedName = typeof storedSettings["workspace.name"] === "string" && storedSettings["workspace.name"].trim()
65
- ? String(storedSettings["workspace.name"])
66
- : options.workspaceName?.trim() || defaultWorkspaceName(resolvedRoot);
153
+ const resolvedName = options.workspaceName?.trim() ||
154
+ (typeof storedSettings["workspace.name"] === "string" && storedSettings["workspace.name"].trim()
155
+ ? String(storedSettings["workspace.name"])
156
+ : defaultWorkspaceName(resolvedRoot));
67
157
  repository.setSetting("workspace.name", resolvedName);
68
158
  repository.setSetting("workspace.version", RECALLX_VERSION);
69
159
  repository.setSetting("api.bind", `${this.serverConfig.bindAddress}:${this.serverConfig.port}`);
@@ -105,6 +195,12 @@ export class WorkspaceSessionManager {
105
195
  repository.ensureSearchTagIndex();
106
196
  repository.ensureActivitySearchIndex();
107
197
  bootstrapAutomaticGovernance(repository);
198
+ const now = new Date().toISOString();
199
+ const safety = beginWorkspaceSession(paths, {
200
+ sessionId: this.processSessionId,
201
+ appVersion: RECALLX_VERSION,
202
+ now,
203
+ });
108
204
  return {
109
205
  db,
110
206
  repository,
@@ -113,9 +209,25 @@ export class WorkspaceSessionManager {
113
209
  workspaceInfo: workspaceInfo(resolvedRoot, {
114
210
  ...this.serverConfig,
115
211
  workspaceName: resolvedName,
116
- }, this.authMode),
212
+ }, this.authMode, {
213
+ dbPath: paths.dbPath,
214
+ artifactsDir: paths.artifactsDir,
215
+ exportsDir: paths.exportsDir,
216
+ importsDir: paths.importsDir,
217
+ backupsDir: paths.backupsDir,
218
+ configDir: paths.configDir,
219
+ cacheDir: paths.cacheDir,
220
+ }, safety),
117
221
  };
118
222
  }
223
+ closeState(state) {
224
+ endWorkspaceSession(state.paths, {
225
+ sessionId: this.processSessionId,
226
+ appVersion: RECALLX_VERSION,
227
+ now: new Date().toISOString(),
228
+ });
229
+ state.db.close();
230
+ }
119
231
  remember(state) {
120
232
  this.history.set(state.workspaceRoot, this.getWorkspaceCatalogItem(state));
121
233
  }
@@ -123,7 +235,7 @@ export class WorkspaceSessionManager {
123
235
  return {
124
236
  ...state.workspaceInfo,
125
237
  isCurrent: state.workspaceRoot === this.currentState?.workspaceRoot,
126
- lastOpenedAt: new Date().toISOString(),
238
+ lastOpenedAt: state.workspaceInfo.safety?.lastOpenedAt ?? new Date().toISOString(),
127
239
  };
128
240
  }
129
241
  }
@@ -43,6 +43,7 @@ export const searchFeedbackResultTypes = ["node", "activity"];
43
43
  export const searchFeedbackVerdicts = ["useful", "not_useful", "uncertain"];
44
44
  export const governanceEntityTypes = ["node", "relation"];
45
45
  export const governanceStates = ["healthy", "low_confidence", "contested"];
46
+ export const governanceDecisionActions = ["promote", "contest", "archive", "accept", "reject"];
46
47
  export const governanceEventTypes = [
47
48
  "evaluated",
48
49
  "promoted",
@@ -165,6 +166,11 @@ export const governanceIssuesQuerySchema = z.object({
165
166
  states: z.array(z.enum(governanceStates)).optional(),
166
167
  limit: z.number().int().min(1).max(100).default(20)
167
168
  });
169
+ export const governanceEventsQuerySchema = z.object({
170
+ entityTypes: z.array(z.enum(governanceEntityTypes)).optional(),
171
+ actions: z.array(z.enum(governanceDecisionActions)).optional(),
172
+ limit: z.number().int().min(1).max(100).default(12)
173
+ });
168
174
  export const recomputeGovernanceSchema = z.object({
169
175
  entityType: z.enum(governanceEntityTypes).optional(),
170
176
  entityIds: z.array(z.string().min(1)).max(200).optional(),
@@ -192,6 +198,18 @@ export const updateNodeSchema = z.object({
192
198
  metadata: z.record(z.any()).optional(),
193
199
  status: z.enum(nodeStatuses).optional()
194
200
  });
201
+ export const governanceNodeActionSchema = z.object({
202
+ action: z.enum(["promote", "contest", "archive"]),
203
+ note: z.string().max(280).optional(),
204
+ source: sourceSchema,
205
+ metadata: z.record(z.any()).default({})
206
+ });
207
+ export const governanceRelationActionSchema = z.object({
208
+ action: z.enum(["accept", "reject", "archive"]),
209
+ note: z.string().max(280).optional(),
210
+ source: sourceSchema,
211
+ metadata: z.record(z.any()).default({})
212
+ });
195
213
  export const createRelationSchema = z.object({
196
214
  fromNodeId: z.string().min(1),
197
215
  toNodeId: z.string().min(1),
@@ -314,3 +332,26 @@ export const createWorkspaceSchema = z.object({
314
332
  export const openWorkspaceSchema = z.object({
315
333
  rootPath: z.string().min(1)
316
334
  });
335
+ export const createWorkspaceBackupSchema = z.object({
336
+ label: z.string().min(1).max(80).optional()
337
+ });
338
+ export const exportWorkspaceSchema = z.object({
339
+ format: z.enum(["json", "markdown"]).default("json")
340
+ });
341
+ export const workspaceImportOptionsSchema = z.object({
342
+ normalizeTitleWhitespace: z.boolean().optional(),
343
+ trimBodyWhitespace: z.boolean().optional(),
344
+ duplicateMode: z.enum(["warn", "skip_exact"]).optional()
345
+ });
346
+ export const importWorkspacePreviewSchema = z.object({
347
+ format: z.enum(["recallx_json", "markdown"]),
348
+ sourcePath: z.string().min(1),
349
+ label: z.string().min(1).max(120).optional(),
350
+ options: workspaceImportOptionsSchema.optional()
351
+ });
352
+ export const importWorkspaceSchema = importWorkspacePreviewSchema;
353
+ export const restoreWorkspaceBackupSchema = z.object({
354
+ backupId: z.string().min(1),
355
+ targetRootPath: z.string().min(1),
356
+ workspaceName: z.string().min(1).optional()
357
+ });
@@ -1 +1 @@
1
- export const RECALLX_VERSION = "1.1.0";
1
+ export const RECALLX_VERSION = "1.2.0";