memorix 1.6.3 → 1.7.1
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 +31 -0
- package/dist/cli/index.js +1029 -643
- package/dist/cli/memcode.js +0 -0
- package/dist/dashboard/static/app.js +298 -37
- package/dist/dashboard/static/index.html +17 -0
- package/dist/dashboard/static/style.css +328 -0
- package/dist/index.js +902 -394
- package/dist/memcode-runtime/CHANGELOG.md +31 -0
- package/dist/sdk.js +901 -393
- package/docs/ARCHITECTURE.md +3 -0
- package/docs/DASHBOARD.md +54 -0
- package/docs/README.md +2 -0
- package/package.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/cleanup.ts +13 -114
- package/src/cli/commands/memory.ts +12 -43
- package/src/dashboard/maintenance.ts +303 -0
- package/src/dashboard/server.ts +100 -2
- package/src/dashboard/static/index.html +17 -0
- package/src/dashboard/static/style.css +328 -0
- package/src/memory/cleanup.ts +143 -0
- package/src/memory/deduplication.ts +178 -0
- package/src/server.ts +12 -46
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { isLLMEnabled } from '../llm/provider.js';
|
|
4
|
+
import { analyzeCleanupObservations, applyCleanupMutations } from '../memory/cleanup.js';
|
|
5
|
+
import { findConsolidationCandidates, executeConsolidation } from '../memory/consolidation.js';
|
|
6
|
+
import { applyDeduplicationPlan, planMemoryDeduplication } from '../memory/deduplication.js';
|
|
7
|
+
import { archiveExpired, projectObservationRetention } from '../memory/retention.js';
|
|
8
|
+
import { canManageObservation, filterReadableObservations } from '../memory/visibility.js';
|
|
9
|
+
import type { ObservationStore } from '../store/obs-store.js';
|
|
10
|
+
import type { Observation, ObservationReader } from '../types.js';
|
|
11
|
+
|
|
12
|
+
const previewSecret = randomBytes(32);
|
|
13
|
+
|
|
14
|
+
export class DashboardMaintenanceError extends Error {
|
|
15
|
+
constructor(message: string, readonly status: number) {
|
|
16
|
+
super(message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DashboardMaintenanceContext {
|
|
21
|
+
dataDir: string;
|
|
22
|
+
projectId: string;
|
|
23
|
+
projectRoot: string | null;
|
|
24
|
+
store: ObservationStore;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface CleanupTokenPayload {
|
|
28
|
+
includeNoise: boolean;
|
|
29
|
+
removeIds: number[];
|
|
30
|
+
archiveIds: number[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DeduplicateTokenPayload {
|
|
34
|
+
resolveIds: number[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ConsolidateTokenPayload {
|
|
38
|
+
clusterIds: number[][];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface RetentionTokenPayload {
|
|
42
|
+
archiveIds: number[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function reader(projectId: string): ObservationReader {
|
|
46
|
+
return { projectId };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function summarize(observation: Observation) {
|
|
50
|
+
return {
|
|
51
|
+
id: observation.id,
|
|
52
|
+
title: observation.title,
|
|
53
|
+
type: observation.type,
|
|
54
|
+
entityName: observation.entityName,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeIds(ids: readonly number[]): number[] {
|
|
59
|
+
return [...new Set(ids)].sort((left, right) => left - right);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeClusters(clusters: readonly (readonly number[])[]): number[][] {
|
|
63
|
+
return clusters
|
|
64
|
+
.map((ids) => normalizeIds(ids))
|
|
65
|
+
.sort((left, right) => left.join(',').localeCompare(right.join(',')));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function payloadRecord(payload: unknown): Record<string, unknown> {
|
|
69
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
70
|
+
throw new DashboardMaintenanceError('The maintenance preview payload is missing.', 400);
|
|
71
|
+
}
|
|
72
|
+
return payload as Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function payloadIds(value: unknown, field: string): number[] {
|
|
76
|
+
if (!Array.isArray(value) || value.some((id) => !Number.isSafeInteger(id) || Number(id) <= 0)) {
|
|
77
|
+
throw new DashboardMaintenanceError(`Invalid ${field} in maintenance preview.`, 400);
|
|
78
|
+
}
|
|
79
|
+
return normalizeIds(value as number[]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sign(action: string, projectId: string, payload: object): string {
|
|
83
|
+
return createHmac('sha256', previewSecret)
|
|
84
|
+
.update(JSON.stringify({ action, projectId, payload }))
|
|
85
|
+
.digest('hex');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function verify(action: string, projectId: string, payload: object, token: unknown): void {
|
|
89
|
+
if (typeof token !== 'string' || !/^[a-f0-9]{64}$/.test(token)) {
|
|
90
|
+
throw new DashboardMaintenanceError('Preview this maintenance action before executing it.', 400);
|
|
91
|
+
}
|
|
92
|
+
const expected = Buffer.from(sign(action, projectId, payload), 'hex');
|
|
93
|
+
const received = Buffer.from(token, 'hex');
|
|
94
|
+
if (!timingSafeEqual(expected, received)) {
|
|
95
|
+
throw new DashboardMaintenanceError('The maintenance preview is invalid or belongs to another project.', 409);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assertCurrent(expected: readonly number[], actual: readonly number[]): void {
|
|
100
|
+
if (JSON.stringify(normalizeIds(expected)) !== JSON.stringify(normalizeIds(actual))) {
|
|
101
|
+
throw new DashboardMaintenanceError('Memory changed after the preview. Refresh the preview before executing.', 409);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function manageableActiveObservations(context: DashboardMaintenanceContext): Promise<Observation[]> {
|
|
106
|
+
const scope = reader(context.projectId);
|
|
107
|
+
return filterReadableObservations(
|
|
108
|
+
await context.store.loadByProject(context.projectId, { status: 'active' }),
|
|
109
|
+
scope,
|
|
110
|
+
).filter((observation) => canManageObservation(observation, scope));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cleanupPayload(includeNoise: boolean, analysis: ReturnType<typeof analyzeCleanupObservations>): CleanupTokenPayload {
|
|
114
|
+
return {
|
|
115
|
+
includeNoise,
|
|
116
|
+
removeIds: normalizeIds(analysis.toRemove.map((observation) => observation.id)),
|
|
117
|
+
archiveIds: normalizeIds(analysis.toArchive.map((observation) => observation.id)),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function previewCleanup(context: DashboardMaintenanceContext, includeNoise: boolean) {
|
|
122
|
+
const analysis = analyzeCleanupObservations(await manageableActiveObservations(context), { includeNoise });
|
|
123
|
+
const payload = cleanupPayload(includeNoise, analysis);
|
|
124
|
+
return {
|
|
125
|
+
action: 'cleanup',
|
|
126
|
+
projectId: context.projectId,
|
|
127
|
+
includeNoise,
|
|
128
|
+
summary: {
|
|
129
|
+
totalActive: analysis.totalActive,
|
|
130
|
+
highQuality: analysis.highQuality,
|
|
131
|
+
lowQuality: analysis.lowQuality.length,
|
|
132
|
+
duplicates: analysis.duplicates.length,
|
|
133
|
+
noise: analysis.noise.length,
|
|
134
|
+
delete: analysis.toRemove.length,
|
|
135
|
+
archive: analysis.toArchive.length,
|
|
136
|
+
},
|
|
137
|
+
lowQuality: analysis.lowQuality.map(summarize),
|
|
138
|
+
duplicateGroups: analysis.duplicateGroups.map((group) => ({
|
|
139
|
+
canonical: summarize(group.canonical),
|
|
140
|
+
duplicates: group.duplicates.map(summarize),
|
|
141
|
+
})),
|
|
142
|
+
noise: analysis.noise.map((hit) => ({ ...summarize(hit.observation), reason: hit.reason })),
|
|
143
|
+
payload,
|
|
144
|
+
token: sign('cleanup', context.projectId, payload),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function executeCleanup(
|
|
149
|
+
context: DashboardMaintenanceContext,
|
|
150
|
+
payload: unknown,
|
|
151
|
+
token: unknown,
|
|
152
|
+
) {
|
|
153
|
+
const record = payloadRecord(payload);
|
|
154
|
+
const normalized: CleanupTokenPayload = {
|
|
155
|
+
includeNoise: record.includeNoise === true,
|
|
156
|
+
removeIds: payloadIds(record.removeIds, 'removeIds'),
|
|
157
|
+
archiveIds: payloadIds(record.archiveIds, 'archiveIds'),
|
|
158
|
+
};
|
|
159
|
+
verify('cleanup', context.projectId, normalized, token);
|
|
160
|
+
const observations = await manageableActiveObservations(context);
|
|
161
|
+
const analysis = analyzeCleanupObservations(observations, { includeNoise: normalized.includeNoise });
|
|
162
|
+
const current = cleanupPayload(normalized.includeNoise, analysis);
|
|
163
|
+
assertCurrent(normalized.removeIds, current.removeIds);
|
|
164
|
+
assertCurrent(normalized.archiveIds, current.archiveIds);
|
|
165
|
+
const result = await applyCleanupMutations(context.store, analysis.toArchive, analysis.toRemove);
|
|
166
|
+
return { action: 'cleanup', projectId: context.projectId, ...result };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function previewDeduplicate(context: DashboardMaintenanceContext) {
|
|
170
|
+
if (!isLLMEnabled()) {
|
|
171
|
+
return {
|
|
172
|
+
action: 'deduplicate',
|
|
173
|
+
projectId: context.projectId,
|
|
174
|
+
available: false,
|
|
175
|
+
reason: 'Intelligent deduplication requires a configured memory LLM.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const plan = await planMemoryDeduplication(await manageableActiveObservations(context));
|
|
179
|
+
const payload: DeduplicateTokenPayload = { resolveIds: normalizeIds(plan.resolveIds) };
|
|
180
|
+
return {
|
|
181
|
+
action: 'deduplicate',
|
|
182
|
+
projectId: context.projectId,
|
|
183
|
+
available: true,
|
|
184
|
+
summary: {
|
|
185
|
+
scanned: plan.scanned,
|
|
186
|
+
entities: plan.entities,
|
|
187
|
+
comparisons: plan.comparisons,
|
|
188
|
+
failedComparisons: plan.failedComparisons,
|
|
189
|
+
resolve: payload.resolveIds.length,
|
|
190
|
+
},
|
|
191
|
+
actions: plan.actions,
|
|
192
|
+
payload,
|
|
193
|
+
token: sign('deduplicate', context.projectId, payload),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function executeDeduplicate(
|
|
198
|
+
context: DashboardMaintenanceContext,
|
|
199
|
+
payload: unknown,
|
|
200
|
+
token: unknown,
|
|
201
|
+
) {
|
|
202
|
+
const record = payloadRecord(payload);
|
|
203
|
+
const normalized: DeduplicateTokenPayload = { resolveIds: payloadIds(record.resolveIds, 'resolveIds') };
|
|
204
|
+
verify('deduplicate', context.projectId, normalized, token);
|
|
205
|
+
const result = await applyDeduplicationPlan(
|
|
206
|
+
context.store,
|
|
207
|
+
context.projectId,
|
|
208
|
+
normalized.resolveIds,
|
|
209
|
+
reader(context.projectId),
|
|
210
|
+
);
|
|
211
|
+
if (result.skipped.length > 0) {
|
|
212
|
+
throw new DashboardMaintenanceError('Memory changed after the preview. Refresh before applying deduplication.', 409);
|
|
213
|
+
}
|
|
214
|
+
return { action: 'deduplicate', projectId: context.projectId, ...result };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function previewConsolidate(context: DashboardMaintenanceContext) {
|
|
218
|
+
const clusters = await findConsolidationCandidates(
|
|
219
|
+
context.projectRoot ?? context.dataDir,
|
|
220
|
+
context.projectId,
|
|
221
|
+
);
|
|
222
|
+
const payload: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusters.map((cluster) => cluster.ids)) };
|
|
223
|
+
return {
|
|
224
|
+
action: 'consolidate',
|
|
225
|
+
projectId: context.projectId,
|
|
226
|
+
summary: {
|
|
227
|
+
clusters: clusters.length,
|
|
228
|
+
observations: normalizeIds(clusters.flatMap((cluster) => cluster.ids)).length,
|
|
229
|
+
},
|
|
230
|
+
clusters,
|
|
231
|
+
payload,
|
|
232
|
+
token: sign('consolidate', context.projectId, payload),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function executeConsolidate(
|
|
237
|
+
context: DashboardMaintenanceContext,
|
|
238
|
+
payload: unknown,
|
|
239
|
+
token: unknown,
|
|
240
|
+
) {
|
|
241
|
+
const record = payloadRecord(payload);
|
|
242
|
+
if (!Array.isArray(record.clusterIds)) {
|
|
243
|
+
throw new DashboardMaintenanceError('Invalid clusterIds in maintenance preview.', 400);
|
|
244
|
+
}
|
|
245
|
+
const clusterIds = record.clusterIds.map((ids, index) => payloadIds(ids, `clusterIds[${index}]`));
|
|
246
|
+
const normalized: ConsolidateTokenPayload = { clusterIds: normalizeClusters(clusterIds) };
|
|
247
|
+
verify('consolidate', context.projectId, normalized, token);
|
|
248
|
+
const current = await findConsolidationCandidates(context.projectRoot ?? context.dataDir, context.projectId);
|
|
249
|
+
if (JSON.stringify(normalized.clusterIds) !== JSON.stringify(normalizeClusters(current.map((cluster) => cluster.ids)))) {
|
|
250
|
+
throw new DashboardMaintenanceError('Memory changed after the preview. Refresh before consolidating.', 409);
|
|
251
|
+
}
|
|
252
|
+
const result = await executeConsolidation(context.projectRoot ?? context.dataDir, context.projectId);
|
|
253
|
+
return { action: 'consolidate', projectId: context.projectId, ...result };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function retentionCandidates(context: DashboardMaintenanceContext) {
|
|
257
|
+
return (await manageableActiveObservations(context))
|
|
258
|
+
.map((observation) => ({
|
|
259
|
+
observation,
|
|
260
|
+
retention: projectObservationRetention(observation),
|
|
261
|
+
}))
|
|
262
|
+
.filter((row) => row.retention.zone === 'archive-candidate');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export async function previewRetentionArchive(context: DashboardMaintenanceContext) {
|
|
266
|
+
const candidates = await retentionCandidates(context);
|
|
267
|
+
const payload: RetentionTokenPayload = {
|
|
268
|
+
archiveIds: normalizeIds(candidates.map((candidate) => candidate.observation.id)),
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
action: 'retention-archive',
|
|
272
|
+
projectId: context.projectId,
|
|
273
|
+
summary: { archive: payload.archiveIds.length },
|
|
274
|
+
candidates: candidates.map(({ observation, retention }) => ({
|
|
275
|
+
...summarize(observation),
|
|
276
|
+
score: retention.displayScore,
|
|
277
|
+
zone: retention.zone,
|
|
278
|
+
ageHours: retention.ageHours,
|
|
279
|
+
})),
|
|
280
|
+
payload,
|
|
281
|
+
token: sign('retention-archive', context.projectId, payload),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function executeRetentionArchive(
|
|
286
|
+
context: DashboardMaintenanceContext,
|
|
287
|
+
payload: unknown,
|
|
288
|
+
token: unknown,
|
|
289
|
+
) {
|
|
290
|
+
const record = payloadRecord(payload);
|
|
291
|
+
const normalized: RetentionTokenPayload = { archiveIds: payloadIds(record.archiveIds, 'archiveIds') };
|
|
292
|
+
verify('retention-archive', context.projectId, normalized, token);
|
|
293
|
+
const current = await retentionCandidates(context);
|
|
294
|
+
assertCurrent(normalized.archiveIds, current.map((candidate) => candidate.observation.id));
|
|
295
|
+
const result = await archiveExpired(
|
|
296
|
+
context.dataDir,
|
|
297
|
+
undefined,
|
|
298
|
+
undefined,
|
|
299
|
+
context.projectId,
|
|
300
|
+
reader(context.projectId),
|
|
301
|
+
);
|
|
302
|
+
return { action: 'retention-archive', projectId: context.projectId, ...result };
|
|
303
|
+
}
|
package/src/dashboard/server.ts
CHANGED
|
@@ -26,6 +26,17 @@ import { scopeKnowledgeGraphToProject } from '../memory/graph-scope.js';
|
|
|
26
26
|
import { projectObservationRetention, summarizeRetentionProjections } from '../memory/retention.js';
|
|
27
27
|
import { canManageObservation, filterReadableObservations } from '../memory/visibility.js';
|
|
28
28
|
import type { Observation } from '../types.js';
|
|
29
|
+
import {
|
|
30
|
+
DashboardMaintenanceError,
|
|
31
|
+
executeCleanup,
|
|
32
|
+
executeConsolidate,
|
|
33
|
+
executeDeduplicate,
|
|
34
|
+
executeRetentionArchive,
|
|
35
|
+
previewCleanup,
|
|
36
|
+
previewConsolidate,
|
|
37
|
+
previewDeduplicate,
|
|
38
|
+
previewRetentionArchive,
|
|
39
|
+
} from './maintenance.js';
|
|
29
40
|
|
|
30
41
|
// MIME types for static file serving
|
|
31
42
|
const MIME_TYPES: Record<string, string> = {
|
|
@@ -75,6 +86,68 @@ function isActiveStatus(status?: string): boolean {
|
|
|
75
86
|
return (status ?? 'active') === 'active';
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
async function handleMaintenanceMutation(
|
|
90
|
+
apiPath: string,
|
|
91
|
+
req: IncomingMessage,
|
|
92
|
+
res: ServerResponse,
|
|
93
|
+
context: {
|
|
94
|
+
dataDir: string;
|
|
95
|
+
projectId: string;
|
|
96
|
+
projectRoot: string | null;
|
|
97
|
+
},
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
if (req.method !== 'POST') {
|
|
100
|
+
throw new DashboardMaintenanceError('Maintenance actions require POST.', 405);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let body: Record<string, unknown> = {};
|
|
104
|
+
const raw = await readBody(req);
|
|
105
|
+
if (raw.trim()) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(raw);
|
|
108
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('object required');
|
|
109
|
+
body = parsed as Record<string, unknown>;
|
|
110
|
+
} catch {
|
|
111
|
+
throw new DashboardMaintenanceError('Invalid maintenance request body.', 400);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const maintenanceContext = {
|
|
116
|
+
...context,
|
|
117
|
+
store: getObservationStore(),
|
|
118
|
+
};
|
|
119
|
+
let result: unknown;
|
|
120
|
+
switch (apiPath) {
|
|
121
|
+
case '/maintenance/cleanup/preview':
|
|
122
|
+
result = await previewCleanup(maintenanceContext, body.includeNoise === true);
|
|
123
|
+
break;
|
|
124
|
+
case '/maintenance/cleanup/execute':
|
|
125
|
+
result = await executeCleanup(maintenanceContext, body.payload, body.token);
|
|
126
|
+
break;
|
|
127
|
+
case '/maintenance/deduplicate/preview':
|
|
128
|
+
result = await previewDeduplicate(maintenanceContext);
|
|
129
|
+
break;
|
|
130
|
+
case '/maintenance/deduplicate/execute':
|
|
131
|
+
result = await executeDeduplicate(maintenanceContext, body.payload, body.token);
|
|
132
|
+
break;
|
|
133
|
+
case '/maintenance/consolidate/preview':
|
|
134
|
+
result = await previewConsolidate(maintenanceContext);
|
|
135
|
+
break;
|
|
136
|
+
case '/maintenance/consolidate/execute':
|
|
137
|
+
result = await executeConsolidate(maintenanceContext, body.payload, body.token);
|
|
138
|
+
break;
|
|
139
|
+
case '/maintenance/retention/preview':
|
|
140
|
+
result = await previewRetentionArchive(maintenanceContext);
|
|
141
|
+
break;
|
|
142
|
+
case '/maintenance/retention/execute':
|
|
143
|
+
result = await executeRetentionArchive(maintenanceContext, body.payload, body.token);
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
throw new DashboardMaintenanceError('Unknown maintenance action.', 404);
|
|
147
|
+
}
|
|
148
|
+
sendJson(res, result);
|
|
149
|
+
}
|
|
150
|
+
|
|
78
151
|
/**
|
|
79
152
|
* Prepare project-scoped dashboard config before any route initializes providers.
|
|
80
153
|
* This keeps standalone dashboard behavior aligned with CLI/TUI commands: project
|
|
@@ -155,6 +228,22 @@ async function handleApi(
|
|
|
155
228
|
|
|
156
229
|
try {
|
|
157
230
|
switch (apiPath) {
|
|
231
|
+
case '/maintenance/cleanup/preview':
|
|
232
|
+
case '/maintenance/cleanup/execute':
|
|
233
|
+
case '/maintenance/deduplicate/preview':
|
|
234
|
+
case '/maintenance/deduplicate/execute':
|
|
235
|
+
case '/maintenance/consolidate/preview':
|
|
236
|
+
case '/maintenance/consolidate/execute':
|
|
237
|
+
case '/maintenance/retention/preview':
|
|
238
|
+
case '/maintenance/retention/execute': {
|
|
239
|
+
await handleMaintenanceMutation(apiPath, req, res, {
|
|
240
|
+
dataDir: effectiveDataDir,
|
|
241
|
+
projectId: effectiveProjectId,
|
|
242
|
+
projectRoot: effectiveProjectRoot,
|
|
243
|
+
});
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
158
247
|
case '/projects': {
|
|
159
248
|
// List all unique project IDs from observations data (flat storage)
|
|
160
249
|
// Deduplicate using alias registry – aliased IDs are merged under canonical
|
|
@@ -773,7 +862,8 @@ async function handleApi(
|
|
|
773
862
|
}
|
|
774
863
|
} catch (err) {
|
|
775
864
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
776
|
-
|
|
865
|
+
const status = err instanceof DashboardMaintenanceError ? err.status : 500;
|
|
866
|
+
sendError(res, message, status);
|
|
777
867
|
}
|
|
778
868
|
}
|
|
779
869
|
|
|
@@ -989,7 +1079,15 @@ async function buildTeamSnapshot(dataDir: string, projectId: string, scope: stri
|
|
|
989
1079
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
990
1080
|
return new Promise((resolve, reject) => {
|
|
991
1081
|
const chunks: Buffer[] = [];
|
|
992
|
-
|
|
1082
|
+
let size = 0;
|
|
1083
|
+
req.on('data', (c: Buffer) => {
|
|
1084
|
+
size += c.length;
|
|
1085
|
+
if (size > 64 * 1024) {
|
|
1086
|
+
reject(new DashboardMaintenanceError('Request body is too large.', 413));
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
chunks.push(c);
|
|
1090
|
+
});
|
|
993
1091
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
994
1092
|
req.on('error', reject);
|
|
995
1093
|
});
|
|
@@ -182,6 +182,23 @@
|
|
|
182
182
|
<div id="page-team" class="page"></div>
|
|
183
183
|
</main>
|
|
184
184
|
|
|
185
|
+
<dialog id="maintenance-dialog" class="maintenance-dialog" aria-labelledby="maintenance-dialog-title">
|
|
186
|
+
<div class="maintenance-dialog-shell">
|
|
187
|
+
<header class="maintenance-dialog-header">
|
|
188
|
+
<div>
|
|
189
|
+
<div class="maintenance-dialog-kicker" id="maintenance-dialog-kicker"></div>
|
|
190
|
+
<h2 id="maintenance-dialog-title"></h2>
|
|
191
|
+
</div>
|
|
192
|
+
<button class="icon-btn maintenance-dialog-close" type="button" aria-label="Close" title="Close">
|
|
193
|
+
<span class="iconify" data-icon="lucide:x"></span>
|
|
194
|
+
</button>
|
|
195
|
+
</header>
|
|
196
|
+
<div id="maintenance-dialog-body" class="maintenance-dialog-body"></div>
|
|
197
|
+
<footer id="maintenance-dialog-footer" class="maintenance-dialog-footer"></footer>
|
|
198
|
+
</div>
|
|
199
|
+
</dialog>
|
|
200
|
+
<div id="dashboard-toast" class="dashboard-toast" role="status" aria-live="polite"></div>
|
|
201
|
+
|
|
185
202
|
<!-- Apache ECharts 5 — Knowledge Graph renderer -->
|
|
186
203
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
|
187
204
|
<!-- Cytoscape.js + Dagre — legacy entity-graph fallback -->
|