@evomap/evolver-webui 2.0.0-beta.2 → 2.0.0-beta.22

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/dist/server.js CHANGED
@@ -1,20 +1,51 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { randomBytes, timingSafeEqual } from 'node:crypto';
3
- import { events as ev, assetstore, mailbox as mb, ops } from '@evomap/evolver-core';
3
+ import { events as ev, assetstore, mailbox as mb, ops, util } from '@evomap/evolver-core';
4
4
  import { CONSOLE_HTML } from './console.js';
5
5
  import { EventSnapshotCache, fileEventSnapshotSource } from './eventSnapshot.js';
6
+ import { listLineageAssets, loadAssetLineage } from './assetLineage.js';
7
+ import { eventListRelations } from './observabilityRelations.js';
8
+ import { redactDiagnosticText, sanitizeDiagnosticValue } from './diagnosticSanitize.js';
6
9
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
7
10
  const DASHBOARD_COOKIE = 'evolver_dashboard';
8
- const BROWSER_BLOCKED_PORTS = new Set([
9
- 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95,
10
- 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179,
11
- 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
12
- 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566,
13
- 6665, 6666, 6667, 6668, 6669, 6697, 10080,
14
- ]);
15
11
  /** The empty value summary (zero entries) — the shape /api/value returns when no provider is wired, so the card
16
12
  * always gets a valid ValueSummary to render. Derived from core's aggregator to stay shape-identical. */
17
13
  const EMPTY_VALUE_SUMMARY = ops.valueSummary([]);
14
+ const MEMORY_GRAPH_REASON_PATTERN = /scoped memory-graph outcome ([+-]\d+\.\d{3}) \(boost=([+-]?\d+\.\d{2})\)/;
15
+ const MEMORY_GRAPH_RECOVERY_STATES = new Set(['healthy', 'degraded', 'recovered', 'empty']);
16
+ function boundedMemoryGraphCount(value) {
17
+ const count = Number(value);
18
+ return Number.isFinite(count) && count > 0 ? Math.min(1_000_000, Math.floor(count)) : 0;
19
+ }
20
+ function sanitizeMemoryGraphReason(value) {
21
+ if (typeof value !== 'string')
22
+ return undefined;
23
+ const match = MEMORY_GRAPH_REASON_PATTERN.exec(value);
24
+ if (!match?.[1] || !match[2])
25
+ return undefined;
26
+ const outcome = Number(match[1]);
27
+ const boost = Number(match[2]);
28
+ if (!Number.isFinite(outcome) || !Number.isFinite(boost) || Math.abs(outcome) > 1 || Math.abs(boost) > 1)
29
+ return undefined;
30
+ return `scoped memory-graph outcome ${match[1]} (boost=${match[2]})`;
31
+ }
32
+ function sanitizeMemoryGraphStatus(value) {
33
+ const raw = value;
34
+ const recovery = MEMORY_GRAPH_RECOVERY_STATES.has(raw['recovery'])
35
+ ? raw['recovery']
36
+ : 'degraded';
37
+ const selectionReason = sanitizeMemoryGraphReason(raw['selectionReason']);
38
+ return {
39
+ recovery,
40
+ compactedRecords: boundedMemoryGraphCount(raw['compactedRecords']),
41
+ activeRecords: boundedMemoryGraphCount(raw['activeRecords']),
42
+ corruptLines: boundedMemoryGraphCount(raw['corruptLines']),
43
+ oversizedLines: boundedMemoryGraphCount(raw['oversizedLines']),
44
+ oversizedFiles: boundedMemoryGraphCount(raw['oversizedFiles']),
45
+ archives: boundedMemoryGraphCount(raw['archives']),
46
+ ...(selectionReason ? { selectionReason } : {}),
47
+ };
48
+ }
18
49
  /** Constant-time token compare (avoids leaking the token via timing). */
