@sanqianx/project-knowledge 5.0.17 → 5.0.18-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.
@@ -0,0 +1,92 @@
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 { markIntent } = require('../lib/integration-intent');
7
+ const { IntegrationAutoRepair } = require('../lib/integration-auto-repair');
8
+
9
+ const tmpDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kb-auto-repair-'));
10
+
11
+ const makeManager = ({ statusClients, onInstall }) => {
12
+ const calls = { statusAll: 0, installAll: [] };
13
+ return {
14
+ calls,
15
+ async statusAll() {
16
+ calls.statusAll += 1;
17
+ return { clients: statusClients.map(client => ({ ...client, developmentCapture: { ...client.developmentCapture } })) };
18
+ },
19
+ async installAll(options) {
20
+ calls.installAll.push(options);
21
+ return onInstall(options);
22
+ },
23
+ };
24
+ };
25
+
26
+ (async () => {
27
+ // D + E: runOnce repairs exactly the connected-and-not-installed clients, once.
28
+ {
29
+ const dir = tmpDataDir();
30
+ markIntent(dir, 'claude', 'connected');
31
+ markIntent(dir, 'codex', 'disconnected');
32
+ markIntent(dir, 'zcode', 'connected');
33
+ const manager = makeManager({
34
+ statusClients: [
35
+ { client: 'claude', developmentCapture: { installed: false } },
36
+ { client: 'codex', developmentCapture: { installed: false } },
37
+ { client: 'zcode', developmentCapture: { installed: true } },
38
+ ],
39
+ onInstall: async options => ({ clients: [{ client: options.clients[0], developmentCapture: { ok: true } }] }),
40
+ });
41
+ const repair = new IntegrationAutoRepair({ dataDir: dir, createManager: () => manager });
42
+ await repair.runOnce();
43
+ assert.equal(manager.calls.installAll.length, 1, 'disconnected and already-installed clients are never repaired');
44
+ assert.deepEqual(manager.calls.installAll[0], { clients: ['claude'] });
45
+ const repaired = repair.status().claude;
46
+ assert.equal(repaired.state, 'repaired');
47
+ assert.equal(repaired.error, null);
48
+ assert.ok(!Number.isNaN(Date.parse(repaired.at)), 'at must be an ISO timestamp');
49
+ await repair.runOnce();
50
+ assert.equal(manager.calls.statusAll, 2, 'second run rescans status');
51
+ assert.equal(manager.calls.installAll.length, 1, 'a second runOnce must not repair the same client again');
52
+ }
53
+ // F + G: failures are recorded without interrupting others; retry repairs.
54
+ {
55
+ const dir = tmpDataDir();
56
+ for (const client of ['claude', 'codex', 'zcode']) markIntent(dir, client, 'connected');
57
+ let claudeBroken = true;
58
+ const manager = makeManager({
59
+ statusClients: [
60
+ { client: 'claude', developmentCapture: { installed: false } },
61
+ { client: 'codex', developmentCapture: { installed: false } },
62
+ { client: 'zcode', developmentCapture: { installed: false } },
63
+ ],
64
+ onInstall: async options => {
65
+ const client = options.clients[0];
66
+ if (client === 'claude' && claudeBroken) throw new Error('boom');
67
+ if (client === 'zcode') return { clients: [{ client, developmentCapture: { ok: false, detail: 'hook conflict' } }] };
68
+ return { clients: [{ client, developmentCapture: { ok: true } }] };
69
+ },
70
+ });
71
+ const repair = new IntegrationAutoRepair({ dataDir: dir, createManager: () => manager });
72
+ await repair.runOnce();
73
+ assert.equal(manager.calls.installAll.length, 3, 'every connected-and-not-installed client gets one attempt');
74
+ const status = repair.status();
75
+ assert.equal(status.claude.state, 'failed');
76
+ assert.ok(status.claude.error, 'a thrown installAll records the error message');
77
+ assert.equal(status.codex.state, 'repaired', 'a failing client does not interrupt the others');
78
+ assert.equal(status.zcode.state, 'failed');
79
+ assert.ok(status.zcode.error, 'ok=false records a detail as the error');
80
+ claudeBroken = false;
81
+ const retried = await repair.retry('claude');
82
+ assert.equal(retried.state, 'repaired');
83
+ assert.equal(retried.error, null);
84
+ assert.equal(manager.calls.installAll.length, 4, 'retry ignores the attempted set');
85
+ assert.deepEqual(manager.calls.installAll[3], { clients: ['claude'] });
86
+ assert.equal(repair.status().claude.state, 'repaired', 'a successful retry overwrites the failure');
87
+ const snapshot = repair.status();
88
+ snapshot.claude = { state: 'tampered' };
89
+ assert.equal(repair.status().claude.state, 'repaired', 'status() returns a copy');
90
+ }
91
+ console.log('integration auto repair test PASS');
92
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -0,0 +1,63 @@
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 { SCHEMA, readIntent, markIntent, bootstrapIntent, summarizeClient } = require('../lib/integration-intent');
7
+
8
+ const tmpDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kb-intent-'));
9
+ const intentPath = dataDir => path.join(dataDir, 'integration-intent.json');
10
+
11
+ // A: markIntent merges atomically and readIntent round-trips.
12
+ {
13
+ const dir = tmpDataDir();
14
+ const first = markIntent(dir, 'claude', 'connected');
15
+ assert.deepEqual(first, { schema: SCHEMA, clients: { claude: 'connected' } });
16
+ const second = markIntent(dir, 'codex', 'disconnected');
17
+ assert.deepEqual(second, { schema: SCHEMA, clients: { claude: 'connected', codex: 'disconnected' } });
18
+ assert.deepEqual(readIntent(dir), { schema: SCHEMA, clients: { claude: 'connected', codex: 'disconnected' } });
19
+ assert.deepEqual(fs.readdirSync(dir), ['integration-intent.json'], 'atomic write must not leave temp files behind');
20
+ assert.throws(() => markIntent(dir, 'claude', 'sometimes'), 'invalid states must throw');
21
+ assert.equal(readIntent(tmpDataDir()), null, 'missing file reads as null');
22
+ const corrupt = tmpDataDir();
23
+ fs.writeFileSync(intentPath(corrupt), '{ not json');
24
+ assert.equal(readIntent(corrupt), null, 'corrupt file reads as null');
25
+ const foreign = tmpDataDir();
26
+ fs.writeFileSync(intentPath(foreign), JSON.stringify({ schema: 'someone-else/v9', clients: { claude: 'connected' } }));
27
+ assert.equal(readIntent(foreign), null, 'schema mismatch reads as null');
28
+ const unknown = tmpDataDir();
29
+ fs.writeFileSync(intentPath(unknown), JSON.stringify({ schema: SCHEMA, clients: { claude: 'connected', codex: 'weird-state' } }));
30
+ assert.deepEqual(readIntent(unknown), { schema: SCHEMA, clients: { claude: 'connected' } }, 'unknown client values are dropped');
31
+ }
32
+ // B: bootstrapIntent only fills gaps and never rewrites decisions.
33
+ {
34
+ const dir = tmpDataDir();
35
+ markIntent(dir, 'claude', 'disconnected');
36
+ const clientsStatus = [
37
+ { client: 'claude', developmentCapture: { installed: true } },
38
+ { client: 'codex', developmentCapture: { installed: true } },
39
+ { client: 'zcode', developmentCapture: { installed: false } },
40
+ ];
41
+ const bootstrapped = bootstrapIntent(dir, clientsStatus);
42
+ assert.deepEqual(bootstrapped, { schema: SCHEMA, clients: { claude: 'disconnected', codex: 'connected', zcode: 'disconnected' } }, 'existing entries are never overwritten');
43
+ const before = fs.statSync(intentPath(dir)).mtimeMs;
44
+ bootstrapIntent(dir, clientsStatus);
45
+ assert.equal(fs.statSync(intentPath(dir)).mtimeMs, before, 'no-change bootstrap must not rewrite the file');
46
+ const fresh = tmpDataDir();
47
+ const created = bootstrapIntent(fresh, [{ client: 'codex', developmentCapture: { installed: false } }]);
48
+ assert.deepEqual(created, { schema: SCHEMA, clients: { codex: 'disconnected' } });
49
+ assert.deepEqual(readIntent(fresh), created, 'bootstrap creates the file when missing');
50
+ }
51
+ // C: summarizeClient covers all five states plus precedence rules.
52
+ {
53
+ const failedRepair = { claude: { state: 'failed', at: new Date().toISOString(), error: 'boom' } };
54
+ assert.equal(summarizeClient({ client: 'claude', available: true, developmentCapture: { state: 'checking' } }, null), 'checking');
55
+ assert.equal(summarizeClient({ client: 'claude', available: true, developmentCapture: {}, knowledgeIntegration: { state: 'checking' } }, null), 'checking');
56
+ assert.equal(summarizeClient({ client: 'claude', available: false, developmentCapture: { state: 'checking' } }, null), 'checking', 'checking wins over not-detected');
57
+ assert.equal(summarizeClient({ client: 'claude', available: false, configFound: false, developmentCapture: {} }, null), 'not-detected');
58
+ assert.equal(summarizeClient({ client: 'claude', available: false, configFound: true, developmentCapture: {} }, null), 'disconnected', 'configFound alone counts as detected');
59
+ assert.equal(summarizeClient({ client: 'claude', available: true, developmentCapture: { installed: true } }, failedRepair), 'connected', 'installed outranks a failed repair');
60
+ assert.equal(summarizeClient({ client: 'claude', available: true, developmentCapture: {} }, failedRepair), 'error');
61
+ assert.equal(summarizeClient({ client: 'claude', available: true, developmentCapture: {} }, null), 'disconnected');
62
+ }
63
+ console.log('integration intent test PASS');
@@ -50,7 +50,9 @@ async function main() {
50
50
  const stub = http.createServer((req, res) => {
51
51
  const url = new URL(req.url, 'http://localhost');
52
52
  hits.push(`${req.method} ${url.pathname}${url.search}`);
53
- if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; }
53
+ // Real console health carries registeredProjects; the identity check
54
+ // rejects generic {"ok":true} bodies from foreign port squatters.
55
+ if (url.pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, registeredProjects: registered.length })); return; }
54
56
  if (url.pathname === '/api/echo') {
55
57
  res.writeHead(200, { 'Content-Type': 'application/json' });
56
58
  res.end(JSON.stringify({ echo: `${req.method} ${url.pathname}`, query: Object.fromEntries(url.searchParams) }));
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const http = require('node:http');
4
+ const { resolveModuleUrl } = require('../lib/module-port-resolver');
5
+ const { ModuleBridge } = require('../lib/module-bridge');
6
+
7
+ const countingProbe = outcome => {
8
+ const seen = [];
9
+ return {
10
+ probe: async url => { seen.push(url); return outcome; },
11
+ get seen() { return seen; },
12
+ get calls() { return seen.length; },
13
+ };
14
+ };
15
+
16
+ const countingPortFree = isFree => {
17
+ const seen = [];
18
+ return {
19
+ portFree: async port => { seen.push(port); return isFree(port); },
20
+ get seen() { return seen; },
21
+ get calls() { return seen.length; },
22
+ };
23
+ };
24
+
25
+ (async () => {
26
+ // A: healthy module on the preferred port is reused without any port scan.
27
+ {
28
+ const probe = countingProbe('module');
29
+ const portFree = countingPortFree(() => false);
30
+ const resolved = await resolveModuleUrl({ kind: 'terminal', preferredUrl: 'http://127.0.0.1:5760', explicit: false, portFree: portFree.portFree, probe: probe.probe });
31
+ assert.equal(resolved.source, 'reused');
32
+ assert.equal(resolved.url, 'http://127.0.0.1:5760');
33
+ assert.equal(resolved.port, 5760);
34
+ assert.deepEqual(probe.seen, ['http://127.0.0.1:5760']);
35
+ assert.equal(portFree.calls, 0, 'reuse must never invoke portFree');
36
+ }
37
+ // B: foreign occupant on the preferred port moves the module to the next free port.
38
+ {
39
+ const probe = countingProbe('foreign');
40
+ const portFree = countingPortFree(port => port === 5761);
41
+ const resolved = await resolveModuleUrl({ kind: 'terminal', preferredUrl: 'http://127.0.0.1:5760', explicit: false, portFree: portFree.portFree, probe: probe.probe });
42
+ assert.equal(resolved.source, 'dynamic');
43
+ assert.equal(resolved.url, 'http://127.0.0.1:5761');
44
+ assert.equal(resolved.port, 5761);
45
+ assert.equal(resolved.offset, 1);
46
+ assert.deepEqual(portFree.seen, [5760, 5761]);
47
+ }
48
+ // C: unreachable preferred port skips three busy ports before picking the fourth.
49
+ {
50
+ const probe = countingProbe('unreachable');
51
+ const portFree = countingPortFree(port => port === 8790);
52
+ const resolved = await resolveModuleUrl({ kind: 'vector-hub', preferredUrl: 'http://127.0.0.1:8787', explicit: false, portFree: portFree.portFree, probe: probe.probe });
53
+ assert.equal(resolved.source, 'dynamic');
54
+ assert.equal(resolved.url, 'http://127.0.0.1:8790');
55
+ assert.equal(resolved.port, 8790);
56
+ assert.equal(resolved.offset, 3);
57
+ assert.deepEqual(portFree.seen, [8787, 8788, 8789, 8790]);
58
+ }
59
+ // D: exhausted scan range fails loudly with kind and port span in the message.
60
+ {
61
+ const probe = countingProbe('unreachable');
62
+ const portFree = countingPortFree(() => false);
63
+ await assert.rejects(
64
+ () => resolveModuleUrl({ kind: 'event-bridge', preferredUrl: 'http://127.0.0.1:8790', explicit: false, portFree: portFree.portFree, probe: probe.probe }),
65
+ error => {
66
+ assert.equal(error.code, 'NO_FREE_MODULE_PORT');
67
+ assert.ok(error.message.includes('event-bridge'), `message must name the module kind: ${error.message}`);
68
+ assert.ok(error.message.includes('8790'), `message must name the first scanned port: ${error.message}`);
69
+ assert.ok(error.message.includes('8809'), `message must name the last scanned port: ${error.message}`);
70
+ return true;
71
+ },
72
+ );
73
+ assert.equal(portFree.calls, 20, 'default scanRange must probe 20 consecutive ports before failing');
74
+ assert.deepEqual(portFree.seen, Array.from({ length: 20 }, (_, index) => 8790 + index));
75
+ }
76
+ // E: an explicit external URL short-circuits before any probe or port scan.
77
+ {
78
+ const probe = countingProbe('module');
79
+ const portFree = countingPortFree(() => true);
80
+ const resolved = await resolveModuleUrl({ kind: 'knowledge-engine', preferredUrl: 'http://192.168.1.5:5790', explicit: true, portFree: portFree.portFree, probe: probe.probe });
81
+ assert.equal(resolved.source, 'external');
82
+ assert.equal(resolved.url, 'http://192.168.1.5:5790');
83
+ assert.equal(probe.calls, 0, 'explicit external URL must never be probed');
84
+ assert.equal(portFree.calls, 0, 'explicit external URL must never trigger a port scan');
85
+ }
86
+ // F: a preferred URL without an explicit port is rejected.
87
+ {
88
+ const probe = countingProbe('module');
89
+ const portFree = countingPortFree(() => true);
90
+ await assert.rejects(
91
+ () => resolveModuleUrl({ kind: 'terminal', preferredUrl: 'http://127.0.0.1', explicit: false, portFree: portFree.portFree, probe: probe.probe }),
92
+ error => { assert.equal(error.code, 'INVALID_MODULE_URL'); return true; },
93
+ );
94
+ }
95
+ // G (integration): an HTTP service squatting on the preferred port with
96
+ // 404 responses — exactly what happens when the shell's own fallback range
97
+ // 5757..5776 lands on the terminal default 5760 — must not be misread as a
98
+ // module and must not crash-loop: the bridge moves to the next free port.
99
+ {
100
+ const squatter = http.createServer((req, res) => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'not found' })); });
101
+ await new Promise(resolve => squatter.listen(0, '127.0.0.1', resolve));
102
+ const squatterPort = squatter.address().port;
103
+ const bridge = new ModuleBridge();
104
+ // Isolate the terminal leg: everything else is explicit/external so the
105
+ // resolution loop only probes the squatter port for the terminal kind.
106
+ bridge.terminalUrl = `http://127.0.0.1:${squatterPort}`;
107
+ bridge.explicitModuleUrls = { terminal: false, vectorHub: true, eventBridge: true, engine: true };
108
+ try {
109
+ await bridge._resolveManagedUrls();
110
+ assert.equal(bridge.moduleUrlSources.terminal, 'dynamic', '404 squatter must force a dynamic move, never reuse');
111
+ assert.equal(bridge.terminalUrl, `http://127.0.0.1:${squatterPort + 1}`, 'module moves to the port right after the squatter');
112
+ } finally {
113
+ squatter.closeAllConnections?.();
114
+ await new Promise(resolve => squatter.close(resolve));
115
+ }
116
+ }
117
+ console.log('module dynamic port test PASS');
118
+ })().catch(error => { console.error(error); process.exitCode = 1; });
@@ -8,7 +8,7 @@ const workflow = fs.readFileSync(workflowPath, 'utf8');
8
8
 
