@sanqianx/project-knowledge 5.0.9-rc.9 → 5.0.10-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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +140 -0
  3. package/README.md +11 -4
  4. package/_modules/knowledge-engine/README.md +9 -1
  5. package/_modules/knowledge-engine/package.json +1 -1
  6. package/_modules/knowledge-engine/src/coordinator.js +11 -2
  7. package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
  8. package/_modules/knowledge-engine/src/memories.js +133 -24
  9. package/_modules/knowledge-engine/src/retrieval.js +19 -4
  10. package/_modules/knowledge-engine/src/runtime/contracts.js +1 -0
  11. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +6 -2
  12. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +7 -2
  13. package/_modules/knowledge-engine/src/server.js +1 -1
  14. package/_modules/knowledge-engine/src/store.js +9 -6
  15. package/_site/_test/bin-cli-test.js +15 -2
  16. package/_site/_test/mcp-server-test.js +1 -1
  17. package/_site/_test/module-agent-start-proxy-test.js +65 -0
  18. package/_site/_test/module-bridge-eventbridge-test.js +1 -1
  19. package/_site/_test/module-wiring-unbind-race-test.js +59 -0
  20. package/_site/_test/path-consistency-test.js +1 -1
  21. package/_site/_test/product-diagnostics-ui-test.js +5 -1
  22. package/_site/_test/product-progress-status-test.js +70 -0
  23. package/_site/_test/shared-scope-root-binding-test.js +67 -0
  24. package/_site/_test/test-server-cleanup-test.js +5 -1
  25. package/_site/_test/vector-storage-root-test.js +16 -0
  26. package/_site/lib/engine-connection.js +4 -1
  27. package/_site/lib/knowledge-tool-runtime.js +1 -1
  28. package/_site/lib/module-bridge.js +65 -8
  29. package/_site/lib/server-app.js +4 -2
  30. package/_site/lib/settings-store.js +20 -0
  31. package/module-runtime-manifest.json +41 -41
  32. package/package.json +3 -3
  33. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  34. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  35. package/plugins/project-knowledge/.mcp.json +1 -1
  36. package/ui/index.html +2 -2
  37. package/ui/product.css +1 -0
  38. package/ui/product.js +62 -10
  39. package/vendor-manifest.json +12 -12
@@ -5,13 +5,17 @@ const { sha256 } = require('./content-hash');
5
5
  const CHUNKER_VERSION = 1;
6
6
  const DEFAULT_MAX_CHARS = 1200;
7
7
  const DEFAULT_OVERLAP_CHARS = 120;
8
- const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules']);
8
+ const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules', 'dev-conversations']);
9
9
  const DERIVED_INDEX_FILENAME = '00-index.md';
10
10
 
11
11
  function normalizeRel(value) {
12
12
  return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
13
13
  }
14
14
 
15
+ function isExcludedKnowledgePath(value) {
16
+ return normalizeRel(value).split('/').some(segment => segment.startsWith('.') || EXCLUDED_DIRS.has(segment.toLowerCase()));
17
+ }
18
+
15
19
  function stripFrontmatter(markdown) {
16
20
  return String(markdown || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
17
21
  }
@@ -112,7 +116,7 @@ function listMarkdownFiles(root, options = {}) {
112
116
  const files = [];
113
117
  const walk = (dir) => {
114
118
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
- if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
119
+ if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name.toLowerCase())) continue;
116
120
  const abs = path.join(dir, entry.name);
117
121
  if (entry.isDirectory()) walk(abs);
118
122
  else if (entry.isFile() && /\.md$/i.test(entry.name) && (includeDerived || !isDerivedIndex(entry.name))) files.push(abs);
@@ -224,6 +228,7 @@ module.exports = {
224
228
  CHUNKER_VERSION,
225
229
  chunkMarkdown,
226
230
  listMarkdownFiles,
231
+ isExcludedKnowledgePath,
227
232
  isDerivedIndex,
228
233
  inferEntryType,
229
234
  parseKnowledgeMetadata,
@@ -136,7 +136,7 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
136
136
  if (method === 'GET' && pathname === '/api/events') return { events: store.listEvents(search.get('limit')).filter(e => !search.get('projectId') || e.projectId === search.get('projectId')) };
137
137
  if (method === 'POST' && pathname === '/api/events' && ctx.debug) return { event: store.addEvent(body) };
138
138
  if (method === 'POST' && pathname === '/api/bridge/notify') { if (coordinator.consume) coordinator.drain(); return { accepted: true }; }
139
- if (method === 'GET' && pathname === '/api/snapshot') return { projects: store.listProjects(), runs: store.listRuns(search.get('projectId')), events: store.listEvents(50), memories: store.state.memories, sharedScopes: store.state.sharedScopes };
139
+ 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 };
140
140
  throw httpError(404, 'NOT_FOUND', '接口不存在。');
141
141
  }