19
50
  function tokenEq(a, b) {
20
51
  const ba = Buffer.from(a), bb = Buffer.from(b);
@@ -35,6 +66,59 @@ function positiveIntParam(value) {
35
66
  const n = Number(value);
36
67
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
37
68
  }
69
+ function requireGet(req, res) {
70
+ if (req.method === 'GET')
71
+ return true;
72
+ res.writeHead(405, { allow: 'GET', 'content-type': 'application/json' });
73
+ res.end(JSON.stringify({ error: 'method_not_allowed' }));
74
+ return false;
75
+ }
76
+ const STABLE_WORKFLOW_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
77
+ function boundedString(value, maxLength = 256) {
78
+ return typeof value === 'string' ? value.slice(0, maxLength) : '';
79
+ }
80
+ function safeRunSummary(value) {
81
+ const runId = boundedString(value.runId, 128);
82
+ const workflowId = boundedString(value.workflowId, 128);
83
+ if (!STABLE_WORKFLOW_ID.test(runId) || !STABLE_WORKFLOW_ID.test(workflowId))
84
+ return null;
85
+ const currentStep = value.currentStep === null ? null : boundedString(value.currentStep);
86
+ const completedAt = value.completedAt === null ? null : boundedString(value.completedAt, 64);
87
+ return {
88
+ runId,
89
+ workflowId,
90
+ status: boundedString(value.status, 64),
91
+ currentStep,
92
+ createdAt: boundedString(value.createdAt, 64),
93
+ updatedAt: boundedString(value.updatedAt, 64),
94
+ completedAt,
95
+ };
96
+ }
97
+ function safeHistoryEntry(value) {
98
+ if (!Number.isSafeInteger(value.sequence) || value.sequence < 0)
99
+ return null;
100
+ const optional = (candidate, maxLength = 256) => {
101
+ if (candidate === undefined)
102
+ return undefined;
103
+ if (candidate === null)
104
+ return null;
105
+ return boundedString(candidate, maxLength);
106
+ };
107
+ return {
108
+ sequence: value.sequence,
109
+ timestamp: boundedString(value.timestamp, 64),
110
+ type: boundedString(value.type, 128),
111
+ ...(value.status !== undefined ? { status: optional(value.status, 64) } : {}),
112
+ ...(value.stepId !== undefined ? { stepId: optional(value.stepId) } : {}),
113
+ ...(value.executionId !== undefined ? { executionId: optional(value.executionId) } : {}),
114
+ ...(value.gateId !== undefined ? { gateId: optional(value.gateId) } : {}),
115
+ ...(value.attempt !== undefined && (value.attempt === null || (Number.isSafeInteger(value.attempt) && value.attempt >= 0))
116
+ ? { attempt: value.attempt }
117
+ : {}),
118
+ ...(value.actorId !== undefined ? { actorId: optional(value.actorId, 128) } : {}),
119
+ ...(value.errorClass !== undefined ? { errorClass: optional(value.errorClass, 64) } : {}),
120
+ };
121
+ }
38
122
  const HUMAN_ACTIONS = {
39
123
  observe: 'actor.human.observe', nudge: 'actor.human.nudge', intervene: 'actor.human.intervene', teach: 'actor.human.teach',
40
124
  };
@@ -51,6 +135,7 @@ export class WebUIServer {
51
135
  actorId;
52
136
  /** Review ledger backing the human-review queue. Undefined when no LocalJsonlProvider store is available. */
53
137
  review;
138
+ provenance;
54
139
  /** Token guarding /api/*; supplied by the browser via Bearer, with ?token= retained for compatibility. */
55
140
  token;
56
141
  launchTicket;
@@ -67,14 +152,15 @@ export class WebUIServer {
67
152
  this.ingestor = deps.ingestor ?? new ev.Ingestor({ path: deps.eventsPath });
68
153
  // Co-locate the review ledger with the store so the queue reads the same review.jsonl the CLI writes.
69
154
  this.review = deps.review ?? (deps.store instanceof assetstore.LocalJsonlProvider ? new assetstore.ReviewLedger(deps.store.baseDir) : undefined);
155
+ this.provenance = deps.provenance ?? (deps.store instanceof assetstore.LocalJsonlProvider ? new assetstore.ProvenanceStore(deps.store.baseDir) : undefined);
70
156
  this.server = createServer((req, res) => { void this.handle(req, res); });
71
157
  }
72
158
  async listen(port = 0) {
73
- if (port !== 0 && BROWSER_BLOCKED_PORTS.has(port))
159
+ if (port !== 0 && util.isFetchForbiddenPort(port))
74
160
  throw new Error('webui_port_blocked');
75
161
  for (let attempt = 0; attempt < 5; attempt += 1) {
76
162
  const assigned = await this.listenOnce(port);
77
- if (port !== 0 || !BROWSER_BLOCKED_PORTS.has(assigned))
163
+ if (port !== 0 || !util.isFetchForbiddenPort(assigned))
78
164
  return assigned;
79
165
  await this.close();
80
166
  }
@@ -148,27 +234,109 @@ export class WebUIServer {
148
234
  }
149
235
  }
150
236
  if (p === '/api/status')
151
- return this.json(res, ev.statusReport(this.eventSnapshots.read()));
237
+ return this.json(res, ev.statusReport(await this.eventSnapshots.read()));
152
238
  if (p === '/api/cycles')
153
- return this.json(res, ev.listCycles(this.eventSnapshots.read()));
154
- if (p === '/api/cycle')
155
- return this.json(res, ev.showCycle(this.eventSnapshots.read(), url.searchParams.get('id') ?? ''));
239
+ return this.json(res, ev.listCycles(await this.eventSnapshots.read()));
240
+ if (p === '/api/cycle') {
241
+ const cycle = ev.showCycle(await this.eventSnapshots.read(), url.searchParams.get('id') ?? '');
242
+ const safeTimeline = cycle.timeline.map((event) => ({
243
+ ...event,
244
+ title: redactDiagnosticText(event.title),
245
+ ...(event.why ? { why: redactDiagnosticText(event.why) } : {}),
246
+ ...(event.payload ? { payload: sanitizeDiagnosticValue(event.payload) } : {}),
247
+ }));
248
+ const relationEvents = cycle.timeline.map((event) => ({
249
+ seq: event.seq, type: event.type, ts: event.ts, payload: event.payload,
250
+ human: { title: event.title, ...(event.why ? { why: event.why } : {}) },
251
+ }));
252
+ return this.json(res, { ...cycle, timeline: safeTimeline, relations: eventListRelations(relationEvents) });
253
+ }
156
254
  if (p === '/api/narrative') {
157
- return this.json(res, ev.buildNarrativeSnapshot(this.eventSnapshots.read(), { limit: positiveIntParam(url.searchParams.get('limit')) }));
255
+ return this.json(res, ev.buildNarrativeSnapshot(await this.eventSnapshots.read(), { limit: positiveIntParam(url.searchParams.get('limit')) }));
158
256
  }
159
257
  if (p === '/api/triggers')
160
- return this.json(res, ev.listTriggers(this.eventSnapshots.read()));
258
+ return this.json(res, ev.listTriggers(await this.eventSnapshots.read()));
259
+ if (p === '/api/evolution-graph') {
260
+ // Read-only projection of the SAME event snapshot the other cards read. The WebUI never re-derives lineage:
261
+ // core owns the projector, the server only bounds the window and serializes the summary + edge list.
262
+ if (!requireGet(req, res))
263
+ return;
264
+ try {
265
+ const graph = ops.projectEvolutionGraph(await this.eventSnapshots.read(), {
266
+ maxEvents: positiveIntParam(url.searchParams.get('maxEvents')),
267
+ });
268
+ return this.json(res, {
269
+ available: true,
270
+ graphId: graph.graphId,
271
+ generatedAt: graph.generatedAt,
272
+ dashboard: graph.dashboard,
273
+ nodes: graph.nodes.map((node) => ({ id: node.id, kind: node.kind, label: redactDiagnosticText(node.label) })),
274
+ edges: graph.edges.map((edge) => ({
275
+ id: edge.id,
276
+ kind: edge.kind,
277
+ from: edge.from,
278
+ to: edge.to,
279
+ ...(edge.reason ? { reason: redactDiagnosticText(edge.reason) } : {}),
280
+ ...(edge.metricDelta ? { metricDelta: edge.metricDelta } : {}),
281
+ provenance: edge.provenance.map((entry) => ({ kind: entry.kind, ref: entry.ref })),
282
+ })),
283
+ });
284
+ }
285
+ catch {
286
+ return this.send(res, 503, 'application/json', JSON.stringify({
287
+ available: false,
288
+ error: 'evolution_graph_unavailable',
289
+ }));
290
+ }
291
+ }
161
292
  if (p === '/api/daily-summary') {
162
293
  const day = url.searchParams.get('day') ?? new Date(this.now()).toISOString().slice(0, 10);
163
- return this.json(res, ev.dailySummary(this.eventSnapshots.read(), day));
294
+ return this.json(res, ev.dailySummary(await this.eventSnapshots.read(), day));
164
295
  }
165
296
  if (p === '/api/value') {
166
297
  // Thin pass-through: the provider (composition layer) owns prices + traces; the server only scopes the
167
298
  // window and serializes. No provider wired → an empty summary so the card renders "no savings yet".
168
299
  const window = ops.windowFromSpec(url.searchParams.get('window') ?? undefined, this.now());
169
- const summary = this.deps.valueSummary ? this.deps.valueSummary(window, this.eventSnapshots.read()) : EMPTY_VALUE_SUMMARY;
300
+ const summary = this.deps.valueSummary ? this.deps.valueSummary(window, await this.eventSnapshots.read()) : EMPTY_VALUE_SUMMARY;
170
301
  return this.json(res, summary);
171
302
  }
303
+ if (p === '/api/personality') {
304
+ if (!requireGet(req, res))
305
+ return;
306
+ return this.json(res, this.deps.personalityDiagnostics
307
+ ? await this.deps.personalityDiagnostics()
308
+ : { available: false, error: 'personality_unavailable' });
309
+ }
310
+ if (p === '/api/memory-graph') {
311
+ if (!requireGet(req, res))
312
+ return;
313
+ if (!this.deps.memoryGraphStatus)
314
+ return this.json(res, { available: false });
315
+ try {
316
+ const status = sanitizeMemoryGraphStatus(this.deps.memoryGraphStatus());
317
+ return this.json(res, { available: true, ...status });
318
+ }
319
+ catch {
320
+ return this.send(res, 503, 'application/json', JSON.stringify({
321
+ available: false,
322
+ error: 'memory_graph_unavailable',
323
+ }));
324
+ }
325
+ }
326
+ if (p === '/api/logs') {
327
+ if (!requireGet(req, res))
328
+ return;
329
+ return this.json(res, this.deps.logDiagnostics
330
+ ? await this.deps.logDiagnostics()
331
+ : { available: false, error: 'logs_unavailable' });
332
+ }
333
+ if (p === '/api/github-prs') {
334
+ if (!requireGet(req, res))
335
+ return;
336
+ return this.json(res, this.deps.githubPrDiagnostics
337
+ ? await this.deps.githubPrDiagnostics()
338
+ : { available: false, error: 'github_prs_unavailable' });
339
+ }
172
340
  if (p === '/api/retention') {
173
341
  if (!this.deps.retentionReport)
174
342
  return this.json(res, { available: false });
@@ -182,6 +350,47 @@ export class WebUIServer {
182
350
  }));
183
351
  }