9
9
  assert.match(workflow, /^\s*push:/m, 'non-release CI must run for branch pushes');
10
10
  assert.match(workflow, /^\s*pull_request:/m, 'non-release CI must run for pull requests');
11
- assert.match(workflow, /node:\s*\['18\.x', '24\.x'\]/, 'Linux CI must cover Node 18 and 24');
11
+ assert.match(workflow, /node:\s*\['22\.x', '24\.x'\]/, 'Linux CI must cover the declared Node >=22 contract (oldest supported 22.x plus current 24.x)');
12
12
  assert.match(workflow, /runs-on:\s*windows-latest/, 'CI must include a Windows job');
13
13
  assert.match(workflow, /npm test -- --no-report/, 'CI must run the core regression suite');
14
14
  assert.match(workflow, /npm pack --dry-run --json/, 'CI must audit the npm package contents');
@@ -1,70 +1,73 @@
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; });
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
+ // The product sends the progress/snapshot reads with query parameters
19
+ // (e.g. /api/progress?summary=1), so route on the pathname only.
20
+ const cleanPath = req.url.split('?')[0];
21
+ if (cleanPath === '/api/progress') {
22
+ connections++; streams.add(res); res.on('close', () => streams.delete(res));
23
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
24
+ res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
25
+ return;
26
+ }
27
+ req.resume();
28
+ if (cleanPath === '/api/snapshot') snapshots++;
29
+ if (cleanPath.includes('/health')) healthRequests++;
30
+ res.writeHead(200, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify(cleanPath === '/api/snapshot' ? snapshot : cleanPath === '/api/projects' ? { projects: [] } : { ok: true, name: 'knowledge-engine', dataSchemaVersion: 3 }));
32
+ });
33
+ await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
34
+ const moduleUrl = `http://127.0.0.1:${upstream.address().port}`;
35
+ const port = 8400 + process.pid % 200;
36
+ const base = `http://127.0.0.1:${port}`;
37
+ const server = spawnServer({ root: ROOT, port, dataDir: path.join(fixture, 'data'), analysisOwner: 'engine', tag: 'product-progress', extraEnv: {
38
+ KB_TERMINAL_URL: moduleUrl, KB_VECTORHUB_URL: moduleUrl,
39
+ KB_EVENTBRIDGE_URL: moduleUrl, KB_KNOWLEDGE_ENGINE_URL: moduleUrl,
40
+ } });
41
+ let browser;
42
+ try {
43
+ await waitFor(async () => { try { return (await fetch(base + '/api/health')).ok; } catch { return false; } }, 'isolated product', 20000);
44
+ browser = await launchCdpBrowser({ chrome: findChrome(), profileDir: path.join(fixture, 'browser'), url: base, width: 1920, height: 1080 });
45
+ await browser.setViewport(1920, 1080);
46
+ const run = expression => browser.evaluate(expression);
47
+ const status = () => run("document.getElementById('activityConnection').textContent");
48
+ await waitFor(async () => await status() === '已连接实时更新', 'initial SSE snapshot');
49
+ assert.equal(connections, 1);
50
+ for (let attempt = 0; attempt < 2; attempt++) {
51
+ const beforeHealth = healthRequests, beforeSnapshot = snapshots;
52
+ await run("window.dispatchEvent(new Event('online'))");
53
+ await waitFor(() => healthRequests > beforeHealth && snapshots > beforeSnapshot, 'online registry and snapshot refresh');
54
+ assert.equal(await status(), '已连接实时更新', 'a duplicate online event must not leave a healthy stream labelled as recovering');
55
+ assert.equal(connections, 1, 'a redundant online receipt cannot create another progress subscription');
56
+ }
57
+ await browser.send('Network.enable');
58
+ await browser.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
59
+ await waitFor(async () => (await status()).includes('连接中断'), 'genuine offline feedback');
60
+ await waitFor(() => streams.size === 0, 'offline closes the upstream stream');
61
+ await browser.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
62
+ await waitFor(async () => await status() === '已连接实时更新' && connections === 2, 'new snapshot and stream after real reconnection');
63
+ assert.equal(streams.size, 1);
64
+ assert.equal(browser.exceptions.length, 0);
65
+ console.log('product-progress-status-test PASS (real product/browser/proxy/SSE; duplicate online stays connected; real offline reconnects once)');
66
+ } finally {
67
+ await browser?.close(); await server.cleanup();
68
+ upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
69
+ assert.equal(path.dirname(fixture), path.resolve(os.tmpdir())); assert.ok(path.basename(fixture).startsWith('pk-product-progress-'));
70
+ fs.rmSync(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
71
+ }
72
+ }
73
+ main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
@@ -1,68 +1,75 @@
1
- 'use strict';
2
- const assert = require('node:assert/strict');
3
- const path = require('node:path');
4
- const { StorageLayout } = require('../lib/storage-layout');
5
- const { ModuleBridge } = require('../lib/module-bridge');
6
- const { KnowledgeToolRuntime } = require('../lib/knowledge-tool-runtime');
7
- const root = path.resolve('D:/knowledge storage 中文');
8
- const layout = new StorageLayout({ dataDir: path.resolve('D:/metadata') });
9
- assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: root } }), path.join(root, '.indexes'));
10
- assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: '' } }), path.join(layout.getDataDir(), 'vectorhub'));
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'));
27
- const bridge = new ModuleBridge({ settingsStore, dataDir: layout.getDataDir() });
28
- const spec = bridge._supervisedSpecs().find(spec => spec.name === 'vector-hub');
29
- assert.equal(spec.env.VECTOR_HUB_ROOT, path.join(root, '.indexes'));
30
- assert.equal(spec.env.VECTOR_HUB_CONFIG, path.join(layout.getDataDir(), 'vectorhub', 'config.json'));
31
- const calls = [];
32
- bridge._fetch = async (url, init) => { const body = JSON.parse(init.body); calls.push({ url, body }); return { ok: true, json: async () => url.includes('/api/internal/runtime-config') ? { ok: true, rootPath: path.join(root, '.indexes') } : {} }; };
33
- bridge.syncEngineConfiguration().then(async status => {
34
- assert.equal(calls.find(call => call.url.includes('/api/internal/runtime-config')).body.storageRoot, path.join(root, '.indexes'));
35
- assert.equal(status.indexStorage.status, 'ready');
36
- let currentRoot = root, release, started;
37
- const entered = new Promise(resolve => { started = resolve; });
38
- const held = new Promise(resolve => { release = resolve; });
39
- const queued = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => ({ knowledge: { rootPath: currentRoot }, brain: { id: 'brain-default', domains: [] } }) } });
40
- const applied = [];
41
- queued._fetch = async (url, init) => {
42
- const body = JSON.parse(init.body);
43
- if (url.endsWith('/api/internal/runtime-config')) {
44
- applied.push(body.storageRoot);
45
- if (applied.length === 1) { started(); await held; }
46
- return { ok: true, json: async () => ({ rootPath: body.storageRoot }) };
47
- }
48
- return { ok: true, json: async () => null };
49
- };
50
- const first = queued.syncEngineConfiguration(); await entered;
51
- currentRoot = path.join(root, 'new root');
52
- const second = queued.syncEngineConfiguration(); release();
53
- await first;
54
- const latest = await second;
55
- assert.deepEqual(applied, [path.join(root, '.indexes'), path.join(currentRoot, '.indexes')]);
56
- assert.equal(latest.indexStorage.actualPath, path.join(currentRoot, '.indexes'));
57
- assert.equal(latest.indexStorage.status, 'ready');
58
- const broken = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => { throw new Error('corrupt settings'); } } });
59
- let revoked;
60
- broken._fetch = async (url, init) => {
61
- if (url.endsWith('/api/internal/runtime-config')) revoked = JSON.parse(init.body);
62
- return { ok: false };
63
- };
64
- const unavailable = await broken.syncEngineConfiguration();
65
- assert.deepEqual(revoked, { settings: null });
66
- assert.equal(unavailable.indexStorage.status, 'pending');
67
- console.log('vector-storage-root-test PASS');
68
- }).catch(error => { console.error(error); process.exitCode = 1; });
1
+ 'use strict';
2
+ const assert = require('node:assert/strict');
3
+ const path = require('node:path');
4
+ const { StorageLayout } = require('../lib/storage-layout');
5
+ const { ModuleBridge } = require('../lib/module-bridge');
6
+ const { KnowledgeToolRuntime } = require('../lib/knowledge-tool-runtime');
7
+ const root = path.resolve('D:/knowledge storage 中文');
8
+ const layout = new StorageLayout({ dataDir: path.resolve('D:/metadata') });
9
+ assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: root } }), path.join(root, '.indexes'));
10
+ assert.equal(layout.getVectorRootPath({ knowledge: { rootPath: '' } }), path.join(layout.getDataDir(), 'vectorhub'));
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'));
27
+ const bridge = new ModuleBridge({ settingsStore, dataDir: layout.getDataDir() });
28
+ // The supervisor refuses to spawn the vendored vector-hub on Node < 22
29
+ // (module-bridge: "vector-hub declares engines >= 22"), so its supervised
30
+ // spec — and these env assertions — only exist on qualifying runtimes.
31
+ const vectorHubSpec = bridge._supervisedSpecs().find(spec => spec.name === 'vector-hub');
32
+ if (parseInt(process.versions.node, 10) >= 22) {
33
+ assert.equal(vectorHubSpec.env.VECTOR_HUB_ROOT, path.join(root, '.indexes'));
34
+ assert.equal(vectorHubSpec.env.VECTOR_HUB_CONFIG, path.join(layout.getDataDir(), 'vectorhub', 'config.json'));
35
+ } else {
36
+ assert.equal(vectorHubSpec, undefined, 'vector-hub must not be supervised on Node < 22');
37
+ }
38
+ const calls = [];
39
+ bridge._fetch = async (url, init) => { const body = JSON.parse(init.body); calls.push({ url, body }); return { ok: true, json: async () => url.includes('/api/internal/runtime-config') ? { ok: true, rootPath: path.join(root, '.indexes') } : {} }; };
40
+ bridge.syncEngineConfiguration().then(async status => {
41
+ assert.equal(calls.find(call => call.url.includes('/api/internal/runtime-config')).body.storageRoot, path.join(root, '.indexes'));
42
+ assert.equal(status.indexStorage.status, 'ready');
43
+ let currentRoot = root, release, started;
44
+ const entered = new Promise(resolve => { started = resolve; });
45
+ const held = new Promise(resolve => { release = resolve; });
46
+ const queued = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => ({ knowledge: { rootPath: currentRoot }, brain: { id: 'brain-default', domains: [] } }) } });
47
+ const applied = [];
48
+ queued._fetch = async (url, init) => {
49
+ const body = JSON.parse(init.body);
50
+ if (url.endsWith('/api/internal/runtime-config')) {
51
+ applied.push(body.storageRoot);
52
+ if (applied.length === 1) { started(); await held; }
53
+ return { ok: true, json: async () => ({ rootPath: body.storageRoot }) };
54
+ }
55
+ return { ok: true, json: async () => null };
56
+ };
57
+ const first = queued.syncEngineConfiguration(); await entered;
58
+ currentRoot = path.join(root, 'new root');
59
+ const second = queued.syncEngineConfiguration(); release();
60
+ await first;
61
+ const latest = await second;
62
+ assert.deepEqual(applied, [path.join(root, '.indexes'), path.join(currentRoot, '.indexes')]);
63
+ assert.equal(latest.indexStorage.actualPath, path.join(currentRoot, '.indexes'));
64
+ assert.equal(latest.indexStorage.status, 'ready');
65
+ const broken = new ModuleBridge({ dataDir: layout.getDataDir(), settingsStore: { read: () => { throw new Error('corrupt settings'); } } });
66
+ let revoked;
67
+ broken._fetch = async (url, init) => {
68
+ if (url.endsWith('/api/internal/runtime-config')) revoked = JSON.parse(init.body);
69
+ return { ok: false };
70
+ };
71
+ const unavailable = await broken.syncEngineConfiguration();
72
+ assert.deepEqual(revoked, { settings: null });
73
+ assert.equal(unavailable.indexStorage.status, 'pending');
74
+ console.log('vector-storage-root-test PASS');
75
+ }).catch(error => { console.error(error); process.exitCode = 1; });