@sanqianx/project-knowledge 5.0.16-rc.8 → 5.0.17-rc.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.
@@ -9,7 +9,7 @@
9
9
  "plugins": [
10
10
  {
11
11
  "name": "project-knowledge",
12
- "version": "5.0.16-rc.8",
12
+ "version": "5.0.17-rc.1",
13
13
  "source": "./plugins/project-knowledge",
14
14
  "description": "Search durable project decisions and implementation history through MCP."
15
15
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.0.16] - 2026-10-15
4
+
5
+ - 收敛 rc.8 已通过的 30 项核心门禁:修复真实主题证据遗漏、短向量子片段重复占位与小型明确提交上下文不完整,保留当前文件校验、记忆优先级及上下文预算。
6
+ - 精确锁定 Workbench 0.13.2-rc.13、vector-hub 1.0.8-rc.8、event-bridge 0.7.2 和 vendored engine 0.2.7-rc.9;保留原生流式会话、项目双目录和 D 盘索引。
7
+ - Codex 本地市场与 MCP 接线使用真实原生命令验证;四客户端实际接入,Claude/Codex/ZCode 首次开发请求的事实与产品记忆送达均实测通过,OpenCode 保留真实工具检索兼容,不冒称自动注入。
8
+ - 正式包先发布 next 并重新下载安装验收,完成后才更新 latest;候选通过不等于正式包已验收。首次失败、升级保护与完整证据见版本验收记录。
9
+
3
10
  ## [5.0.16-rc.8] - 2026-10-15
4
11
 
5
12
  - 纳入引擎 0.2.7-rc.9:经验证的短向量子范围与包含它的 Markdown 段落不再重复占用片段名额;保留较大独立向量范围与原有校验,不扩散向量分数。
@@ -22,6 +22,10 @@
22
22
 
23
23
  ## 与 shell 的接线契约(模块契约)
24
24
 
25
+ 运行投影支持 `GET /api/snapshot?summary=1&limit=50` 和 `GET /api/runs?summary=1&projectId=...&before=<cursor>`,每页最多 100 条;概要不携带 staging 和冻结证据,完整详情仍由 `GET /api/runs/:id` 读取。未指定概要参数时保留旧接口。进度订阅 `summary=1` 的初始快照只含游标,消费者先读取有界快照。
26
+
27
+ 受管激活 `POST /api/control` 可传入已安全接线的 `projectIds` 白名单;未接线项目的任务保留排队,不能执行或恢复晋升。新增可用项目只恢复其自身的晋升与索引,重复激活不重复恢复;服务就绪与单项目接线失败分别投影。
28
+
25
29
  - **env**:`KB_KNOWLEDGE_ENGINE_URL`(默认 `http://127.0.0.1:5790`)、`KB_KNOWLEDGE_ENGINE_COMMAND`(显式命令行覆盖)、随家族一起受 `KB_MODULES_AUTOSTART=0` 约束。
26
30
  - **supervisor**:shell `_supervisedSpecs()` 增加本模块条目(vendored runtime 位于 `project-knowledge-base/_modules/knowledge-engine/`),探测已监听则不重复拉起,退出指数退避重启。
27
31
  - **反代**:`/api/engine/<rest>` → 本服务 `/api/<rest>`,浏览器同源,无 CORS。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanqianx/ai-coding-knowledge-engine",
3
- "version": "0.2.7-rc.9",
3
+ "version": "0.2.7-rc.10",
4
4
  "description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
5
5
  "license": "Apache-2.0",
6
6
  "main": "src/server.js",
@@ -96,15 +96,19 @@ class EngineCoordinator extends EventEmitter {
96
96
  this.pump();
97
97
  if (this.executionEnabled) for (const run of this.store.state.runs.filter(r => r.state === 'index-pending')) this.retry(run.runId);
98
98
  }
99
- async activate() {
99
+ async activate(projectIds) {
100
100
  await this.ready;
101
- if (this.executionEnabled && this.consume) return;
102
- await this.promotion.recoverAll();
101
+ const allowed = projectIds ? new Set(projectIds) : null;
102
+ const wasEnabled = this.executionEnabled && this.consume;
103
+ const newlyAllowed = allowed ? new Set([...allowed].filter(id => !this.allowedProjects?.has(id))) : null;
104
+ if (wasEnabled && newlyAllowed?.size === 0) { this.allowedProjects = allowed; this.pump(); return; }
105
+ await this.promotion.recoverAll(wasEnabled ? newlyAllowed : allowed);
106
+ this.allowedProjects = allowed;
103
107
  this.executionEnabled = true;
104
108
  this.consume = true;
105
109
  await this.drain();
106
110
  if (!this.poll) { this.poll = setInterval(() => this.drain(), 2000); this.poll.unref(); }
107
- for (const run of this.store.state.runs.filter(r => r.state === 'index-pending')) this.retry(run.runId);
111
+ for (const run of this.store.state.runs.filter(r => r.state === 'index-pending' && (!allowed || allowed.has(r.projectId)))) this.retry(run.runId);
108
112
  this.pump();
109
113
  }
110
114
  enqueue(input) {
@@ -190,7 +194,7 @@ class EngineCoordinator extends EventEmitter {
190
194
  }
191
195
  pump() {
192
196
  if (this.closed || !this.executionEnabled) return;
193
- const queued = this.store.state.runs.filter(r => r.state === 'queued').sort((a, b) => a.sequence - b.sequence);
197
+ const queued = this.store.state.runs.filter(r => r.state === 'queued' && (!this.allowedProjects || this.allowedProjects.has(r.projectId))).sort((a, b) => a.sequence - b.sequence);
194
198
  for (const run of queued) {
195
199
  if (this.active.size >= this.concurrency) break;
196
200
  if (this.active.has(run.projectId)) continue;
@@ -520,7 +520,7 @@ class KnowledgePromotionService {
520
520
  return this.rollbackJournal(journal);
521
521
  }
522
522
 
523
- async recoverAll() {
523
+ async recoverAll(allowedProjects = null) {
524
524
  const root = this.layout.getRuntimePath('promotions');
525
525
  if (!fs.existsSync(root)) return [];
526
526
  const files = [];
@@ -541,7 +541,7 @@ class KnowledgePromotionService {
541
541
  return value;
542
542
  },
543
543
  });
544
- results.push({ file, ...(await this.recoverJournal(journal)) });
544
+ if (!allowedProjects || allowedProjects.has(journal.projectId)) results.push({ file, ...(await this.recoverJournal(journal)) });
545
545
  }
546
546
  return results;
547
547
  }
@@ -36,7 +36,7 @@ function createEngineServer(options = {}) {
36
36
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
37
37
  connections.add(res);
38
38
  const projectId = url.searchParams.get('projectId');
39
- res.write(`event: snapshot\ndata: ${JSON.stringify({ cursor: store.state.cursor, runs: store.listRuns(projectId || null) })}\n\n`);
39
+ res.write(`event: snapshot\ndata: ${JSON.stringify({ cursor: store.state.cursor, ...(url.searchParams.get('summary') === '1' ? {} : { runs: store.listRuns(projectId || null) }) })}\n\n`);
40
40
  const listener = event => { if ((!projectId || event.projectId === projectId) && !res.destroyed) res.write(`id: ${event.seq || store.state.cursor}\nevent: progress\ndata: ${JSON.stringify(event)}\n\n`); };
41
41
  coordinator.on('progress', listener);
42
42
  const heartbeat = setInterval(() => { if (!res.destroyed) res.write(': heartbeat\n\n'); }, 15000);
@@ -52,7 +52,8 @@ function createEngineServer(options = {}) {
52
52
  if (pathname === '/api/control' && req.method === 'POST') {
53
53
  if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳切换运行所有权。');
54
54
  if (body.enabled !== true) throw httpError(400, 'INVALID_ARGUMENT', '只支持外壳完成接线后的启用操作。');
55
- await coordinator.activate(); send(res, 200, { ok: true }); return;
55
+ if (body.projectIds !== undefined && (!Array.isArray(body.projectIds) || body.projectIds.some(id => typeof id !== 'string' || !store.getProject(id)))) throw httpError(400, 'INVALID_ARGUMENT', '启用项目未登记。');
56
+ await coordinator.activate(body.projectIds); send(res, 200, { ok: true }); return;
56
57
  }
57
58
  if (pathname === '/api/runtime-config' && req.method === 'POST') {
58
59
  if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任本地服务解析配置。');
@@ -125,7 +126,7 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
125
126
  if (!result.removed) throw httpError(404, 'NOT_FOUND', '项目未登记。');
126
127
  return { ...result, keptData: true };
127
128
  }
128
- if (method === 'GET' && pathname === '/api/runs') return { runs: store.listRuns(search.get('projectId')) };
129
+ if (method === 'GET' && pathname === '/api/runs') return search.get('summary') === '1' ? store.listRunPage({ projectId: search.get('projectId'), limit: search.get('limit') || 50, before: search.get('before') }) : { runs: store.listRuns(search.get('projectId')) };
129
130
  const runMatch = pathname.match(/^\/api\/runs\/([^/]+)(\/retry)?$/);
130
131
  if (runMatch) {
131
132
  const id = decodeURIComponent(runMatch[1]);
@@ -142,7 +143,7 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
142
143
  if (method === 'GET' && pathname === '/api/events') return { events: store.listEvents(search.get('limit')).filter(e => !search.get('projectId') || e.projectId === search.get('projectId')) };
143
144
  if (method === 'POST' && pathname === '/api/events' && ctx.debug) return { event: store.addEvent(body) };
144
145
  if (method === 'POST' && pathname === '/api/bridge/notify') { if (coordinator.consume) coordinator.drain(); return { accepted: true }; }
145
- if (method === 'GET' && pathname === '/api/snapshot') return { projects: store.listProjects(), runs: store.listRuns(search.get('projectId')), events: store.listEvents(50), memories: coordinator.memories.list(), sharedScopes: store.state.sharedScopes };
146
+ if (method === 'GET' && pathname === '/api/snapshot') return { projects: store.listProjects(), ...(search.get('summary') === '1' ? store.listRunPage({ projectId: search.get('projectId'), limit: search.get('limit') || 50, before: search.get('before') }) : { runs: store.listRuns(search.get('projectId')) }), events: store.listEvents(50), memories: coordinator.memories.list(), sharedScopes: store.state.sharedScopes };
146
147
  throw httpError(404, 'NOT_FOUND', '接口不存在。');
147
148
  }
148
149
 
@@ -153,6 +153,15 @@ class EngineStore {
153
153
  return this.state.runs.find(r => r.runId === runId) || null;
154
154
  }
155
155
 
156
+ listRunPage({ projectId, limit = 50, before } = {}) {
157
+ limit = Number(limit);
158
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100 || before != null && !/^[1-9]\d*$/.test(String(before))) throw Object.assign(new Error('运行分页参数无效。'), { status: 400, code: 'INVALID_ARGUMENT' });
159
+ const all = this.state.runs.filter(run => !projectId || run.projectId === projectId);
160
+ const rows = all.filter(run => before == null || run.sequence < Number(before)).sort((a, b) => b.sequence - a.sequence);
161
+ const page = rows.slice(0, limit);
162
+ return { runs: page.map(summaryRun), nextCursor: rows.length > limit ? String(page.at(-1).sequence) : null, total: all.length };
163
+ }
164
+
156
165
  addRun(input) {
157
166
  if (!/^[a-f0-9]{40}$/i.test(input.commitSha || '')) throw Object.assign(new Error('必须提供完整的真实提交 SHA。'), { status: 400, code: 'INVALID_ARGUMENT' });
158
167
  const prior = this.state.runs.find(r => r.projectId === input.projectId && r.commitSha === input.commitSha.toLowerCase());
@@ -242,4 +251,7 @@ class EngineStore {
242
251
 
243
252
  function initialRuntime(project = {}) { return { lastAnalyzedCommit: project.trackingStartCommit || null, conversationBaselineCursor: project.conversationBaselineCursor ?? null, conversationBaselineCursors: project.conversationBaselineCursors || {}, consumedRequirements: [], analysis: { status: 'idle', activeClaim: null, lastError: null }, index: { generation: 0, dirty: false, status: 'pending' } }; }
244
253
 
245
- module.exports = { EngineStore, initialRuntime };
254
+ function summaryRun(run) {
255
+ return Object.fromEntries(['runId', 'projectId', 'commitSha', 'sequence', 'state', 'phase', 'trigger', 'startedAt', 'finishedAt', 'promotedAt', 'indexState', 'error', 'memoryCapture'].filter(key => run[key] !== undefined).map(key => [key, run[key]]));
256
+ }
257
+ module.exports = { EngineStore, initialRuntime, summaryRun };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const { ModuleBridge } = require('../lib/module-bridge');
4
+ (async () => {
5
+ const bridge = new ModuleBridge({ dataDir: process.cwd() });
6
+ let release, entered;
7
+ const started = new Promise(resolve => { entered = resolve; });
8
+ const hold = new Promise(resolve => { release = resolve; });
9
+ let writes = 0;
10
+ bridge._fetch = async (url, init) => {
11
+ if (url.endsWith('/api/runtime-config')) {
12
+ if (++writes === 1) { entered(); await hold; }
13
+ return { ok: true, json: async () => ({ ok: true }) };
14
+ }
15
+ return { ok: true, json: async () => ({ rootPath: 'not-a-real-storage-path' }) };
16
+ };
17
+ const first = bridge.syncEngineConfiguration(); await started;
18
+ bridge.resetRuntimeConfiguration(); release(); await first;
19
+ assert.equal(writes, 2, 'a module restart while configuration is in flight triggers one merged replay');
20
+ await bridge.syncEngineConfiguration(); assert.equal(writes, 2, 'unchanged configuration is cached only in the new generation');
21
+ console.log('configuration-generation-test PASS (late pre-restart acknowledgements cannot suppress new configuration)');
22
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,385 @@
1
+ 'use strict';
2
+ // GLM independent QA for the web part of docs/plans/ui-refresh-electron-plan-2026-10-13.md.
3
+ // Node/HTTP/VM only: no browser, no CDP; stubs listen on 127.0.0.1:0; no product process.
4
+ // Temp data stays in .local-test-data/ui-refresh-2026-10-15/ and is never deleted here.
5
+ //
6
+ // Every risk is probed twice: baseline = the pre-fix sources from git HEAD (first-failure
7
+ // evidence), current = the working tree (post-fix verification, the Leader is fixing
8
+ // production code concurrently). Gates encode the plan's desired behavior.
9
+ // A first screen / B slow module / C engine takeover / D config sync / E 8-tab SSE.
10
+
11
+ const assert = require('node:assert/strict');
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const http = require('node:http');
15
+ const vm = require('node:vm');
16
+ const { execFileSync } = require('node:child_process');
17
+
18
+ const ROOT = path.resolve(__dirname, '../..');
19
+ const { ModuleBridge } = require('../lib/module-bridge');
20
+ const { connectEngine } = require('../lib/engine-connection');
21
+
22
+ const TEMP_DATA = path.join(ROOT, '.local-test-data', 'ui-refresh-2026-10-15');
23
+ fs.mkdirSync(TEMP_DATA, { recursive: true });
24
+
25
+ const allServers = [];
26
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
27
+ const gates = [];
28
+
29
+ async function gate(section, name, run) {
30
+ try { await run(); gates.push({ section, name, ok: true }); console.log('[PASS] ' + section + ' ' + name); }
31
+ catch (error) { gates.push({ section, name, ok: false, message: error && error.message }); console.log('[FAIL] ' + section + ' ' + name + ' :: ' + (error && error.message)); }
32
+ }
33
+
34
+ function headSource(relPath) {
35
+ return execFileSync('git', ['show', '55e162762950a069cf00e826c9f9b8eea5c768e5:' + relPath], { encoding: 'utf8', cwd: ROOT, maxBuffer: 16 * 1024 * 1024 });
36
+ }
37
+
38
+ const headModuleCache = new Map();
39
+ function loadHeadModule(relPath) {
40
+ if (headModuleCache.has(relPath)) return headModuleCache.get(relPath);
41
+ const moduleObject = { exports: {} };
42
+ const directory = path.dirname(path.join(ROOT, relPath));
43
+ const sandboxRequire = request => require(request.startsWith('.') ? path.join(directory, request) : request);
44
+ const sandbox = {
45
+ require: sandboxRequire, module: moduleObject, exports: moduleObject.exports,
46
+ fetch, AbortController, setTimeout, clearTimeout, setInterval, clearInterval, Buffer, URL, URLSearchParams, console,
47
+ process: { env: {}, versions: { node: process.versions.node }, platform: process.platform },
48
+ };
49
+ sandbox.globalThis = sandbox;
50
+ vm.runInNewContext(headSource(relPath), sandbox, { filename: relPath });
51
+ headModuleCache.set(relPath, moduleObject.exports);
52
+ return moduleObject.exports;
53
+ }
54
+
55
+ function startStub(routes) {
56
+ const counters = {};
57
+ const server = http.createServer((req, res) => {
58
+ let body = '';
59
+ req.on('data', chunk => { body += chunk; });
60
+ req.on('end', () => {
61
+ const cleanPath = req.url.split('?')[0];
62
+ const route = routes.find(r => (r.method === 'ANY' || r.method === req.method) && r.path.test(cleanPath));
63
+ if (!route) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{}'); return; }
64
+ if (route.hang) return; // accepted the socket and never responds: the hung module
65
+ counters[route.name] = (counters[route.name] || 0) + 1;
66
+ if (route.countWith) { const key = route.countWith(body); if (key) counters[key] = (counters[key] || 0) + 1; }
67
+ let payload = {};
68
+ let status = route.status || 200;
69
+ if (route.handle) {
70
+ payload = route.handle(cleanPath, body);
71
+ if (payload && payload.__status) { status = payload.__status; delete payload.__status; }
72
+ }
73
+ res.writeHead(status, { 'Content-Type': 'application/json' });
74
+ res.end(JSON.stringify(payload));
75
+ });
76
+ });
77
+ allServers.push(server);
78
+ return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ server, url: 'http://127.0.0.1:' + server.address().port, counters })));
79
+ }
80
+
81
+ async function makeEnv(options = {}) {
82
+ const failProjects = options.engineFailProjects || [];
83
+ const BridgeClass = options.baseline ? loadHeadModule('_site/lib/module-bridge.js').ModuleBridge : ModuleBridge;
84
+ const connect = options.baseline ? loadHeadModule('_site/lib/engine-connection.js').connectEngine : connectEngine;
85
+ const terminal = await startStub([
86
+ { method: 'GET', path: /\/projects\/?$/, name: 'terminal-list', handle: () => ({ projects: options.terminalProjects || [] }) },
87
+ { method: 'POST', path: /\/claude-workbench\/v1\/projects\/?$/, name: 'terminal-register', handle: () => ({ id: 't1' }) },
88
+ { method: 'POST', path: /internal\/maintenance-config$/, name: 'terminal-maintenance', status: 500, handle: () => ({}) },
89
+ { method: 'POST', path: /internal\/embedding-config$/, name: 'terminal-embedding', status: 500, handle: () => ({}) },
90
+ ]);
91
+ const vectorHub = await startStub([
92
+ { method: 'GET', path: /^\/api\/projects\/?$/, name: 'vector-list', hang: !!options.vectorHubHung, handle: () => ({ projects: [] }) },
93
+ { method: 'GET', path: /^\/api\/health\/?$/, name: 'vector-health', hang: !!options.vectorHubHung, handle: () => ({ ok: true }) },
94
+ { method: 'POST', path: /^\/api\/import$/, name: 'vector-import', handle: () => ({ ok: true }) },
95
+ { method: 'POST', path: /^\/api\/internal\/runtime-config$/, name: 'vector-runtime-config', handle: () => ({ rootPath: null }) },
96
+ ]);
97
+ const eventBridge = await startStub([
98
+ { method: 'GET', path: /^\/api\/projects\/?$/, name: 'bridge-list', handle: () => ({ projects: [] }) },
99
+ { method: 'POST', path: /^\/api\/projects\/?$/, name: 'bridge-register', handle: () => ({ project: { id: 'eb1' } }) },
100
+ { method: 'GET', path: /^\/api\/journal\/high-watermark$/, name: 'bridge-watermark', handle: () => ({ sequence: 0 }) },
101
+ ]);
102
+ const registered = [...(options.engineProjects || [])];
103
+ const engine = await startStub([
104
+ { method: 'GET', path: /^\/api\/health\/?$/, name: 'engine-health', handle: () => ({ name: 'knowledge-engine', dataSchemaVersion: 3 }) },
105
+ { method: 'ANY', path: /^\/api\/runtime-auth\/?$/, name: 'engine-auth', handle: () => ({ ok: true }) },
106
+ { method: 'GET', path: /^\/api\/projects\/?$/, name: 'engine-list', handle: () => ({ projects: registered }) },
107
+ { method: 'POST', path: /^\/api\/projects\/?$/, name: 'engine-register', countWith: body => { try { return 'engine-register:' + JSON.parse(body).projectId; } catch { return null; } }, handle: (cleanPath, body) => { try { const parsed = JSON.parse(body); if (failProjects.includes(parsed.projectId)) return { __status: 500, ok: false, failed: parsed.projectId }; registered.push({ projectId: parsed.projectId, repoPath: parsed.repoPath, knowledgePath: parsed.knowledgePath }); } catch {} return { ok: true }; } },
108
+ { method: 'GET', path: /^\/api\/snapshot\/?$/, name: 'engine-snapshot', handle: () => ({ sharedScopes: [] }) },
109
+ { method: 'POST', path: /^\/api\/scopes\/?$/, name: 'engine-scopes', handle: () => ({ ok: true }) },
110
+ { method: 'POST', path: /^\/api\/runtime-config\/?$/, name: 'engine-runtime-config', handle: () => ({ ok: true }) },
111
+ { method: 'POST', path: /^\/api\/control\/?$/, name: 'engine-control', handle: () => ({ ok: true }) },
112
+ ]);
113
+ const projectIds = options.projectIds || [];
114
+ const configs = new Map(projectIds.map(id => [id, { displayName: id, repoPath: 'D:/qa/' + id, knowledgePath: 'D:/qa/knowledge/' + id, brainId: 'brain-default', domainId: null }]));
115
+ const states = new Map(projectIds.map(id => [id, {}]));
116
+ const registryStore = { listIds: () => [...projectIds], readDisplaySnapshot: id => projectIds.includes(id) ? { displayName: id } : null };
117
+ const projectStore = { readConfig: id => configs.get(id), readState: id => states.get(id) || {}, updateState: async (id, mutator) => { const state = states.get(id) || {}; mutator(state); states.set(id, state); } };
118
+ const settings = { brain: { id: 'brain-default', name: 'QA-Brain', domains: [] }, knowledge: { rootPath: path.join(TEMP_DATA, 'qa-knowledge-root') } };
119
+ const settingsStore = { read: () => settings, bindSharedKnowledgePaths: async () => {} };
120
+ const bridge = new BridgeClass({
121
+ terminalUrl: terminal.url, vectorHubUrl: vectorHub.url, eventBridgeUrl: eventBridge.url, engineUrl: engine.url,
122
+ registryStore, projectStore, settingsStore, dataDir: path.join(TEMP_DATA, 'qa-data-dir'),
123
+ logger: { info() {}, warn() {}, error() {} },
124
+ });
125
+ const makeRuntime = () => ({ analysisOwner: 'engine', moduleBridge: bridge, registryStore, projectStore, logger: { info() {}, warn() {}, error() {} }, ownership: { owner: 'engine', ready: false } });
126
+ return { bridge, connect, makeRuntime, stubs: { terminal, vectorHub, eventBridge, engine } };
127
+ }
128
+
129
+ async function sectionA() {
130
+ const makeElement = () => {
131
+ const el = { textContent: '', innerHTML: '', hidden: false, disabled: false, value: '', className: '', open: false, dataset: {}, childElementCount: 0, firstElementChild: null };
132
+ el.classList = { toggle() {}, add() {}, remove() {}, contains() { return false; } };
133
+ el.addEventListener = () => {}; el.removeEventListener = () => {}; el.append = () => {}; el.replaceChildren = () => {};
134
+ el.reset = () => {}; el.focus = () => {}; el.select = () => {}; el.close = () => {}; el.showModal = () => {}; el.scrollIntoView = () => {};
135
+ el.dispatchEvent = () => true; el.parentElement = el; el.contentWindow = null;
136
+ el.elements = { confirmGitInit: { checked: false }, confirmReplaceHook: { checked: false } };
137
+ return el;
138
+ };
139
+ const jsonOk = payload => Promise.resolve({ ok: true, status: 200, json: async () => payload, headers: { get: () => null } });
140
+ const brainPayload = { ok: true, brain: { id: 'brain-default', name: 'QA-Brain', domains: [] }, knowledgeRoot: 'D:/qa/root', indexStoragePath: 'D:/qa/idx' };
141
+ const healthPayload = { ok: true, modules: { terminal: { up: true }, vectorHub: { up: true }, eventBridge: { up: true }, engine: { up: true } } };
142
+ const projectsPayload = { ok: true, projects: [{ projectId: 'p1', name: 'P1', workspacePath: 'D:/qa/p1', knowledgePath: 'D:/qa/knowledge/p1', brainId: 'brain-default', domainId: null, modules: { terminal: { registered: true }, vectorHub: { registered: true }, eventBridge: { registered: true }, engine: { registered: true } } }] };
143
+ const runScenario = async (source, fetchImpl) => {
144
+ const elements = new Map();
145
+ const timers = new Set();
146
+ const get = id => { if (!elements.has(id)) elements.set(id, makeElement()); return elements.get(id); };
147
+ const document = { getElementById: get, querySelectorAll: () => [], createElement: makeElement, addEventListener: () => {}, body: makeElement(), documentElement: { dataset: {} }, hidden: false };
148
+ class EventSourceStub { constructor() { this.readyState = 0; } addEventListener() {} close() {} }
149
+ const sandbox = {
150
+ document, navigator: { onLine: true }, location: { origin: 'http://vm.local', hash: '' }, history: { replaceState() {} },
151
+ addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
152
+ localStorage: { getItem: () => null, setItem() {} }, EventSource: EventSourceStub, fetch: fetchImpl, AbortController,
153
+ URLSearchParams, URL, crypto: require('node:crypto').webcrypto,
154
+ setTimeout: (fn, ms, ...rest) => { const timer = setTimeout(fn, ms, ...rest); timers.add(timer); return timer; },
155
+ clearTimeout: timer => { timers.delete(timer); clearTimeout(timer); },
156
+ setInterval: () => 0, clearInterval: () => {},
157
+ };
158
+ sandbox.window = sandbox; sandbox.globalThis = sandbox;
159
+ vm.runInNewContext(source, sandbox, { filename: 'product.js' });
160
+ await sleep(2500);
161
+ for (const timer of timers) clearTimeout(timer);
162
+ return id => (elements.has(id) ? elements.get(id).textContent : undefined);
163
+ };
164
+ const hungFetch = url => {
165
+ const target = String(url);
166
+ if (target.includes('/api/projects/aggregated') || target.includes('/api/projects/projection')) return new Promise(() => {});
167
+ if (target.includes('/api/modules/health')) return jsonOk(healthPayload);
168
+ if (target.includes('/api/brain')) return jsonOk(brainPayload);
169
+ if (target.includes('/api/engine/snapshot')) return jsonOk({ projects: [], runs: [], events: [], memories: [] });
170
+ return jsonOk({ ok: true });
171
+ };
172
+ const fastFetch = url => {
173
+ const target = String(url);
174
+ if (target.includes('/api/projects/aggregated') || target.includes('/api/projects/projection')) return jsonOk(projectsPayload);
175
+ if (target.includes('/api/modules/health')) return jsonOk(healthPayload);
176
+ if (target.includes('/api/brain')) return jsonOk(brainPayload);
177
+ if (target.includes('/api/engine/snapshot')) return jsonOk({ projects: [], runs: [], events: [], memories: [] });
178
+ return jsonOk({ ok: true });
179
+ };
180
+ const baselineSource = headSource('ui/product.js');
181
+ const currentSource = fs.readFileSync(path.join(ROOT, 'ui', 'product.js'), 'utf8');
182
+ const readBaseline = await runScenario(baselineSource, hungFetch);
183
+ console.log(' baseline(HEAD) systemTitle after 2.5s with a hanging projects read: ' + JSON.stringify(readBaseline('systemTitle')));
184
+ await gate('A', 'A1 baseline(HEAD): health region renders while the projects read hangs', async () => {
185
+ const value = readBaseline('systemTitle');
186
+ assert.ok(value !== undefined && value !== '' && value !== '正在检查系统', 'systemTitle never updated: the first screen is one Promise.all behind the slowest read (refreshRegistry), so brain/health regions cannot render');
187
+ });
188
+ const readCurrent = await runScenario(currentSource, hungFetch);
189
+ console.log(' current systemTitle after 2.5s with a hanging projects read: ' + JSON.stringify(readCurrent('systemTitle')));
190
+ await gate('A', 'A2 current: health region renders while the projects read hangs', async () => {
191
+ const value = readCurrent('systemTitle');
192
+ assert.ok(value !== undefined && value !== '' && value !== '正在检查系统', 'post-fix frontend still gates the health region behind the hanging projects read');
193
+ });
194
+ const readControl = await runScenario(currentSource, fastFetch);
195
+ await gate('A', 'A3 control: current frontend renders system status when all reads are fast', async () => {
196
+ assert.strictEqual(readControl('systemTitle'), '系统连接正常', 'control scenario failed to render the health region');
197
+ });
198
+ }
199
+
200
+ async function sectionB() {
201
+ const hungOptions = {
202
+ vectorHubHung: true,
203
+ projectIds: ['p1'],
204
+ terminalProjects: [{ id: 't0', rootPath: 'D:/qa/p1' }],
205
+ engineProjects: [{ projectId: 'p1', repoPath: 'D:/qa/p1', knowledgePath: 'D:/qa/knowledge/p1' }],
206
+ };
207
+ const baseline = await makeEnv({ ...hungOptions, baseline: true });
208
+ const baselineStarted = Date.now();
209
+ await baseline.bridge.aggregatedProjects();
210
+ const baselineMs = Date.now() - baselineStarted;
211
+ console.log(' baseline(HEAD) aggregatedProjects() with one hung module: ' + baselineMs + 'ms');
212
+ await gate('B', 'B1 baseline(HEAD): aggregated read within 3.5s despite one hung module', async () => {
213
+ assert.ok(baselineMs <= 3500, 'baseline took ' + baselineMs + 'ms (10s list timeout + sequential 2s availability probes): one slow module stalls the whole first screen');
214
+ });
215
+ const current = await makeEnv(hungOptions);
216
+ const currentStarted = Date.now();
217
+ const aggregated = await current.bridge.aggregatedProjects();
218
+ const currentMs = Date.now() - currentStarted;
219
+ console.log(' current aggregatedProjects() with one hung module: ' + currentMs + 'ms');
220
+ await gate('B', 'B2 current: aggregated read within 3.5s despite one hung module', async () => {
221
+ assert.ok(currentMs <= 3500, 'aggregatedProjects() took ' + currentMs + 'ms with one hung module; plan requires a 3s-class budget');
222
+ });
223
+ await gate('B', 'B3 current: aggregated read keeps the registry spine (no false "no projects")', async () => {
224
+ assert.strictEqual(aggregated.projects.length, 1, 'aggregated projection must keep the registry spine even when a module is unreachable');
225
+ });
226
+ const healthStarted = Date.now();
227
+ await current.bridge.modulesHealth();
228
+ const healthMs = Date.now() - healthStarted;
229
+ console.log(' current modulesHealth() with one hung module: ' + healthMs + 'ms');
230
+ await gate('B', 'B4 current: modulesHealth within 2.5s (parallel probes)', async () => {
231
+ assert.ok(healthMs <= 2500, 'modulesHealth() took ' + healthMs + 'ms');
232
+ });
233
+ }
234
+
235
+ async function sectionC() {
236
+ const baseline = await makeEnv({ baseline: true, projectIds: ['proj-bad', 'proj-good'], engineFailProjects: ['proj-bad'] });
237
+ const baselineRuntime = baseline.makeRuntime();
238
+ await baseline.connect(baselineRuntime);
239
+ console.log(' baseline(HEAD) engine counters with one failing project: ' + JSON.stringify(baseline.stubs.engine.counters));
240
+ await gate('C', 'C1 baseline(HEAD): healthy project is wired when another project fails', async () => {
241
+ assert.ok((baseline.stubs.engine.counters['engine-register:proj-good'] || 0) >= 1, 'proj-good never reached engine POST /api/projects: sequential wiring aborted at proj-bad');
242
+ });
243
+ await gate('C', 'C2 baseline(HEAD): ownership not globally pending because of one project', async () => {
244
+ assert.ok(baselineRuntime.ownership.ready, 'ownership stayed not-ready (' + JSON.stringify(baselineRuntime.ownership.error || null) + '): one failing project keeps the whole product in global "待恢复"');
245
+ });
246
+ await gate('C', 'C3 baseline(HEAD): engine activation happens with one failing project', async () => {
247
+ assert.ok((baseline.stubs.engine.counters['engine-control'] || 0) >= 1, 'engine POST /api/control never happened: activation is gated behind all-project wiring');
248
+ });
249
+ const current = await makeEnv({ projectIds: ['proj-bad', 'proj-good'], engineFailProjects: ['proj-bad'] });
250
+ const currentRuntime = current.makeRuntime();
251
+ await current.connect(currentRuntime);
252
+ console.log(' current engine counters with one failing project: ' + JSON.stringify(current.stubs.engine.counters));
253
+ console.log(' current ownership: ' + JSON.stringify(currentRuntime.ownership));
254
+ await gate('C', 'C4 current: healthy project is wired when another project fails', async () => {
255
+ assert.ok((current.stubs.engine.counters['engine-register:proj-good'] || 0) >= 1, 'proj-good still not wired when proj-bad fails');
256
+ });
257
+ await gate('C', 'C5 current: ownership not globally pending because of one project', async () => {
258
+ assert.ok(currentRuntime.ownership.ready, 'ownership still not-ready globally');
259
+ });
260
+ await gate('C', 'C6 current: engine activation happens with one failing project', async () => {
261
+ assert.ok((current.stubs.engine.counters['engine-control'] || 0) >= 1, 'engine POST /api/control still gated behind all-project wiring');
262
+ });
263
+ }
264
+
265
+ async function sectionC4() {
266
+ const baseline = await makeEnv({ baseline: true, projectIds: ['p-a', 'p-b'] });
267
+ await baseline.connect(baseline.makeRuntime());
268
+ await baseline.connect(baseline.makeRuntime());
269
+ console.log(' baseline(HEAD) counters after two takeover passes: ' + JSON.stringify(baseline.stubs.engine.counters));
270
+ await gate('C', 'C7 baseline(HEAD): healthy projects are not re-registered on a repeat takeover pass', async () => {
271
+ assert.strictEqual(baseline.stubs.engine.counters['engine-register:p-a'] || 0, 1, 'every takeover pass re-POSTs every healthy project (no remote-list/fingerprint dedupe)');
272
+ });
273
+ await gate('C', 'C8 baseline(HEAD): unchanged configuration is not re-pushed on a repeat takeover pass', async () => {
274
+ assert.strictEqual(baseline.stubs.engine.counters['engine-scopes'] || 0, 1, 'unchanged scopes are re-pushed on every pass (no change detection)');
275
+ });
276
+ const current = await makeEnv({ projectIds: ['p-a', 'p-b'] });
277
+ const currentRuntime = current.makeRuntime();
278
+ await current.connect(currentRuntime);
279
+ await current.connect(currentRuntime);
280
+ console.log(' current counters after two takeover passes: ' + JSON.stringify(current.stubs.engine.counters));
281
+ await gate('C', 'C9 current: healthy projects are not re-registered on a repeat takeover pass', async () => {
282
+ assert.strictEqual(current.stubs.engine.counters['engine-register:p-a'] || 0, 1, 'healthy project re-registered on a repeat pass');
283
+ });
284
+ await gate('C', 'C10 current: unchanged configuration is not re-pushed on a repeat takeover pass', async () => {
285
+ assert.strictEqual(current.stubs.engine.counters['engine-scopes'] || 0, 1, 'unchanged scopes re-pushed on a repeat pass');
286
+ });
287
+ }
288
+
289
+ async function sectionD() {
290
+ const baseline = await makeEnv({ baseline: true, projectIds: ['p1'] });
291
+ await Promise.all([baseline.bridge.syncEngineConfiguration(), baseline.bridge.syncEngineConfiguration(), baseline.bridge.syncEngineConfiguration()]);
292
+ console.log(' baseline(HEAD) engine counters after 3 concurrent syncs: ' + JSON.stringify(baseline.stubs.engine.counters));
293
+ await gate('D', 'D1 baseline(HEAD): concurrent duplicate config syncs are merged into one push', async () => {
294
+ assert.strictEqual(baseline.stubs.engine.counters['engine-scopes'] || 0, 1, 'single-flight chains a full re-run per concurrent caller: 3 callers produced ' + (baseline.stubs.engine.counters['engine-scopes'] || 0) + ' scope pushes');
295
+ });
296
+ await baseline.bridge.syncEngineConfiguration();
297
+ console.log(' baseline(HEAD) engine counters after a fourth unchanged sync: ' + JSON.stringify(baseline.stubs.engine.counters));
298
+ await gate('D', 'D2 baseline(HEAD): unchanged configuration is not re-pushed on the next sync', async () => {
299
+ assert.strictEqual(baseline.stubs.engine.counters['engine-scopes'] || 0, 1, 'a completed sync with unchanged settings still re-pushes scopes');
300
+ });
301
+ const current = await makeEnv({ projectIds: ['p1'] });
302
+ await Promise.all([current.bridge.syncEngineConfiguration(), current.bridge.syncEngineConfiguration(), current.bridge.syncEngineConfiguration()]);
303
+ console.log(' current engine counters after 3 concurrent syncs: ' + JSON.stringify(current.stubs.engine.counters));
304
+ await gate('D', 'D3 current: concurrent duplicate config syncs are merged into one push', async () => {
305
+ assert.strictEqual(current.stubs.engine.counters['engine-scopes'] || 0, 1, 'concurrent syncs were not merged');
306
+ });
307
+ await current.bridge.syncEngineConfiguration();
308
+ console.log(' current engine counters after a fourth unchanged sync: ' + JSON.stringify(current.stubs.engine.counters));
309
+ await gate('D', 'D4 current: unchanged configuration is not re-pushed on the next sync', async () => {
310
+ assert.strictEqual(current.stubs.engine.counters['engine-scopes'] || 0, 1, 'unchanged settings re-pushed');
311
+ });
312
+ }
313
+
314
+ async function sectionE() {
315
+ let upstreamOpen = 0;
316
+ const upstream = http.createServer((req, res) => {
317
+ if (!req.url.includes('/api/events/stream')) { res.writeHead(404); res.end(); return; }
318
+ upstreamOpen++;
319
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
320
+ res.write('event: snapshot\ndata: {"n":1}\n\n');
321
+ const keepAlive = setInterval(() => { try { res.write('event: tick\ndata: {}\n\n'); } catch {} }, 200);
322
+ req.on('close', () => { upstreamOpen--; clearInterval(keepAlive); });
323
+ });
324
+ allServers.push(upstream);
325
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
326
+ const upstreamUrl = 'http://127.0.0.1:' + upstream.address().port;
327
+ const env = await makeEnv({ projectIds: [] });
328
+ const shell = http.createServer((req, res) => { env.bridge.proxy(req, res, upstreamUrl + '/api/events/stream').catch(() => {}); });
329
+ allServers.push(shell);
330
+ await new Promise(resolve => shell.listen(0, '127.0.0.1', resolve));
331
+ const shellUrl = 'http://127.0.0.1:' + shell.address().port;
332
+ const clients = [];
333
+ try {
334
+ for (let i = 0; i < 8; i++) {
335
+ const controller = new AbortController();
336
+ const got = { firstMs: null, startedAt: Date.now() };
337
+ clients.push({ controller, got });
338
+ fetch(shellUrl + '/stream', { signal: controller.signal, headers: { Accept: 'text/event-stream' } })
339
+ .then(response => {
340
+ got.firstMs = Date.now() - got.startedAt;
341
+ const reader = response.body.getReader();
342
+ const pump = () => reader.read().then(({ done }) => { if (!done) pump(); }).catch(() => {});
343
+ pump();
344
+ })
345
+ .catch(() => {});
346
+ }
347
+ await sleep(1500);
348
+ await gate('E', 'E1 current: 8 concurrent SSE clients each get their first event within 3s', async () => {
349
+ assert.strictEqual(clients.filter(c => c.got.firstMs !== null).length, 8, 'not all 8 clients received a response within 1.5s');
350
+ assert.strictEqual(upstreamOpen, 8, 'expected 8 independent upstream connections, saw ' + upstreamOpen);
351
+ });
352
+ for (const client of clients.slice(0, 4)) client.controller.abort();
353
+ await sleep(1000);
354
+ console.log(' upstream connections after aborting 4 of 8 clients: ' + upstreamOpen);
355
+ await gate('E', 'E2 current: aborting 4 clients releases exactly their 4 upstream connections', async () => {
356
+ assert.strictEqual(upstreamOpen, 4, 'aborting clients did not release their upstream connections (leak/block): ' + upstreamOpen + ' still open');
357
+ });
358
+ } finally {
359
+ for (const client of clients) client.controller.abort();
360
+ }
361
+ }
362
+
363
+ async function main() {
364
+ await sectionA();
365
+ await sectionB();
366
+ await sectionC();
367
+ await sectionC4();
368
+ await sectionD();
369
+ await sectionE();
370
+ const failed = gates.filter(g => !g.ok);
371
+ console.log('');
372
+ console.log('glm-ui-refresh-review-test summary: ' + (gates.length - failed.length) + '/' + gates.length + ' gates passed');
373
+ if (failed.length) {
374
+ console.log('failed gates (baseline(HEAD) failures = first-failure evidence; current failures = regressions):');
375
+ for (const g of failed) console.log(' [' + g.section + '] ' + g.name + ' :: ' + g.message);
376
+ }
377
+ const baseline = gates.filter(g => g.name.includes('baseline(HEAD)'));
378
+ const regressions = failed.filter(g => !g.name.includes('baseline(HEAD)'));
379
+ console.log(`Current: ${gates.length - baseline.length - regressions.length}/${gates.length - baseline.length}; baseline expected failures: ${baseline.filter(g => !g.ok).length}/${baseline.length}`);
380
+ process.exitCode = regressions.length || baseline.some(g => g.ok) ? 1 : 0;
381
+ }
382
+
383
+ main().catch(error => { console.error(error); process.exitCode = 1; }).finally(() => {
384
+ for (const server of allServers) { server.close(); server.closeAllConnections?.(); }
385
+ });
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const { once } = require('node:events');
5
+ const { WebSocket } = require('ws');
6
+ const { attachLiveGateway } = require('../lib/live-gateway');
7
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
8
+ (async () => {
9
+ let active = 0, total = 0;
10
+ const upstream = http.createServer((req, res) => {
11
+ active++; total++;
12
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
13
+ res.write('id: 17\r\nevent: snapshot\r\ndata: {"ok":true}\r');
14
+ setTimeout(() => { if (!res.destroyed) res.write('\n\r\n'); }, 5);
15
+ req.on('close', () => { active--; });
16
+ });
17
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
18
+ const origin = `http://127.0.0.1:${upstream.address().port}`;
19
+ const server = http.createServer();
20
+ const runtime = { moduleBridge: { engineUrl: origin, engineCompatible: true, _terminalApi: route => origin + route } };
21
+ const gateway = attachLiveGateway(server, runtime);
22
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
23
+ const base = `http://127.0.0.1:${server.address().port}`, clients = [];
24
+ try {
25
+ for (let i = 0; i < 8; i++) {
26
+ const ws = new WebSocket(base.replace('http:', 'ws:') + '/api/live', { origin: base }); clients.push(ws);
27
+ await once(ws, 'open');
28
+ const messages = []; ws.on('message', b => messages.push(JSON.parse(String(b))));
29
+ ws.send(JSON.stringify({ type: 'subscribe', id: 'engine', route: '/api/engine/progress?summary=1' }));
30
+ for (let n = 0; n < 100 && !messages.some(m => m.type === 'event'); n++) await wait(5);
31
+ const event = messages.find(m => m.type === 'event'); assert.equal(event.event, 'snapshot'); assert.equal(event.cursor, '17'); assert.deepEqual(JSON.parse(event.data), { ok: true });
32
+ ws.send(JSON.stringify({ type: 'subscribe', id: 'invalid', route: 'http://attacker.invalid/private' }));
33
+ for (let n = 0; n < 100 && !messages.some(m => m.id === 'invalid'); n++) await wait(5);
34
+ assert.equal(messages.find(m => m.id === 'invalid').code, 'INVALID_SUBSCRIPTION');
35
+ }
36
+ assert.equal(active, 8); assert.equal(total, 8, 'invalid URLs never fetch an upstream');
37
+ const last = clients.at(-1);
38
+ last.send(JSON.stringify({ type: 'unsubscribe', id: 'engine' }));
39
+ for (let n = 0; n < 100 && active !== 7; n++) await wait(5);
40
+ assert.equal(active, 7, 'unsubscribing releases the upstream');
41
+ const rejected = new WebSocket(base.replace('http:', 'ws:') + '/api/live', { origin: 'http://evil.invalid' });
42
+ await new Promise(resolve => { rejected.on('error', resolve); rejected.on('open', () => { throw new Error('cross-origin upgrade was accepted'); }); });
43
+ gateway.close();
44
+ for (let n = 0; n < 100 && active; n++) await wait(5);
45
+ assert.equal(active, 0);
46
+ console.log('live-gateway-test PASS (8 real sockets, CRLF frames, bounded allowlist, unsubscribe and shutdown release)');
47
+ } finally { for (const ws of clients) ws.terminate(); gateway.close(); upstream.closeAllConnections(); await Promise.all([new Promise(r => server.close(r)), new Promise(r => upstream.close(r))]); }
48
+ })().catch(error => { console.error(error); process.exitCode = 1; });