@sanqianx/project-knowledge 5.0.9-rc.14 → 5.0.9-rc.15
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 +15 -0
- package/_site/_test/bin-cli-test.js +15 -2
- package/_site/_test/module-bridge-eventbridge-test.js +1 -1
- package/_site/_test/module-wiring-unbind-race-test.js +59 -0
- package/_site/lib/engine-connection.js +4 -1
- package/_site/lib/module-bridge.js +30 -2
- package/_site/lib/server-app.js +4 -2
- package/package.json +1 -1
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.0.9-rc.15] - 2026-10-11
|
|
4
|
+
|
|
5
|
+
- Serialize registration, module removal and registry deletion for each project.
|
|
6
|
+
Recheck membership inside the wiring lease so delayed supervisor registration
|
|
7
|
+
cannot resurrect a project after keep-data unbind. Unrelated projects remain
|
|
8
|
+
independent; failed deletion can still be repaired and retried.
|
|
9
|
+
- Reproduce the race with a delayed real HTTP registration and preserve the
|
|
10
|
+
downloaded rc.14 Node 22 UI failure. Include the regression in the package.
|
|
11
|
+
- Use non-binding CLI readiness probes in tests; the old bind probe could take
|
|
12
|
+
the exact port while its own foreground child was starting.
|
|
13
|
+
- Real MiniMax acceptance covers overlapping analyses, another commit and a
|
|
14
|
+
dirty developer draft during analysis, streaming, secondary development,
|
|
15
|
+
four installed clients and evidenced preference candidates. New downloaded
|
|
16
|
+
candidate acceptance and actual-user cutover still precede stable latest.
|
|
17
|
+
|
|
3
18
|
## [5.0.9-rc.14] - 2026-10-11
|
|
4
19
|
|
|
5
20
|
- Vendor engine 0.2.3-rc.7: isolated maintenance consumes frozen evidence even
|
|
@@ -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
|
}
|
|
@@ -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; });
|
|
@@ -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
|
}
|
|
@@ -93,6 +93,7 @@ class ModuleBridge {
|
|
|
93
93
|
this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
|
|
94
94
|
this.registryStore = options.registryStore || null;
|
|
95
95
|
this.projectStore = options.projectStore || null;
|
|
96
|
+
this.projectWiringQueue = new Map();
|
|
96
97
|
this.settingsStore = options.settingsStore || null;
|
|
97
98
|
this.dataDir = options.dataDir || null;
|
|
98
99
|
this.supervised = [];
|
|
@@ -398,7 +399,24 @@ class ModuleBridge {
|
|
|
398
399
|
* (retry on the next register call or health-check pass), never a hard error.
|
|
399
400
|
* KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
|
|
400
401
|
*/
|
|
401
|
-
|
|
402
|
+
_withProjectWiring(projectId, action) {
|
|
403
|
+
const prior = this.projectWiringQueue.get(projectId) || Promise.resolve();
|
|
404
|
+
const operation = prior.catch(() => {}).then(action);
|
|
405
|
+
const settled = operation.finally(() => { if (this.projectWiringQueue.get(projectId) === settled) this.projectWiringQueue.delete(projectId); });
|
|
406
|
+
this.projectWiringQueue.set(projectId, settled);
|
|
407
|
+
return settled;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
registerProjectLinks(input) {
|
|
411
|
+
return this._withProjectWiring(input.projectId, () => {
|
|
412
|
+
// A supervisor may have captured this project before a concurrent unbind.
|
|
413
|
+
// Recheck the spine after acquiring the same lease as the removal path.
|
|
414
|
+
if (this.registryStore && !this.registryStore.readDisplaySnapshot(input.projectId)) return Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { ok: true, status: 'removed', skipped: 'project-not-registered' }]));
|
|
415
|
+
return this._registerProjectLinks(input);
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async _registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
|
|
402
420
|
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
403
421
|
if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
|
|
404
422
|
result.terminal = { ok: false, status: 'disabled' };
|
|
@@ -490,7 +508,17 @@ class ModuleBridge {
|
|
|
490
508
|
* only drops its derived index (source untouched); terminal removes the project
|
|
491
509
|
* with keepSessions=1 so session records survive; the event-bridge console only
|
|
492
510
|
* unregisters the project — journals and sealed commit documents stay on disk. */
|
|
493
|
-
|
|
511
|
+
removeProjectLinks(input) {
|
|
512
|
+
return this._withProjectWiring(input.projectId, async () => {
|
|
513
|
+
const result = await this._removeProjectLinks(input);
|
|
514
|
+
// The caller owns registry/lifecycle changes. Hold the wiring lease until
|
|
515
|
+
// its transaction settles, so delayed reconfiguration cannot resurrect it.
|
|
516
|
+
await input.onRemoved?.(result);
|
|
517
|
+
return result;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async _removeProjectLinks({ projectId, requireEngine = true }) {
|
|
494
522
|
const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
|
|
495
523
|
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
496
524
|
// Stop the writer first, before removing hooks/index registrations.
|
package/_site/lib/server-app.js
CHANGED
|
@@ -1057,11 +1057,13 @@ function createRequestHandler(runtime, options = {}) {
|
|
|
1057
1057
|
throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
|
|
1058
1058
|
}
|
|
1059
1059
|
const body = await readJsonBody(req);
|
|
1060
|
-
|
|
1061
|
-
const result = await runtime.lifecycleService.deleteProject(projectId, {
|
|
1060
|
+
const remove = () => runtime.lifecycleService.deleteProject(projectId, {
|
|
1062
1061
|
deleteKnowledge: body.deleteKnowledge === true,
|
|
1063
1062
|
confirmationToken: body.confirmationToken || '',
|
|
1064
1063
|
});
|
|
1064
|
+
let result;
|
|
1065
|
+
if (runtime.registryStore.readDisplaySnapshot(projectId)) await runtime.moduleBridge.removeProjectLinks({ projectId, requireEngine: runtime.analysisOwner !== 'legacy', onRemoved: async () => { result = await remove(); } });
|
|
1066
|
+
else result = await remove();
|
|
1065
1067
|
return send(res, 200, result);
|
|
1066
1068
|
}
|
|
1067
1069
|
const gitStatusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/git-status$/);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/project-knowledge",
|
|
3
|
-
"version": "5.0.9-rc.
|
|
3
|
+
"version": "5.0.9-rc.15",
|
|
4
4
|
"description": "Knowledge base manager with Git integration, AI-driven analysis, and bilingual (zh-CN/en-US) knowledge output",
|
|
5
5
|
"main": "_site/server.js",
|
|
6
6
|
"scripts": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "project-knowledge",
|
|
4
|
-
"version": "5.0.9-rc.
|
|
4
|
+
"version": "5.0.9-rc.15",
|
|
5
5
|
"description": "Read durable project decisions and implementation history from Project Knowledge.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "SanQianX",
|