142
142
 
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const crypto = require('crypto');
6
6
  const { validateProjectId } = require('./runtime/contracts');
7
7
  const { RuntimeLayout } = require('./runtime/layout');
8
+ const AtomicFile = require('./runtime/atomic-file');
8
9
 
9
10
  /**
10
11
  * 单文件 JSON 状态层(<data>/engine-state.json):注册项目、分析运行、事件流。
@@ -39,6 +40,11 @@ class EngineStore {
39
40
  for (const [journalId, sequence] of Object.entries(raw.journalCursors || {})) if (!/^(global|sha256:[a-f0-9]{64})$/.test(journalId) || !Number.isSafeInteger(sequence) || sequence < 0) throw new Error('Invalid journal cursor');
40
41
  for (const scope of raw.sharedScopes || []) if (!scope || !['brain', 'domain'].includes(scope.kind) || !path.isAbsolute(scope.knowledgePath || '')) throw new Error('Invalid shared scope');
41
42
  for (const memory of raw.memories || []) if (!memory || !['candidate', 'confirmed', 'ignored'].includes(memory.status) || typeof memory.text !== 'string' || !memory.evidence?.projectId || !/^[a-f0-9]{40}$/.test(memory.evidence.commitSha || '')) throw new Error('Invalid memory record');
43
+ for (const memory of raw.memories || []) if (memory.scopeType !== undefined || memory.scopeId !== undefined) {
44
+ if (!['global', 'domain', 'project'].includes(memory.scopeType)) throw new Error('Invalid memory scope');
45
+ validateProjectId(memory.scopeId);
46
+ if (memory.scopeType === 'global' && memory.scopeId !== (memory.brainId || 'brain-default')) throw new Error('Invalid global memory target');
47
+ }
42
48
  this.state = { ...this.state, ...raw };
43
49
  } catch (error) {
44
50
  if (error.code === 'SCHEMA_UNSUPPORTED') throw error;
@@ -56,12 +62,9 @@ class EngineStore {
56
62
  }
57
63
 
58
64
  save() {
59
- fs.mkdirSync(this.dataDir, { recursive: true });
60
- const tmp = this.file + '.tmp';
61
- const fd = fs.openSync(tmp, 'w', 0o600);
62
- try { fs.writeFileSync(fd, JSON.stringify(this.state, null, 2), 'utf8'); fs.fsyncSync(fd); }
63
- finally { fs.closeSync(fd); }
64
- fs.renameSync(tmp, this.file);
65
+ // Windows scanners/readers may briefly hold the destination; never unlink
66
+ // durable state to force a replace, or share a fixed temp name with writers.
67
+ AtomicFile.writeJsonAtomic(this.file, this.state);
65
68
  }
66
69
 
67
70
  listProjects() {
@@ -78,12 +78,25 @@ function isPortListening(port) {
78
78
  async function waitForListening(port, deadlineMs = 8000) {
79
79
  const deadline = Date.now() + deadlineMs;
80
80
  while (Date.now() < deadline) {
81
- if (await isPortListening(port)) return true;
81
+ if (await canConnect(port)) return true;
82
82
  await new Promise(resolve => setTimeout(resolve, 150));
83
83
  }
84
84
  return false;
85
85
  }
86
86
 
87
+ function canConnect(port) {
88
+ // A bind probe can steal the selected port exactly as the child starts.
89
+ // Readiness checks must connect, never compete with the server's listener.
90
+ return new Promise(resolve => {
91
+ const socket = net.createConnection({ host: '127.0.0.1', port });
92
+ let settled = false;
93
+ const finish = result => { if (settled) return; settled = true; socket.destroy(); resolve(result); };
94
+ socket.setTimeout(500, () => finish(false));
95
+ socket.once('connect', () => finish(true));
96
+ socket.once('error', () => finish(false));
97
+ });
98
+ }
99
+
87
100
  (async () => {
88
101
  // Make sure no leftover PID file or server from a previous run.
89
102
  removePidFile();
@@ -185,7 +198,7 @@ async function waitForListening(port, deadlineMs = 8000) {
185
198
  while (Date.now() < deadline && !detectedPort) {
186
199
  const rec = readPidFile();
187
200
  if (rec && rec.port && rec.port !== busyPort) {
188
- if (await isPortListening(rec.port)) {
201
+ if (await canConnect(rec.port)) {
189
202
  detectedPort = rec.port;
190
203
  break;
191
204
  }
@@ -113,7 +113,7 @@ async function createMcpClient() {
113
113
  let response = await client.request('tools/call', { name: 'project_knowledge_resolve', arguments: { repoPath: repo } });
114
114
  assert.strictEqual(response.result.structuredContent.projectId, 'project-demo');
115
115
  assert.strictEqual(response.result.structuredContent.knowledgePath, markdownKb);
116
- assert.strictEqual(response.result.structuredContent.indexPath, layout.getIndexPath());
116
+ assert.strictEqual(response.result.structuredContent.indexPath, layout.getVectorRootPath(settingsStore.read()));
117
117
 
118
118
  response = await client.request('tools/call', { name: 'project_knowledge_search', arguments: { projectId: 'project-demo', query: 'refresh tokens' } });
119
119
  assert.strictEqual(response.result.structuredContent.source, 'knowledge-retrieval-service');
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const { Readable } = require('node:stream');
5
+ const { EventEmitter } = require('node:events');
6
+ const { ModuleBridge } = require('../lib/module-bridge');
7
+
8
+ async function budget(method, pathname) {
9
+ const timers = [], originalTimer = global.setTimeout, originalFetch = global.fetch;
10
+ const req = Readable.from([]); req.method = method; req.headers = {};
11
+ const res = new EventEmitter();
12
+ res.writeHead = () => {}; res.end = () => { res.writableEnded = true; };
13
+ try {
14
+ global.setTimeout = (fn, ms, ...args) => { timers.push(ms); return originalTimer(fn, ms, ...args); };
15
+ global.fetch = async () => new Response('{}', { headers: { 'Content-Type': 'application/json' } });
16
+ await new ModuleBridge().proxy(req, res, 'http://127.0.0.1:5760' + pathname);
17
+ return timers[0];
18
+ } finally { global.setTimeout = originalTimer; global.fetch = originalFetch; }
19
+ }
20
+
21
+ (async () => {
22
+ const upstream = http.createServer((req, res) => {
23
+ const timer = setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"accepted":true}'); }, 11000);
24
+ res.on('close', () => clearTimeout(timer));
25
+ });
26
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
27
+ const bridge = new ModuleBridge();
28
+ const target = `http://127.0.0.1:${upstream.address().port}/api/claude-workbench/v1/sessions/cold-client/input`;
29
+ const shell = http.createServer((req, res) => bridge.proxy(req, res, target));
30
+ await new Promise(resolve => shell.listen(0, '127.0.0.1', resolve));
31
+ try {
32
+ const response = await fetch(`http://127.0.0.1:${shell.address().port}/send`, { method: 'POST', body: '{}' });
33
+ assert.equal(response.status, 200, 'cold native client startup must not be reported as unreachable after ten seconds');
34
+ assert.equal((await response.json()).accepted, true);
35
+ } finally {
36
+ for (const server of [shell, upstream]) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
37
+ }
38
+ assert.equal(await budget('POST', '/api/claude-workbench/v1/sessions'), 90000);
39
+ assert.equal(await budget('POST', '/api/claude-workbench/v1/sessions/cold-client/input?scope=project'), 90000);
40
+ for (const [method, route] of [
41
+ ['GET', '/api/claude-workbench/v1/sessions'],
42
+ ['POST', '/api/claude-workbench/v1/sessions/cold-client/abort'],
43
+ ['POST', '/api/claude-workbench/v1/sessions/cold-client/permission'],
44
+ ['POST', '/api/engine/runs'],
45
+ ['POST', '/api/sessions/cold-client/input'],
46
+ ]) assert.equal(await budget(method, route), 10000, 'unrelated APIs must keep their ordinary timeout');
47
+ assert.equal(await budget('POST', '/api/claude-workbench/v1/system/pick-folder'), 600000);
48
+ const originalFetch = global.fetch;
49
+ let warning, failure;
50
+ const request = Readable.from([]); request.method = 'GET'; request.headers = {};
51
+ const response = new EventEmitter(); response.writeHead = () => {};
52
+ response.end = bytes => { response.writableEnded = true; failure = JSON.parse(bytes); };
53
+ try {
54
+ global.fetch = async () => { throw new Error('upstream leaked credential: secret-proxy-fixture'); };
55
+ const safeBridge = new ModuleBridge({ logger: { warn(...args) { warning = args; } } });
56
+ await safeBridge.proxy(request, response, 'http://127.0.0.1:5760/api/health?apiKey=secret-proxy-fixture');
57
+ assert.equal(failure.error, 'MODULE_UNREACHABLE');
58
+ assert.equal(failure.details.failureKind, 'NETWORK_ERROR');
59
+ assert.equal(failure.details.timeoutMs, 10000);
60
+ assert.equal(warning[0], 'module.proxy.failed');
61
+ assert.equal(warning[2].context.path, '/api/health');
62
+ assert.ok(!JSON.stringify([failure, warning]).includes('secret-proxy-fixture'), 'diagnostics omit queries, raw errors and credentials');
63
+ } finally { global.fetch = originalFetch; }
64
+ console.log('module-agent-start-proxy-test PASS');
65
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -108,7 +108,7 @@ async function main() {
108
108
  vectorHubUrl: DEAD_URL,
109
109
  eventBridgeUrl: stubUrl,
110
110
  engineUrl: DEAD_URL,
111
- registryStore: { listIds: () => ['demo-proj'] },
111
+ registryStore: { listIds: () => ['demo-proj'], readDisplaySnapshot: id => id === 'demo-proj' ? { displayName: 'demo' } : null },
112
112
  projectStore: makeStoreStub(),
113
113
  });
114
114
 
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const { ModuleBridge } = require('../lib/module-bridge');
5
+
6
+ async function main() {
7
+ let releaseRegister, registerStarted, registered = true;
8
+ const started = new Promise(resolve => { registerStarted = resolve; });
9
+ const gate = new Promise(resolve => { releaseRegister = resolve; });
10
+ const engineProjects = new Set();
11
+ const calls = [];
12
+ const server = http.createServer(async (req, res) => {
13
+ req.resume();
14
+ if (req.method === 'POST' && req.url === '/engine/api/projects') {
15
+ registerStarted(); await gate;
16
+ engineProjects.add('project-race'); calls.push('register');
17
+ }
18
+ if (req.method === 'DELETE' && req.url === '/engine/api/projects/project-race') { engineProjects.delete('project-race'); calls.push('remove'); }
19
+ res.setHeader('Content-Type', 'application/json');
20
+ res.end(JSON.stringify({ ok: true, projects: [], id: 'terminal-race', project: { id: 'event-race' }, sequence: 0 }));
21
+ });
22
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
23
+ const base = `http://127.0.0.1:${server.address().port}`;
24
+ const state = { modules: {} };
25
+ const bridge = new ModuleBridge({ terminalUrl: base + '/terminal', vectorHubUrl: base + '/vector', eventBridgeUrl: base + '/event', engineUrl: base + '/engine', registryStore: { readDisplaySnapshot: () => registered ? { displayName: 'Race' } : null }, projectStore: { readState: () => state, readConfig: () => ({}), updateState: async (_id, apply) => { apply(state); } } });
26
+ try {
27
+ const input = { projectId: 'project-race', name: 'Race', workspacePath: process.cwd(), knowledgePath: process.cwd() };
28
+ const wiring = bridge.registerProjectLinks(input);
29
+ await started;
30
+ const removing = bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { registered = false; } }).then(() => { registered = false; });
31
+ // A delayed supervisor registration must settle before removal, never after.
32
+ await new Promise(resolve => setTimeout(resolve, 100)); releaseRegister();
33
+ await Promise.all([wiring, removing]);
34
+ assert.deepEqual([...engineProjects], [], 'delayed wiring must not resurrect a removed engine project');
35
+ assert.deepEqual(calls, ['register', 'remove']);
36
+ const before = calls.length;
37
+ const stale = await bridge.registerProjectLinks(input);
38
+ assert.equal(calls.length, before, 'a stale registry snapshot cannot register an absent project');
39
+ assert.equal(stale.engine.status, 'removed');
40
+ assert.equal(bridge.projectWiringQueue?.size || 0, 0, 'settled operations must not leak per-project queues');
41
+ let releaseOther;
42
+ const otherGate = new Promise(resolve => { releaseOther = resolve; });
43
+ const order = [];
44
+ const held = bridge._withProjectWiring('project-a', async () => { await otherGate; order.push('a'); });
45
+ const independent = bridge._withProjectWiring('project-b', async () => { order.push('b'); });
46
+ await independent; assert.deepEqual(order, ['b'], 'unrelated projects do not share a global wiring lock');
47
+ releaseOther(); await held; assert.deepEqual(order, ['b', 'a']);
48
+ // Failed deletion keeps its registry, then a retry can still repair/remove it.
49
+ registered = true;
50
+ await assert.rejects(bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { throw new Error('controlled hook conflict'); } }), /controlled hook conflict/);
51
+ assert.equal(registered, true);
52
+ await bridge.registerProjectLinks(input);
53
+ assert.ok(engineProjects.has('project-race'));
54
+ await bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { registered = false; } });
55
+ assert.deepEqual([...engineProjects], []);
56
+ console.log('module-wiring-unbind-race-test PASS');
57
+ } finally { releaseRegister(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
58
+ }
59
+ main().catch(error => { console.error(error); process.exitCode = 1; });
@@ -98,7 +98,7 @@ async function waitForServer() {
98
98
  const resolved = runtime.resolveProject({ repoPath: movedRepo });
99
99
  assert.strictEqual(resolved.projectId, 'project-stable');
100
100
  assert.strictEqual(resolved.knowledgePath, oldKnowledge);
101
- assert.strictEqual(resolved.indexPath, layout.getIndexPath());
101
+ assert.strictEqual(resolved.indexPath, layout.getVectorRootPath(settingsStore.read()));
102
102
  const mcpResolved = await callTool(runtime, 'project_knowledge_resolve', { projectId: 'project-stable' });
103
103
  assert.strictEqual(mcpResolved.knowledgePath, resolved.knowledgePath);
104
104
  assert.strictEqual(mcpResolved.indexPath, resolved.indexPath);
@@ -83,7 +83,11 @@ async function main() {
83
83
  if (browser) console.error(await browser.evaluate("JSON.stringify({ toast:document.getElementById('toast').textContent, project:document.getElementById('projectSelect').value, detail:document.getElementById('projectDetail').textContent })"));
84
84
  throw error;
85
85
  } finally {
86
- await browser?.close(); await server.cleanup(); offline.closeAllConnections(); await new Promise(resolve => offline.close(resolve)); fixture.cleanup();
86
+ try { await browser?.close(); }
87
+ finally {
88
+ try { await server.cleanup(); }
89
+ finally { offline.closeAllConnections(); await new Promise(resolve => offline.close(resolve)); fixture.cleanup(); }
90
+ }
87
91
  }
88
92
  }
89
93
  main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const http = require('node:http');
7
+ const { spawnServer } = require('./helpers/spawn-server');
8
+ const { findChrome } = require('./helpers/find-chrome');
9
+ const { launchCdpBrowser, waitFor } = require('./helpers/cdp-browser');
10
+
11
+ const ROOT = path.resolve(__dirname, '../..');
12
+ async function main() {
13
+ const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'pk-product-progress-'));
14
+ const snapshot = { projects: [], runs: [], events: [], memories: [], sharedScopes: [] };
15
+ let connections = 0, snapshots = 0, healthRequests = 0;
16
+ const streams = new Set();
17
+ const upstream = http.createServer((req, res) => {
18
+ if (req.url === '/api/progress') {
19
+ connections++; streams.add(res); res.on('close', () => streams.delete(res));
20
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
21
+ res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
22
+ return;
23
+ }
24
+ req.resume();
25
+ if (req.url === '/api/snapshot') snapshots++;
26
+ if (req.url.includes('/health')) healthRequests++;
27
+ res.writeHead(200, { 'Content-Type': 'application/json' });
28
+ res.end(JSON.stringify(req.url === '/api/snapshot' ? snapshot : req.url === '/api/projects' ? { projects: [] } : { ok: true, name: 'knowledge-engine', dataSchemaVersion: 3 }));
29
+ });
30
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
31
+ const moduleUrl = `http://127.0.0.1:${upstream.address().port}`;
32
+ const port = 8400 + process.pid % 200;
33
+ const base = `http://127.0.0.1:${port}`;
34
+ const server = spawnServer({ root: ROOT, port, dataDir: path.join(fixture, 'data'), analysisOwner: 'engine', tag: 'product-progress', extraEnv: {
35
+ KB_TERMINAL_URL: moduleUrl, KB_VECTORHUB_URL: moduleUrl,
36
+ KB_EVENTBRIDGE_URL: moduleUrl, KB_KNOWLEDGE_ENGINE_URL: moduleUrl,
37
+ } });
38
+ let browser;
39
+ try {
40
+ await waitFor(async () => { try { return (await fetch(base + '/api/health')).ok; } catch { return false; } }, 'isolated product', 20000);
41
+ browser = await launchCdpBrowser({ chrome: findChrome(), profileDir: path.join(fixture, 'browser'), url: base, width: 1920, height: 1080 });
42
+ await browser.setViewport(1920, 1080);
43
+ const run = expression => browser.evaluate(expression);
44
+ const status = () => run("document.getElementById('activityConnection').textContent");
45
+ await waitFor(async () => await status() === '已连接实时更新', 'initial SSE snapshot');
46
+ assert.equal(connections, 1);
47
+ for (let attempt = 0; attempt < 2; attempt++) {
48
+ const beforeHealth = healthRequests, beforeSnapshot = snapshots;
49
+ await run("window.dispatchEvent(new Event('online'))");
50
+ await waitFor(() => healthRequests > beforeHealth && snapshots > beforeSnapshot, 'online registry and snapshot refresh');
51
+ assert.equal(await status(), '已连接实时更新', 'a duplicate online event must not leave a healthy stream labelled as recovering');
52
+ assert.equal(connections, 1, 'a redundant online receipt cannot create another progress subscription');
53
+ }
54
+ await browser.send('Network.enable');
55
+ await browser.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
56
+ await waitFor(async () => (await status()).includes('连接中断'), 'genuine offline feedback');
57
+ await waitFor(() => streams.size === 0, 'offline closes the upstream stream');
58
+ await browser.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
59
+ await waitFor(async () => await status() === '已连接实时更新' && connections === 2, 'new snapshot and stream after real reconnection');
60
+ assert.equal(streams.size, 1);
61
+ assert.equal(browser.exceptions.length, 0);
62
+ console.log('product-progress-status-test PASS (real product/browser/proxy/SSE; duplicate online stays connected; real offline reconnects once)');
63
+ } finally {
64
+ await browser?.close(); await server.cleanup();
65
+ upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
66
+ assert.equal(path.dirname(fixture), path.resolve(os.tmpdir())); assert.ok(path.basename(fixture).startsWith('pk-product-progress-'));
67
+ fs.rmSync(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
68
+ }
69
+ }
70
+ main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { createEngineServer } = require('../../_modules/knowledge-engine/src/server');
7
+ const { SettingsStore } = require('../lib/settings-store');
8
+ const { ModuleBridge } = require('../lib/module-bridge');
9
+
10
+ (async () => {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-shared-bindings-'));
12
+ const oldRoot = path.join(root, '原知识根目录'), newRoot = path.join(root, '新知识根目录');
13
+ const settings = new SettingsStore({ dataDir: path.join(root, 'shell') });
14
+ await settings.initialize({ knowledge: { rootPath: oldRoot } });
15
+ await settings.addDomain({ id: 'domain-tools', name: '开发工具' });
16
+ let engine;
17
+ async function start() {
18
+ engine = createEngineServer({ dataDir: path.join(root, 'engine'), consume: false, runtimeToken: 'scope-binding-test' });
19
+ await engine.ready;
20
+ await new Promise(resolve => engine.server.listen(0, '127.0.0.1', resolve));
21
+ return `http://127.0.0.1:${engine.server.address().port}`;
22
+ }
23
+ const logs = [];
24
+ const bridge = new ModuleBridge({ settingsStore: settings, runtimeToken: 'scope-binding-test', logger: { warn: (...args) => logs.push(args) } });
25
+ const upstream = bridge._fetch.bind(bridge);
26
+ bridge._fetch = (url, init) => url.startsWith(bridge.engineUrl) ? upstream(url, init) : Promise.resolve({ ok: false });
27
+ try {
28
+ bridge.engineUrl = await start();
29
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
30
+ const scopes = structuredClone(engine.store.state.sharedScopes);
31
+ const brain = scopes.find(scope => scope.kind === 'brain');
32
+ const domain = scopes.find(scope => scope.kind === 'domain');
33
+ engine.store.addProject({ projectId: 'project-test', repoPath: path.join(root, 'code'), knowledgePath: path.join(root, 'project-knowledge'), domainId: domain.id });
34
+ engine.store.state.memories.push({ id: 'memory-test', brainId: brain.id, status: 'candidate', text: '保持目录绑定稳定', evidence: { projectId: 'project-test', commitSha: 'a'.repeat(40), entryId: 'README.md' } });
35
+ engine.store.save();
36
+ await engine.coordinator.memories.update('memory-test', { status: 'confirmed' });
37
+ fs.writeFileSync(path.join(domain.knowledgePath, 'README.md'), '# 领域经验\n\n领域知识不能随根目录设置隐式移动。\n');
38
+ const memoryFile = engine.coordinator.memories.file(brain);
39
+ const before = fs.readFileSync(memoryFile);
40
+ await settings.updatePatch({ knowledge: { rootPath: newRoot } });
41
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true, 'changing index root must not invalidate shared memory wiring');
42
+ assert.deepEqual(engine.store.state.sharedScopes.map(scope => scope.knowledgePath), scopes.map(scope => scope.knowledgePath));
43
+ assert.deepEqual(fs.readFileSync(memoryFile), before);
44
+ assert.equal(settings.read().brain.knowledgePath, brain.knowledgePath);
45
+ assert.equal(settings.read().brain.domains[0].knowledgePath, domain.knowledgePath);
46
+ await assert.rejects(settings.updatePatch({ brain: { knowledgePath: newRoot } }), { code: 'INVALID_ARGUMENT' });
47
+ await assert.rejects(settings.bindSharedKnowledgePaths([{ ...brain, knowledgePath: newRoot }]), { code: 'IMMUTABLE_FIELD' });
48
+ await settings.addDomain({ id: 'domain-new', name: '新领域' });
49
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
50
+ assert.equal(engine.store.state.sharedScopes.find(scope => scope.id === 'domain-new').knowledgePath, path.join(newRoot, '_shared', 'domain-new'));
51
+ await engine.close();
52
+ bridge.engineUrl = await start();
53
+ assert.equal((await bridge.syncEngineConfiguration()).scopes, true);
54
+ assert.deepEqual(fs.readFileSync(memoryFile), before);
55
+ const memory = await engine.coordinator.retrieval.get({ projectId: 'project-test', spaceId: 'brain:brain-default', entryId: 'memories/confirmed.md' });
56
+ assert.ok(memory.chunks.some(chunk => chunk.chunk_text.includes('保持目录绑定稳定')));
57
+ const shared = await engine.coordinator.retrieval.get({ projectId: 'project-test', spaceId: 'domain:domain-tools', entryId: 'README.md' });
58
+ assert.ok(shared.chunks.some(chunk => chunk.chunk_text.includes('不能随根目录设置隐式移动')));
59
+ await engine.close(); engine = null;
60
+ const pending = await bridge.syncEngineConfiguration();
61
+ assert.equal(pending.scopes, false);
62
+ assert.equal(pending.scopeStatus.status, 'pending');
63
+ assert.ok(logs.some(entry => entry[0] === 'modules.scopes.pending'));
64
+ assert.equal(settings.read().brain.knowledgePath, brain.knowledgePath);
65
+ console.log('shared-scope-root-binding-test PASS');
66
+ } finally { if (engine) await engine.close(); }
67
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { spawnServer } = require('./helpers/spawn-server');
6
6
  const freePort = require('./helpers/free-port');
7
7
  const { readEndpoint } = require('../lib/runtime-endpoint');
8
+ const { spawn } = require('node:child_process');
8
9
 
9
10
  (async () => {
10
11
  const port = await freePort();
@@ -19,7 +20,10 @@ const { readEndpoint } = require('../lib/runtime-endpoint');
19
20
  assert.equal(readEndpoint(spawned.dataDir)?.pid, spawned.child.pid);
20
21
  let output = '';
21
22
  spawned.child.stderr.on('data', bytes => { output += bytes; });
22
- await spawned.cleanup(); stopped = true;
23
+ const locker = process.platform === 'win32' ? spawn(process.execPath, ['-e', 'process.stdout.write("holding-cwd\\n");setTimeout(()=>process.exit(0),2500)'], { cwd: spawned.dataDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) : null;
24
+ if (locker) await new Promise((resolve, reject) => { locker.once('error', reject); locker.stdout.once('data', resolve); });
25
+ try { await spawned.cleanup(); stopped = true; }
26
+ finally { if (locker?.exitCode === null) { locker.kill(); await new Promise(resolve => locker.once('close', resolve)); } }
23
27
  assert.notEqual(spawned.child.exitCode, null, output);
24
28
  assert.equal(spawned.child.signalCode, null, 'normal cleanup must drain the server instead of force killing it');
25
29
  assert.equal(spawned.child.exitCode, 0, output);
@@ -3,11 +3,27 @@ const assert = require('node:assert/strict');
3
3
  const path = require('node:path');
4
4
  const { StorageLayout } = require('../lib/storage-layout');
5
5
  const { ModuleBridge } = require('../lib/module-bridge');
6
+ const { KnowledgeToolRuntime } = require('../lib/knowledge-tool-runtime');
6
7
  const root = path.resolve('D:/knowledge storage 中文');
7
8
  const layout = new StorageLayout({ dataDir: path.resolve('D:/metadata') });
8
9
  assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: root } }), path.join(root, '.indexes'));
9
10
  assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: '' } }), path.join(layout.getDataDir(), 'vectorhub'));
10
11
  const settingsStore = { read: () => ({ knowledge: { rootPath: root }, brain: { id: 'brain-default', domains: [] } }) };
12
+ let knowledgeRoot = root;
13
+ const tools = new KnowledgeToolRuntime({
14
+ layout,
15
+ settingsStore: { read: () => ({ knowledge: { rootPath: knowledgeRoot } }) },
16
+ registryStore: { listIds: () => ['project-storage'] },
17
+ projectStore: {
18
+ readConfig: () => ({ displayName: 'Storage', repoPath: path.join(root, 'repo'), knowledgePath: path.join(root, 'project') }),
19
+ readState: () => ({ index: { generation: 3 } }),
20
+ },
21
+ });
22
+ assert.equal(tools.resolveProject({ projectId: 'project-storage' }).indexPath, path.join(root, '.indexes'), 'MCP/CLI resolution must report the vector-hub location, not the retired shell database');
23
+ knowledgeRoot = path.join(root, 'changed root');
24
+ assert.equal(tools.resolveProject({ projectId: 'project-storage' }).indexPath, path.join(knowledgeRoot, '.indexes'), 'a running MCP process must reread the selected knowledge root');
25
+ knowledgeRoot = '';
26
+ assert.equal(tools.resolveProject({ projectId: 'project-storage' }).indexPath, path.join(layout.getDataDir(), 'vectorhub'));
11
27
  const bridge = new ModuleBridge({ settingsStore, dataDir: layout.getDataDir() });
12
28
  const spec = bridge._supervisedSpecs().find(spec => spec.name === 'vector-hub');
13
29
  assert.equal(spec.env.VECTOR_HUB_ROOT, path.join(root, '.indexes'));
@@ -24,7 +24,10 @@ async function connectEngine(runtime) {
24
24
  step = 'project-wiring';
25
25
  for (const id of runtime.registryStore.listIds()) {
26
26
  projectId = id;
27
- const config = runtime.projectStore.readConfig(id);
27
+ if (!runtime.registryStore.readDisplaySnapshot(id)) continue;
28
+ let config;
29
+ try { config = runtime.projectStore.readConfig(id); }
30
+ catch (error) { if (!runtime.registryStore.readDisplaySnapshot(id)) continue; throw error; }
28
31
  const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
29
32
  if (!modules.engine.ok || !modules.eventBridge.ok) throw new Error('PROJECT_WIRING_PENDING');
30
33
  }
@@ -163,7 +163,7 @@ class KnowledgeToolRuntime {
163
163
  repoPath: path.resolve(resolvedRepoPath || config.repoPath),
164
164
  knowledgePath: path.resolve(config.knowledgePath),
165
165
  kbPath: path.resolve(config.knowledgePath),
166
- indexPath: this.layout.getIndexPath(),
166
+ indexPath: this.layout.getVectorRootPath(this.settingsStore.read({ allowMissing: true })),
167
167
  primarySpaceId: `project:${projectId}`,
168
168
  relatedProjectIds: Array.isArray(config.relatedProjectIds) ? [...config.relatedProjectIds] : [],
169
169
  index: { ...state.index },