@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +140 -0
- package/README.md +11 -4
- package/_modules/knowledge-engine/README.md +9 -1
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/coordinator.js +11 -2
- package/_modules/knowledge-engine/src/headless-analyzer.js +2 -1
- package/_modules/knowledge-engine/src/memories.js +133 -24
- package/_modules/knowledge-engine/src/retrieval.js +19 -4
- package/_modules/knowledge-engine/src/runtime/contracts.js +1 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +6 -2
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +7 -2
- package/_modules/knowledge-engine/src/server.js +1 -1
- package/_modules/knowledge-engine/src/store.js +9 -6
- package/_site/_test/bin-cli-test.js +15 -2
- package/_site/_test/mcp-server-test.js +1 -1
- package/_site/_test/module-agent-start-proxy-test.js +65 -0
- package/_site/_test/module-bridge-eventbridge-test.js +1 -1
- package/_site/_test/module-wiring-unbind-race-test.js +59 -0
- package/_site/_test/path-consistency-test.js +1 -1
- package/_site/_test/product-diagnostics-ui-test.js +5 -1
- package/_site/_test/product-progress-status-test.js +70 -0
- package/_site/_test/shared-scope-root-binding-test.js +67 -0
- package/_site/_test/test-server-cleanup-test.js +5 -1
- package/_site/_test/vector-storage-root-test.js +16 -0
- package/_site/lib/engine-connection.js +4 -1
- package/_site/lib/knowledge-tool-runtime.js +1 -1
- package/_site/lib/module-bridge.js +65 -8
- package/_site/lib/server-app.js +4 -2
- package/_site/lib/settings-store.js +20 -0
- package/module-runtime-manifest.json +41 -41
- package/package.json +3 -3
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/ui/index.html +2 -2
- package/ui/product.css +1 -0
- package/ui/product.js +62 -10
- 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:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 },
|