184
352
  }
353
+ if (p === '/api/workflows') {
354
+ if (req.method !== 'GET')
355
+ return this.methodNotAllowed(res, 'GET');
356
+ if (!this.deps.workflow)
357
+ return this.json(res, { workflows: [] });
358
+ try {
359
+ const workflows = (await this.deps.workflow.listRuns())
360
+ .map(safeRunSummary)
361
+ .filter((run) => run !== null);
362
+ return this.json(res, { workflows });
363
+ }
364
+ catch {
365
+ return this.apiError(res, 503, 'workflow_unavailable');
366
+ }
367
+ }
368
+ if (p === '/api/workflow' || p === '/api/workflow/history') {
369
+ if (req.method !== 'GET')
370
+ return this.methodNotAllowed(res, 'GET');
371
+ const runId = url.searchParams.get('id') ?? '';
372
+ if (!STABLE_WORKFLOW_ID.test(runId))
373
+ return this.apiError(res, 400, 'invalid_workflow_id');
374
+ if (!this.deps.workflow)
375
+ return this.apiError(res, 503, 'workflow_unavailable');
376
+ try {
377
+ if (p === '/api/workflow') {
378
+ const candidate = await this.deps.workflow.getRun(runId);
379
+ const run = candidate === null ? null : safeRunSummary(candidate);
380
+ return run ? this.json(res, { workflow: run }) : this.apiError(res, 404, 'workflow_not_found');
381
+ }
382
+ const history = await this.deps.workflow.getHistory(runId);
383
+ if (history === null)
384
+ return this.apiError(res, 404, 'workflow_not_found');
385
+ return this.json(res, {
386
+ runId,
387
+ history: history.map(safeHistoryEntry).filter((entry) => entry !== null),
388
+ });
389
+ }
390
+ catch {
391
+ return this.apiError(res, 503, 'workflow_unavailable');
392
+ }
393
+ }
185
394
  if (p === '/api/mailbox') {
186
395
  if (!this.deps.mailbox)
187
396
  return this.json(res, { pending: 0, dlq: 0, messages: [] });
@@ -197,6 +406,31 @@ export class WebUIServer {
197
406
  const kind = url.searchParams.get('kind');
198
407
  return this.json(res, (await this.deps.store.list(kind ?? undefined, 100)).map((a) => ({ asset_id: a.asset_id, type: a.type, summary: a.summary })));
199
408
  }
409
+ if (p === '/api/asset-lineage/assets') {
410
+ if (!requireGet(req, res))
411
+ return;
412
+ return this.json(res, await listLineageAssets(this.deps.store, {
413
+ page: positiveIntParam(url.searchParams.get('page')),
414
+ pageSize: positiveIntParam(url.searchParams.get('pageSize')),
415
+ }));
416
+ }
417
+ if (p === '/api/asset-lineage') {
418
+ if (!requireGet(req, res))
419
+ return;
420
+ return this.json(res, await loadAssetLineage({
421
+ store: this.deps.store,
422
+ events: () => this.eventSnapshots.read(),
423
+ review: this.review,
424
+ provenance: this.provenance,
425
+ }, url.searchParams.get('id') ?? '', {
426
+ page: positiveIntParam(url.searchParams.get('page')),
427
+ pageSize: positiveIntParam(url.searchParams.get('pageSize')),
428
+ capsulePage: positiveIntParam(url.searchParams.get('capsulePage')),
429
+ capsulePageSize: positiveIntParam(url.searchParams.get('capsulePageSize')),
430
+ eventPage: positiveIntParam(url.searchParams.get('eventPage')),
431
+ eventPageSize: positiveIntParam(url.searchParams.get('eventPageSize')),
432
+ }));
433
+ }
200
434
  if (p === '/api/review' && req.method === 'POST') {
201
435
  // Approve/reject a quarantined draft from the console: the SAME gate the CLI uses — flip the ReviewLedger
202
436
  // state + record an audited actor.human.review.* event. Loopback + bearer already guard the route; actor is
@@ -236,7 +470,7 @@ export class WebUIServer {
236
470
  // record) are appended as context from a bounded list. Pending (quarantined) sorts first. Read-only here.
237
471
  // Match the CLI's semantics exactly: only the unattended distill-observer counts as "auto-drafted"
238
472
  // (manual `ingest --distill` / skill2gep emit gene.distilled with a different source and are NOT auto).
239
- const autoDrafted = new Set(this.eventSnapshots.read()
473
+ const autoDrafted = new Set((await this.eventSnapshots.read())
240
474
  .filter((e) => e.type === 'gene.distilled' && e.payload?.['source'] === 'distill-observer')
241
475
  .map((e) => String(e.payload?.['assetId'] ?? ''))
242
476
  .filter(Boolean));
@@ -271,8 +505,8 @@ export class WebUIServer {
271
505
  }
272
506
  return this.send(res, 404, 'application/json', JSON.stringify({ error: 'not found' }));
273
507
  }
274
- catch (e) {
275
- return this.send(res, 500, 'application/json', JSON.stringify({ error: e instanceof Error ? e.message : String(e) }));
508
+ catch {
509
+ return this.send(res, 500, 'application/json', JSON.stringify({ error: 'dashboard_request_failed' }));
276
510
  }
277
511
  }
278
512
  readJson(req) {
@@ -293,5 +527,10 @@ export class WebUIServer {
293
527
  });
294
528
  }
295
529
  json(res, body) { this.send(res, 200, 'application/json', JSON.stringify(body)); }
530
+ apiError(res, code, error) { this.send(res, code, 'application/json', JSON.stringify({ error })); }
531
+ methodNotAllowed(res, allow) {
532
+ res.writeHead(405, { allow, 'content-type': 'application/json' });
533
+ res.end(JSON.stringify({ error: 'method_not_allowed' }));
534
+ }
296
535
  send(res, code, ct, body) { res.writeHead(code, { 'content-type': ct }); res.end(body); }
297
536
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@evomap/evolver-webui",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.22",
4
4
  "private": false,
5
5
  "type": "module",
6
+ "engines": {
7
+ "node": "^22.13.0 || >=23.4.0"
8
+ },
6
9
  "description": "保活/可视化 WebUI",
7
10
  "main": "./dist/index.js",
8
11
  "types": "./dist/index.d.ts",
@@ -13,7 +16,12 @@
13
16
  }
14
17
  },
15
18
  "dependencies": {
16
- "@evomap/evolver-core": "2.0.0-beta.2"
19
+ "@evomap/evolver-core": "2.0.0-beta.22",
20
+ "jsonc-parser": "3.3.1"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/EvoMap/evolver.git"
17
25
  },
18
26
  "publishConfig": {
19
27
  "access": "public",