@sanqianx/project-knowledge 4.8.0 → 5.0.0
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 +19 -0
- package/README.md +11 -6
- package/_modules/knowledge-engine/README.md +9 -6
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/coordinator.js +11 -3
- package/_modules/knowledge-engine/src/server.js +9 -10
- package/_modules/knowledge-engine/src/store.js +9 -12
- package/_modules/knowledge-engine/ui/app.js +1 -1
- package/_site/_test/fresh-hook-reimport-test.js +53 -0
- package/_site/_test/fresh-product-data-test.js +42 -0
- package/_site/_test/module-model-configuration-test.js +2 -1
- package/_site/_test/tracking-start-test.js +1 -0
- package/_site/lib/data-dir.js +13 -81
- package/_site/lib/engine-connection.js +67 -0
- package/_site/lib/hook-manager.js +1 -1
- package/_site/lib/integration-installer.js +2 -1
- package/_site/lib/module-bridge.js +6 -16
- package/_site/lib/product-data.js +24 -0
- package/_site/lib/project-lifecycle-service.js +18 -2
- package/_site/lib/server-app.js +11 -22
- package/docs/README.zh-CN.md +8 -4
- package/docs/fresh-v5-start.md +28 -0
- package/module-runtime-manifest.json +2 -2
- package/package.json +2 -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/ui/index.html +2 -1
- package/ui/product.js +41 -15
- package/vendor-manifest.json +7 -8
- package/_modules/knowledge-engine/src/migration.js +0 -88
- package/_site/_test/data-dir-migration-test.js +0 -77
- package/_site/_test/knowledge-migration-test.js +0 -75
- package/_site/_test/legacy-forward-compat-test.js +0 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +0 -363
- package/_site/_test/p0-data-migration-characterization-test.js +0 -37
- package/_site/_test/p0-e2e-gate-test.js +0 -347
- package/_site/_test/project-layout-v2-migration-test.js +0 -127
- package/_site/_test/server-runtime-migration-safety-test.js +0 -27
- package/_site/_test/v4122-upgrade-data-contract-test.js +0 -56
- package/_site/lib/data-state-classifier.js +0 -40
- package/_site/lib/engine-cutover.js +0 -97
- package/_site/lib/legacy-data-manifest.js +0 -26
- package/_site/lib/migration-service.js +0 -442
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.0.0] - 2026-10-10
|
|
4
|
+
|
|
5
|
+
- Fresh-data-only web product. Default state moves to `~/.project-knowledge-v5`;
|
|
6
|
+
no previous knowledge, project registry, unfinished claims or model profiles
|
|
7
|
+
are imported. Explicitly selected old data directories are rejected unchanged.
|
|
8
|
+
- Remove shell migration/cutover and engine `/api/migrate`. Engine 0.2.0 uses
|
|
9
|
+
schema version 3; validate its data generation and runtime authentication
|
|
10
|
+
before registering projects or enabling consumption. System status reports
|
|
11
|
+
safe connection stage/error codes instead of silently waiting for takeover.
|
|
12
|
+
- Isolate Workbench, bridge journals and vector configuration under the new
|
|
13
|
+
product directory. No automatic embedding-credential migration.
|
|
14
|
+
- Queued, unstarted tasks are cancelled on unbind; active knowledge/index write
|
|
15
|
+
leases still block removal. The visible confirmation dialog updates project
|
|
16
|
+
selection immediately and rejects stale registry responses without reloading.
|
|
17
|
+
- Reimport can explicitly replace a leftover knowledge-managed Git Hook. Failed
|
|
18
|
+
imports restore the prior Hook; third-party hooks and existing knowledge files
|
|
19
|
+
are never overwritten. Import tracks HEAD without analyzing historical commits.
|
|
20
|
+
|
|
21
|
+
|
|
3
22
|
## [4.8.0] - 2026-10-10
|
|
4
23
|
|
|
5
24
|
- Unified brain web product: six real pages for work, retrieval, memories,
|
package/README.md
CHANGED
|
@@ -32,9 +32,8 @@ project-knowledge
|
|
|
32
32
|
It also registers the host-level `project-knowledge` Bridge consumer.
|
|
33
33
|
No client UI needs to be opened; third-party hooks and configs are
|
|
34
34
|
preserved, and a Codex notify conflict is reported instead of overwritten.
|
|
35
|
-
3. `project-knowledge` starts
|
|
36
|
-
|
|
37
|
-
identity + conversation baseline — no manual migration).
|
|
35
|
+
3. `project-knowledge` starts a fresh v5 local backend. Import projects again;
|
|
36
|
+
older registries, knowledge, sessions and unfinished tasks are not migrated.
|
|
38
37
|
|
|
39
38
|
Notes:
|
|
40
39
|
|
|
@@ -62,7 +61,7 @@ Requires Node.js 22 or newer and Git on `PATH`.
|
|
|
62
61
|
|
|
63
62
|
### Windows desktop
|
|
64
63
|
|
|
65
|
-
Version
|
|
64
|
+
Version 5.0.0 delivers the web product through npm. A new Windows installer
|
|
66
65
|
and desktop auto-update are not part of this release.
|
|
67
66
|
|
|
68
67
|
## Runtime model
|
|
@@ -138,7 +137,13 @@ scopes are nested; unconfirmed memory candidates are not task preferences.
|
|
|
138
137
|
|
|
139
138
|
## Storage
|
|
140
139
|
|
|
141
|
-
|
|
140
|
+
Version 5 is a fresh start, not a data upgrade. Stop the previous process,
|
|
141
|
+
remove any old `KB_DATA_DIR` override, configure models and import projects into
|
|
142
|
+
new empty knowledge directories. Old files remain untouched but are not read.
|
|
143
|
+
See [fresh v5 setup](docs/fresh-v5-start.md). Queued tasks can be cancelled by
|
|
144
|
+
unbinding; only an active knowledge/index writer blocks removal.
|
|
145
|
+
|
|
146
|
+
Default internal data directory: `~/.project-knowledge-v5/`
|
|
142
147
|
|
|
143
148
|
Override it with `KB_DATA_DIR`:
|
|
144
149
|
|
|
@@ -157,7 +162,7 @@ Settings, metadata, indexes, caches, runtime state, logs, and recovery assets
|
|
|
157
162
|
remain in the internal data directory:
|
|
158
163
|
|
|
159
164
|
```text
|
|
160
|
-
~/.project-knowledge/
|
|
165
|
+
~/.project-knowledge-v5/
|
|
161
166
|
├── settings.json
|
|
162
167
|
├── projects.json # minimal project ID/order index
|
|
163
168
|
├── projects/<projectId>/
|
|
@@ -33,7 +33,9 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
33
33
|
|
|
34
34
|
打开 `http://127.0.0.1:5790` 即调试台。默认绑定 loopback:运行历史包含 commit 证据与知识内容,不出本机。
|
|
35
35
|
|
|
36
|
-
首次启动为空,不创建演示项目。`--debug` 才开放手工运行、事件注入和模拟失败。独立运行默认只提供读取与诊断;设置 `KNOWLEDGE_ENGINE_CONSUME=1` 开始消费。外壳守护使用 `KB_ENGINE_MANAGED=1
|
|
36
|
+
首次启动为空,不创建演示项目。`--debug` 才开放手工运行、事件注入和模拟失败。独立运行默认只提供读取与诊断;设置 `KNOWLEDGE_ENGINE_CONSUME=1` 开始消费。外壳守护使用 `KB_ENGINE_MANAGED=1`,确认服务身份、运行期鉴权、项目接线及配置后启用消费与执行;不再导入旧外壳任务。
|
|
37
|
+
|
|
38
|
+
0.2.0 使用全新 schemaVersion 3,独立运行默认目录为 `./data-v3`。旧版状态目录明确拒绝读取,原文件保持不变;不备份后重置、不迁移、不创建 `upgrade-recovery`。同一新版数据目录内的崩溃恢复仍保留。
|
|
37
39
|
|
|
38
40
|
本模块零新增 npm 依赖、CommonJS、Node ≥18;无头 Agent SDK 由宿主已安装的 Workbench/SDK 提供,缺失时明确失败,不退回假分析。完整产品的 Node 门槛为 ≥22。
|
|
39
41
|
|
|
@@ -41,9 +43,9 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
41
43
|
|
|
42
44
|
| 方法 | 路径 | 说明 |
|
|
43
45
|
|---|---|---|
|
|
44
|
-
| GET | `/api/health` | 版本 / uptime / 计数 / journal 游标 |
|
|
46
|
+
| GET | `/api/health` | 版本 / dataSchemaVersion / uptime / 计数 / journal 游标 |
|
|
45
47
|
| GET / POST | `/api/projects` | 项目列表 / 注册(按 projectId 幂等) |
|
|
46
|
-
| DELETE | `/api/projects/:id` |
|
|
48
|
+
| DELETE | `/api/projects/:id` | 摘除登记,取消未开始任务;实际运行或写入租约未释放时返回 409;保留代码、知识和证据 |
|
|
47
49
|
| GET | `/api/runs?projectId=` | 分析运行列表(新→旧) |
|
|
48
50
|
| POST | `/api/runs` | 仅调试模式手工运行;必填完整 `commitSha` |
|
|
49
51
|
| GET | `/api/runs/:id` | 运行详情(含 staging manifest) |
|
|
@@ -57,7 +59,8 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
57
59
|
| GET / PATCH | `/api/memories`、`/api/memories/:id` | 候选和已确认记忆;编辑、确认、忽略持久化 |
|
|
58
60
|
| POST | `/api/scopes` | 外壳投影智脑、领域共享目录;受运行期鉴权保护 |
|
|
59
61
|
| POST | `/api/runtime-config` | 运行期模型配置,仅保留在内存;受鉴权保护 |
|
|
60
|
-
|
|
|
62
|
+
| GET | `/api/runtime-auth` | 本机服务确认运行期鉴权;受鉴权保护,不对浏览器反代 |
|
|
63
|
+
| POST | `/api/control` | 外壳完成接线后启用;受鉴权保护。旧 `/api/migrate` 已删除 |
|
|
61
64
|
|
|
62
65
|
鉴权接口只接受无浏览器 Origin 的本机服务调用,使用 `KB_ENGINE_RUNTIME_TOKEN` 的 Bearer token。外壳不能把这些接口或解析后的凭据转发到浏览器。登记投影只接受白名单元数据,API key 不写入 EngineStore、任务记录或日志。
|
|
63
66
|
|
|
@@ -71,11 +74,11 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
71
74
|
- 晋升与索引分别记录。索引失败保留已晋升知识并显示待修复,不重新调用模型。
|
|
72
75
|
- 项目、领域、智脑检索使用包含范围。向量只提供排序,显示内容必须来自当前真实文件;索引不可用时降级为文件检索。
|
|
73
76
|
- 候选必须有已晋升文件中的引用依据,未确认候选不能进入任务。确认记录原子持久化后写共享记忆文件,派生索引异步更新,索引失败不会撤销有效记忆。
|
|
74
|
-
-
|
|
77
|
+
- 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
|
|
75
78
|
|
|
76
79
|
## 验证与尚未验收项
|
|
77
80
|
|
|
78
|
-
`npm test` 覆盖真实 Git/Hook、真实分项目 journal、真实 vector-hub 文件索引、晋升、重复抑制、FIFO
|
|
81
|
+
`npm test` 覆盖真实 Git/Hook、真实分项目 journal、真实 vector-hub 文件索引、晋升、重复抑制、FIFO/并发、记忆、旧格式拒绝、新版恢复及进程隔离。自动测试中的模型和 embedding 是可控替身。
|
|
79
82
|
|
|
80
83
|
`npm run test:web` 使用兄弟源码仓库启动隔离的五服务网页环境,只有模型与 embedding 是替身。单一 1920×1080 视口验证双目录 UI 导入、真实 Hook 到自动刷新、原生消息流与中止、快速项目切换/草稿/历史、迟到检索及文件响应、领域检索、记忆落盘、四 iframe 身份与主题、唯一进度连接、诊断关闭和保留数据解绑;不会在用户代码仓库提交。该脚本是开发验收工具,不属于引擎生产运行依赖。
|
|
81
84
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-knowledge-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Knowledge engine module for project-knowledge: commit-boundary triggered knowledge analysis/generation (trigger plane) and retrieval for development assistants (retrieval plane).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -97,6 +97,14 @@ class EngineCoordinator extends EventEmitter {
|
|
|
97
97
|
this.ready.then(() => this.pump());
|
|
98
98
|
return run;
|
|
99
99
|
}
|
|
100
|
+
async removeProject(projectId) {
|
|
101
|
+
await this.ready;
|
|
102
|
+
// An indexing job can still hold a write lease even after its state changes.
|
|
103
|
+
if (this.active.has(projectId)) throw Object.assign(new Error('此项目正在执行知识维护,请等待写入结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
|
|
104
|
+
const result = this.store.removeProject(projectId);
|
|
105
|
+
if (result.removed) this.emit('progress', this.store.addEvent({ type: 'project.unregister', projectId, summary: `已解绑项目,取消 ${result.cancelledRuns} 个未开始任务;知识与提交证据保留。` }));
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
100
108
|
drain() {
|
|
101
109
|
if (this.drainJob) return this.drainJob;
|
|
102
110
|
if (this.closed) return Promise.resolve();
|
|
@@ -115,8 +123,8 @@ class EngineCoordinator extends EventEmitter {
|
|
|
115
123
|
let through = cursor;
|
|
116
124
|
for (const event of events) {
|
|
117
125
|
if (this.closed) break;
|
|
118
|
-
const p = this.store.listProjects().find(p => (journal.journalId !== 'global' && p.journalId === journal.journalId)
|
|
119
|
-
|| ((journal.journalId === 'global' || !p.journalId) && event.repoIdentity?.workspaceRoot && this.layout.pathsEqual(p.repoPath, event.repoIdentity.workspaceRoot)));
|
|
126
|
+
const p = this.store.listProjects().find(p => p.enabled !== false && ((journal.journalId !== 'global' && p.journalId === journal.journalId)
|
|
127
|
+
|| ((journal.journalId === 'global' || !p.journalId) && event.repoIdentity?.workspaceRoot && this.layout.pathsEqual(p.repoPath, event.repoIdentity.workspaceRoot))));
|
|
120
128
|
if (p) {
|
|
121
129
|
if (event.schema === 'ai-coding-event/v1' && ['user_prompt', 'assistant_response'].includes(event.eventType)) await this.conversations.appendBridgeEvent(p.projectId, { ...event, journalId: journal.journalId });
|
|
122
130
|
if (event.schema === 'git-commit-boundary/v1' && /^[a-f0-9]{7,64}$/i.test(event.commitSha || '')) {
|
|
@@ -124,7 +132,7 @@ class EngineCoordinator extends EventEmitter {
|
|
|
124
132
|
const baseline = p.runtime.lastAnalyzedCommit || p.trackingStartCommit;
|
|
125
133
|
const completed = this.reconciler.processingLedger.read(p.projectId, event.commitSha);
|
|
126
134
|
const historical = baseline && (baseline === event.commitSha || (await execGit(p.repoPath, ['merge-base', '--is-ancestor', event.commitSha, baseline])).ok);
|
|
127
|
-
if (!completed && !historical) this.enqueue({ projectId: p.projectId, commitSha: event.commitSha, trigger: 'git-hook', journalId: journal.journalId, journalSequence: event.sequence });
|
|
135
|
+
if (!completed && !historical && this.store.getProject(p.projectId)?.enabled) this.enqueue({ projectId: p.projectId, commitSha: event.commitSha, trigger: 'git-hook', journalId: journal.journalId, journalSequence: event.sequence });
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
through = event.sequence;
|
|
@@ -5,7 +5,6 @@ const fs = require('node:fs');
|
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
6
|
const { EngineStore } = require('./store');
|
|
7
7
|
const { EngineCoordinator } = require('./coordinator');
|
|
8
|
-
const { importLegacyState } = require('./migration');
|
|
9
8
|
const AtomicFile = require('./runtime/atomic-file');
|
|
10
9
|
|
|
11
10
|
const VERSION = require('../package.json').version;
|
|
@@ -13,7 +12,7 @@ const UI_DIR = path.resolve(__dirname, '..', 'ui');
|
|
|
13
12
|
const STATIC_FILES = new Map([['/', ['index.html', 'text/html']], ['/index.html', ['index.html', 'text/html']], ['/app.js', ['app.js', 'text/javascript']], ['/app.css', ['app.css', 'text/css']]]);
|
|
14
13
|
|
|
15
14
|
function createEngineServer(options = {}) {
|
|
16
|
-
const dataDir = options.dataDir || path.join(process.cwd(), 'data');
|
|
15
|
+
const dataDir = options.dataDir || path.join(process.cwd(), 'data-v3');
|
|
17
16
|
const store = options.store || new EngineStore(dataDir, { deferLoad: true });
|
|
18
17
|
let releaseLease;
|
|
19
18
|
const coordinator = new EngineCoordinator(store, { ...options, initializeHook: async () => {
|
|
@@ -46,14 +45,13 @@ function createEngineServer(options = {}) {
|
|
|
46
45
|
}
|
|
47
46
|
if (pathname.startsWith('/api/')) {
|
|
48
47
|
const body = await readBody(req);
|
|
49
|
-
if (pathname === '/api/
|
|
50
|
-
if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '
|
|
51
|
-
|
|
52
|
-
send(res, 200, { ok: true, ...await importLegacyState(coordinator, body) }); return;
|
|
48
|
+
if (pathname === '/api/runtime-auth' && req.method === 'GET') {
|
|
49
|
+
if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳检查连接。');
|
|
50
|
+
send(res, 200, { ok: true, dataSchemaVersion: store.state.schemaVersion }); return;
|
|
53
51
|
}
|
|
54
52
|
if (pathname === '/api/control' && req.method === 'POST') {
|
|
55
53
|
if (!trusted(req)) throw httpError(403, 'RUNTIME_AUTH_REQUIRED', '仅允许受信任外壳切换运行所有权。');
|
|
56
|
-
if (body.enabled !== true) throw httpError(400, 'INVALID_ARGUMENT', '
|
|
54
|
+
if (body.enabled !== true) throw httpError(400, 'INVALID_ARGUMENT', '只支持外壳完成接线后的启用操作。');
|
|
57
55
|
await coordinator.activate(); send(res, 200, { ok: true }); return;
|
|
58
56
|
}
|
|
59
57
|
if (pathname === '/api/runtime-config' && req.method === 'POST') {
|
|
@@ -102,7 +100,7 @@ function createEngineServer(options = {}) {
|
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
async function api(method, pathname, search, body, store, coordinator, ctx) {
|
|
105
|
-
if (method === 'GET' && pathname === '/api/health') return { name: 'knowledge-engine', version: VERSION, debug: ctx.debug, executionEnabled: coordinator.executionEnabled, uptimeSec: Math.floor((Date.now() - ctx.startedAt) / 1000), dataDir: ctx.dataDir, counts: { projects: store.state.projects.length, runs: store.state.runs.length, running: coordinator.active.size }, journal: { cursor: store.state.cursor, cursors: store.state.journalCursors, consumer: 'knowledge-engine', enabled: coordinator.consume, error: coordinator.journalError || null }, concurrency: coordinator.concurrency };
|
|
103
|
+
if (method === 'GET' && pathname === '/api/health') return { name: 'knowledge-engine', version: VERSION, dataSchemaVersion: store.state.schemaVersion, debug: ctx.debug, executionEnabled: coordinator.executionEnabled, uptimeSec: Math.floor((Date.now() - ctx.startedAt) / 1000), dataDir: ctx.dataDir, counts: { projects: store.state.projects.length, runs: store.state.runs.length, running: coordinator.active.size }, journal: { cursor: store.state.cursor, cursors: store.state.journalCursors, consumer: 'knowledge-engine', enabled: coordinator.consume, error: coordinator.journalError || null }, concurrency: coordinator.concurrency };
|
|
106
104
|
if (method === 'GET' && pathname === '/api/projects') return { projects: store.listProjects() };
|
|
107
105
|
if (method === 'GET' && pathname === '/api/memories') return { memories: coordinator.memories.list(Object.fromEntries(search)) };
|
|
108
106
|
const memoryMatch = pathname.match(/^\/api\/memories\/([^/]+)$/);
|
|
@@ -118,8 +116,9 @@ async function api(method, pathname, search, body, store, coordinator, ctx) {
|
|
|
118
116
|
}
|
|
119
117
|
const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
|
|
120
118
|
if (method === 'DELETE' && projectMatch) {
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
const result = await coordinator.removeProject(decodeURIComponent(projectMatch[1]));
|
|
120
|
+
if (!result.removed) throw httpError(404, 'NOT_FOUND', '项目未登记。');
|
|
121
|
+
return { ...result, keptData: true };
|
|
123
122
|
}
|
|
124
123
|
if (method === 'GET' && pathname === '/api/runs') return { runs: store.listRuns(search.get('projectId')) };
|
|
125
124
|
const runMatch = pathname.match(/^\/api\/runs\/([^/]+)(\/retry)?$/);
|
|
@@ -14,21 +14,14 @@ class EngineStore {
|
|
|
14
14
|
constructor(dataDir, options = {}) {
|
|
15
15
|
this.dataDir = dataDir;
|
|
16
16
|
this.file = path.join(dataDir, 'engine-state.json');
|
|
17
|
-
this.state = { schemaVersion:
|
|
17
|
+
this.state = { schemaVersion: 3, projects: [], runs: [], events: [], cursor: 0, journalCursors: {}, memories: [], sharedScopes: [] };
|
|
18
18
|
if (!options.deferLoad) this.load();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
load() {
|
|
22
22
|
try {
|
|
23
23
|
const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
24
|
-
if (raw
|
|
25
|
-
const backup = path.join(this.dataDir, `legacy-debug-state-${crypto.randomUUID()}.json`);
|
|
26
|
-
require('./runtime/atomic-file').writeJsonAtomic(backup, raw);
|
|
27
|
-
this.state.legacyStateBackup = backup;
|
|
28
|
-
this.save();
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
if (raw?.schemaVersion !== 2) throw new Error('Unsupported engine state');
|
|
24
|
+
if (raw?.schemaVersion !== 3) throw Object.assign(new Error('不再读取旧版引擎数据,请使用新的空数据目录重新导入项目。'), { code: 'SCHEMA_UNSUPPORTED' });
|
|
32
25
|
if (!raw || !Array.isArray(raw.projects) || !Array.isArray(raw.runs) || !Array.isArray(raw.events) || !Number.isSafeInteger(raw.cursor) || raw.cursor < 0) throw new Error('Invalid engine state');
|
|
33
26
|
if (raw.memories && !Array.isArray(raw.memories) || raw.sharedScopes && !Array.isArray(raw.sharedScopes) || raw.journalCursors && (typeof raw.journalCursors !== 'object' || Array.isArray(raw.journalCursors))) throw new Error('Invalid engine projections');
|
|
34
27
|
const ids = new Set();
|
|
@@ -40,7 +33,7 @@ class EngineStore {
|
|
|
40
33
|
const runIds = new Set();
|
|
41
34
|
for (const run of raw.runs) {
|
|
42
35
|
validateProjectId(run.projectId);
|
|
43
|
-
if (!/^run-[a-f0-9-]{36}$/.test(run.runId || '') || runIds.has(run.runId) || !/^[a-f0-9]{40}$/.test(run.commitSha || '') || !['queued', 'running', 'failed', 'succeeded', 'index-pending'].includes(run.state) || !Number.isSafeInteger(run.sequence) || run.sequence < 1) throw new Error('Invalid queued run');
|
|
36
|
+
if (!/^run-[a-f0-9-]{36}$/.test(run.runId || '') || runIds.has(run.runId) || !/^[a-f0-9]{40}$/.test(run.commitSha || '') || !['queued', 'running', 'failed', 'succeeded', 'index-pending', 'cancelled'].includes(run.state) || !Number.isSafeInteger(run.sequence) || run.sequence < 1) throw new Error('Invalid queued run');
|
|
44
37
|
runIds.add(run.runId);
|
|
45
38
|
}
|
|
46
39
|
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');
|
|
@@ -48,6 +41,7 @@ class EngineStore {
|
|
|
48
41
|
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');
|
|
49
42
|
this.state = { ...this.state, ...raw };
|
|
50
43
|
} catch (error) {
|
|
44
|
+
if (error.code === 'SCHEMA_UNSUPPORTED') throw error;
|
|
51
45
|
if (error.code !== 'ENOENT') throw Object.assign(new Error('引擎状态损坏,已保留原文件;请从备份恢复。'), { code: 'DATA_CORRUPT', cause: error });
|
|
52
46
|
}
|
|
53
47
|
}
|
|
@@ -129,11 +123,14 @@ class EngineStore {
|
|
|
129
123
|
}
|
|
130
124
|
|
|
131
125
|
removeProject(projectId) {
|
|
132
|
-
if (this.state.runs.some(r => r.projectId === projectId &&
|
|
126
|
+
if (this.state.runs.some(r => r.projectId === projectId && r.state === 'running')) throw Object.assign(new Error('此项目正在执行知识维护,请等待写入结束后再解绑。'), { status: 409, code: 'PROJECT_BUSY' });
|
|
133
127
|
const before = this.state.projects.length;
|
|
128
|
+
const now = new Date().toISOString();
|
|
129
|
+
const cancelled = this.state.runs.filter(r => r.projectId === projectId && r.state === 'queued');
|
|
130
|
+
for (const run of cancelled) Object.assign(run, { state: 'cancelled', phase: 'cancelled', finishedAt: now, cancellationReason: 'project-unbound' });
|
|
134
131
|
this.state.projects = this.state.projects.filter(p => p.projectId !== projectId);
|
|
135
132
|
this.save();
|
|
136
|
-
return this.state.projects.length < before;
|
|
133
|
+
return { removed: this.state.projects.length < before, cancelledRuns: cancelled.length };
|
|
137
134
|
}
|
|
138
135
|
|
|
139
136
|
listRuns(projectId) {
|
|
@@ -50,7 +50,7 @@ function fmtTime(iso) {
|
|
|
50
50
|
}
|
|
51
51
|
function fmtDuration(ms) { return ms == null ? '–' : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`; }
|
|
52
52
|
function shortSha(sha) { return sha ? sha.slice(0, 7) : '–'; }
|
|
53
|
-
function stateBadge(state) { return `<span class="badge ${escapeHtml(state)}">${escapeHtml(({ queued: '排队', running: '运行中', succeeded: '可检索', failed: '失败', 'index-pending': '索引待修复' })[state] || state)}</span>`; }
|
|
53
|
+
function stateBadge(state) { return `<span class="badge ${escapeHtml(state)}">${escapeHtml(({ queued: '排队', running: '运行中', succeeded: '可检索', failed: '失败', 'index-pending': '索引待修复', cancelled: '已取消' })[state] || state)}</span>`; }
|
|
54
54
|
function typeBadge(type) { return `<span class="badge type">${escapeHtml(type)}</span>`; }
|
|
55
55
|
function scopedPath(path) { return scopedProjectId ? `${path}${path.includes('?') ? '&' : '?'}projectId=${encodeURIComponent(scopedProjectId)}` : path; }
|
|
56
56
|
function successfulRun(runs) { return runs.find(run => run.promotedAt) || null; }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const assert = require('assert/strict');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { StorageLayout } = require('../lib/storage-layout');
|
|
7
|
+
const { SettingsStore } = require('../lib/settings-store');
|
|
8
|
+
const { ProjectRegistryStore } = require('../lib/project-registry-store');
|
|
9
|
+
const { ProjectStore } = require('../lib/project-store');
|
|
10
|
+
const { ProjectLifecycleService } = require('../lib/project-lifecycle-service');
|
|
11
|
+
const hooks = require('../lib/hook-manager');
|
|
12
|
+
|
|
13
|
+
(async () => {
|
|
14
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-fresh-hook-'));
|
|
15
|
+
const layout = new StorageLayout({ dataDir: path.join(root, 'fresh') });
|
|
16
|
+
const settingsStore = new SettingsStore({ layout });
|
|
17
|
+
const registryStore = new ProjectRegistryStore({ layout });
|
|
18
|
+
const projectStore = new ProjectStore({ layout });
|
|
19
|
+
const triggerScriptPath = path.resolve(__dirname, '../scripts/hook-trigger.js');
|
|
20
|
+
await settingsStore.initialize({ knowledge: { rootPath: path.join(root, 'knowledge') } });
|
|
21
|
+
await registryStore.initialize();
|
|
22
|
+
const options = { layout, settingsStore, registryStore, projectStore, triggerScriptPath };
|
|
23
|
+
const lifecycle = new ProjectLifecycleService(options);
|
|
24
|
+
const repo = path.join(root, '中文 项目'); fs.mkdirSync(repo);
|
|
25
|
+
const git = args => execFileSync('git', args, { cwd: repo, windowsHide: true, encoding: 'utf8' }).trim();
|
|
26
|
+
git(['init', '-q']); git(['config', 'user.name', 'Fresh Test']); git(['config', 'user.email', 'fresh@example.invalid']);
|
|
27
|
+
fs.writeFileSync(path.join(repo, 'user-code.txt'), 'unchanged'); git(['add', '.']); git(['commit', '-qm', 'baseline']);
|
|
28
|
+
const old = hooks.installHook({ repoPath: repo, projectId: 'project-old', triggerScriptPath, dataDir: path.join(root, 'old') });
|
|
29
|
+
const oldBytes = fs.readFileSync(old.hookPath, 'utf8');
|
|
30
|
+
const input = { localPath: repo, projectId: 'project-new', storageName: 'new' };
|
|
31
|
+
const preflight = await lifecycle.preflightImport(input);
|
|
32
|
+
assert.equal(preflight.checks.hook.replacementProjectId, 'project-old');
|
|
33
|
+
for (const confirmation of [undefined, 'project-wrong']) {
|
|
34
|
+
await assert.rejects(lifecycle.importProject({ ...input, replaceManagedHookProjectId: confirmation }), { code: 'HOOK_CONFLICT' });
|
|
35
|
+
assert.equal(fs.readFileSync(old.hookPath, 'utf8'), oldBytes);
|
|
36
|
+
assert.equal(registryStore.listIds().length, 0);
|
|
37
|
+
}
|
|
38
|
+
const brokenRegistry = Object.create(registryStore);
|
|
39
|
+
brokenRegistry.add = async () => { throw new Error('registry write failed'); };
|
|
40
|
+
await assert.rejects(new ProjectLifecycleService({ ...options, registryStore: brokenRegistry }).importProject({ ...input, replaceManagedHookProjectId: 'project-old' }));
|
|
41
|
+
assert.equal(fs.readFileSync(old.hookPath, 'utf8'), oldBytes, 'failed import restores the old managed hook');
|
|
42
|
+
const result = await lifecycle.importProject({ ...input, replaceManagedHookProjectId: 'project-old' });
|
|
43
|
+
assert.equal(hooks.readHookStatus({ repoPath: repo }).projectId, 'project-new');
|
|
44
|
+
assert.equal(result.state.lastAnalyzedCommit, null);
|
|
45
|
+
assert.equal(result.state.trackingStartCommit, git(['rev-parse', 'HEAD']));
|
|
46
|
+
assert.deepEqual(fs.readdirSync(result.config.knowledgePath), []);
|
|
47
|
+
assert.equal(fs.readFileSync(path.join(repo, 'user-code.txt'), 'utf8'), 'unchanged');
|
|
48
|
+
await lifecycle.deleteProject('project-new');
|
|
49
|
+
const thirdParty = '#!/bin/sh\necho third-party\n'; fs.writeFileSync(old.hookPath, thirdParty);
|
|
50
|
+
await assert.rejects(lifecycle.importProject({ ...input, replaceManagedHookProjectId: 'project-old' }), { code: 'HOOK_CONFLICT' });
|
|
51
|
+
assert.equal(fs.readFileSync(old.hookPath, 'utf8'), thirdParty);
|
|
52
|
+
console.log('PASS explicit fresh Hook replacement, rollback, baseline, third-party protection');
|
|
53
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const assert = require('assert/strict');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { initializeProductData, DATA_SCHEMA } = require('../lib/product-data');
|
|
6
|
+
const { resolveDataDirPath } = require('../lib/data-dir');
|
|
7
|
+
const { connectEngine } = require('../lib/engine-connection');
|
|
8
|
+
|
|
9
|
+
(async () => {
|
|
10
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kb-fresh-product-'));
|
|
11
|
+
assert.equal(resolveDataDirPath({ dataDir: '', homeDir: root }), path.join(root, '.project-knowledge-v5'));
|
|
12
|
+
const old = path.join(root, 'old'); fs.mkdirSync(old);
|
|
13
|
+
const bytes = JSON.stringify({ projects: ['old-project'], activeClaim: 'old-claim' });
|
|
14
|
+
fs.writeFileSync(path.join(old, 'projects.json'), bytes);
|
|
15
|
+
assert.throws(() => initializeProductData(old), { code: 'SCHEMA_UNSUPPORTED' });
|
|
16
|
+
assert.equal(fs.readFileSync(path.join(old, 'projects.json'), 'utf8'), bytes);
|
|
17
|
+
const fresh = path.join(root, 'fresh'); initializeProductData(fresh); initializeProductData(fresh);
|
|
18
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(fresh, 'product-data.json'))).schema, DATA_SCHEMA);
|
|
19
|
+
const paths = [];
|
|
20
|
+
let activationStatus = 403;
|
|
21
|
+
const logs = [];
|
|
22
|
+
const runtime = {
|
|
23
|
+
analysisOwner: 'engine', registryStore: { listIds: () => [] }, logger: { warn: async (...args) => logs.push(args) },
|
|
24
|
+
moduleBridge: { engineUrl: 'http://engine.invalid', runtimeToken: 'private-local-token', syncEngineConfiguration: async () => {}, _fetch: async url => {
|
|
25
|
+
paths.push(url);
|
|
26
|
+
if (url.endsWith('/api/health')) return { ok: true, status: 200, json: async () => ({ name: 'knowledge-engine', version: '0.2.0', dataSchemaVersion: 3 }) };
|
|
27
|
+
if (url.endsWith('/api/runtime-auth')) return { ok: true, status: 200 };
|
|
28
|
+
return { ok: activationStatus === 200, status: activationStatus };
|
|
29
|
+
} },
|
|
30
|
+
};
|
|
31
|
+
await connectEngine(runtime);
|
|
32
|
+
assert.equal(runtime.ownership.ready, false);
|
|
33
|
+
assert.equal(runtime.ownership.error.code, 'RUNTIME_AUTH_REQUIRED');
|
|
34
|
+
assert.equal(runtime.ownership.error.step, 'engine-activation');
|
|
35
|
+
assert.ok(!JSON.stringify(logs).includes('private-local-token'));
|
|
36
|
+
assert.ok(paths.every(url => !url.includes('migrate')));
|
|
37
|
+
await connectEngine(runtime);
|
|
38
|
+
assert.equal(logs.length, 1, 'identical connection failures are not repeatedly logged');
|
|
39
|
+
activationStatus = 200; await connectEngine(runtime);
|
|
40
|
+
assert.deepEqual(runtime.ownership, { owner: 'engine', ready: true });
|
|
41
|
+
console.log('PASS fresh data isolation, legacy rejection, precise connection failure, recovery without migration');
|
|
42
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -33,7 +33,8 @@ const { ModuleBridge } = require('../lib/module-bridge');
|
|
|
33
33
|
assert.equal(status.model, true); assert.equal(status.embedding, true);
|
|
34
34
|
assert.equal(appliedModel.mainModel, 'durable-default'); assert.equal(appliedVector.model, 'embedding-model');
|
|
35
35
|
assert.ok(!JSON.stringify(status).includes('key'));
|
|
36
|
-
assert.ok(hits.
|
|
36
|
+
assert.ok(!hits.some(route => /import-embedding|retire-embedding/.test(route)), 'fresh product must not import old credentials');
|
|
37
|
+
assert.ok(!hits.includes('/api/internal/embedding-config'));
|
|
37
38
|
profile = null; settings = null;
|
|
38
39
|
const revoked = await bridge.syncEngineConfiguration();
|
|
39
40
|
assert.equal(revoked.model, false); assert.equal(revoked.embedding, false);
|
|
@@ -51,6 +51,7 @@ function commit(target, name) {
|
|
|
51
51
|
settingsStore: { read: () => ({ knowledge: { rootPath: knowledgeRoot }, ai: { schema: 'ai-profiles/v1', profiles: [{ id: 'test-profile', enabled: true, vendor: 'anthropic', model: 'm' }], defaultProfileId: null } }) },
|
|
52
52
|
triggerScriptPath: path.join(temp, 'hook-trigger.js'),
|
|
53
53
|
hookManager: {
|
|
54
|
+
readHookStatus: () => ({ kbManaged: false }),
|
|
54
55
|
installHook: options => { installed.push(options.projectId); return { ok: true, managedVersion: 2 }; },
|
|
55
56
|
uninstallHook: () => ({ ok: true }),
|
|
56
57
|
},
|
package/_site/lib/data-dir.js
CHANGED
|
@@ -1,81 +1,13 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
const os = require('os');
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
function ensureDataDir(dataDir = resolveDataDirPath()) { fs.mkdirSync(dataDir, { recursive: true }); return path.resolve(dataDir); }
|
|
16
|
-
function getDataDir() { if (!_resolved) _resolved = resolveDataDirPath(); return _resolved; }
|
|
17
|
-
function _resetCache() { _resolved = null; }
|
|
18
|
-
function fileDigest(filePath) { return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); }
|
|
19
|
-
function directoryDigest(root) {
|
|
20
|
-
const hash = crypto.createHash('sha256');
|
|
21
|
-
const walk = current => {
|
|
22
|
-
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
23
|
-
const filePath = path.join(current, entry.name);
|
|
24
|
-
const relative = path.relative(root, filePath).replace(/\\/g, '/');
|
|
25
|
-
hash.update(`${entry.isDirectory() ? 'd' : 'f'}:${relative}\0`);
|
|
26
|
-
if (entry.isDirectory()) walk(filePath); else if (entry.isFile()) hash.update(fs.readFileSync(filePath));
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
walk(root); return hash.digest('hex');
|
|
30
|
-
}
|
|
31
|
-
function isEmptyAsset(filePath, asset) {
|
|
32
|
-
if (!fs.existsSync(filePath)) return true;
|
|
33
|
-
if (asset.kind === 'dir') return fs.readdirSync(filePath).length === 0;
|
|
34
|
-
if (fs.statSync(filePath).size === 0) return true;
|
|
35
|
-
if (!filePath.endsWith('.json')) return false;
|
|
36
|
-
try { const raw = fs.readFileSync(filePath, 'utf8'); return isEmptyJson(JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw)); } catch { return false; }
|
|
37
|
-
}
|
|
38
|
-
function assetsEqual(source, target, asset) { return asset.kind === 'dir' ? directoryDigest(source) === directoryDigest(target) : fileDigest(source) === fileDigest(target); }
|
|
39
|
-
function copyDir(source, target) {
|
|
40
|
-
fs.mkdirSync(target, { recursive: true });
|
|
41
|
-
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
42
|
-
const from = path.join(source, entry.name); const to = path.join(target, entry.name);
|
|
43
|
-
if (entry.isDirectory()) copyDir(from, to); else if (entry.isFile()) fs.copyFileSync(from, to);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function migrateFromLegacy({ legacyRoot, logger } = {}) {
|
|
48
|
-
if (process.env.KB_SKIP_MIGRATION === '1') return { ok: true, migrated: false, reason: 'skipped via KB_SKIP_MIGRATION=1' };
|
|
49
|
-
if (!legacyRoot || !fs.existsSync(legacyRoot)) return { ok: true, migrated: false, reason: legacyRoot ? 'legacy root does not exist' : 'no legacy root provided' };
|
|
50
|
-
const dataDir = getDataDir();
|
|
51
|
-
if (path.resolve(legacyRoot) === dataDir) return { ok: true, migrated: false, reason: 'legacy root equals data dir' };
|
|
52
|
-
// Packaged application roots may include empty scaffolding such as
|
|
53
|
-
// `projects/`. It is not user data and must never conflict with populated
|
|
54
|
-
// runtime data during startup relocation.
|
|
55
|
-
const sources = LEGACY_ASSETS.filter(asset => {
|
|
56
|
-
const source = assetPath(legacyRoot, asset);
|
|
57
|
-
return fs.existsSync(source) && !isEmptyAsset(source, asset);
|
|
58
|
-
});
|
|
59
|
-
if (!sources.length) return { ok: true, migrated: false, reason: 'no legacy assets found' };
|
|
60
|
-
const conflicts = [];
|
|
61
|
-
for (const asset of sources) {
|
|
62
|
-
const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
|
|
63
|
-
if (fs.existsSync(target) && !isEmptyAsset(target, asset) && !assetsEqual(source, target, asset)) conflicts.push(asset.target);
|
|
64
|
-
}
|
|
65
|
-
if (conflicts.length) return { ok: false, migrated: false, requiresManualRecovery: true, reason: 'legacy-data-conflict', conflicts, source: legacyRoot, target: dataDir };
|
|
66
|
-
ensureDataDir(dataDir);
|
|
67
|
-
const result = { ok: true, migrated: false, files: 0, dirs: 0, source: legacyRoot, target: dataDir };
|
|
68
|
-
try {
|
|
69
|
-
for (const asset of sources) {
|
|
70
|
-
const source = assetPath(legacyRoot, asset); const target = assetPath(dataDir, asset, 'target');
|
|
71
|
-
if (fs.existsSync(target) && assetsEqual(source, target, asset)) continue;
|
|
72
|
-
if (asset.kind === 'dir') { if (fs.existsSync(target)) fs.rmSync(target, { recursive: true, force: true }); copyDir(source, target); result.dirs++; }
|
|
73
|
-
else { fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(source, target); result.files++; }
|
|
74
|
-
}
|
|
75
|
-
result.migrated = result.files > 0 || result.dirs > 0;
|
|
76
|
-
if (result.migrated && logger) logger(`migrated runtime data to ${dataDir} (${result.files} files, ${result.dirs} dirs from ${legacyRoot})`);
|
|
77
|
-
return result;
|
|
78
|
-
} catch (error) { return { ...result, ok: false, error: `failed to relocate legacy runtime data: ${error.message}` }; }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, migrateFromLegacy, LEGACY_ASSETS, _resetCache };
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
let resolved = null;
|
|
6
|
+
function resolveDataDirPath(options = {}) {
|
|
7
|
+
const configured = options.dataDir === undefined ? process.env.KB_DATA_DIR : options.dataDir;
|
|
8
|
+
return path.resolve(configured || path.join(options.homeDir || os.homedir(), '.project-knowledge-v5'));
|
|
9
|
+
}
|
|
10
|
+
function ensureDataDir(dataDir = resolveDataDirPath()) { fs.mkdirSync(dataDir, { recursive: true }); return path.resolve(dataDir); }
|
|
11
|
+
function getDataDir() { if (!resolved) resolved = resolveDataDirPath(); return resolved; }
|
|
12
|
+
function _resetCache() { resolved = null; }
|
|
13
|
+
module.exports = { resolveDataDirPath, ensureDataDir, getDataDir, _resetCache };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const { DomainError } = require('./contracts');
|
|
2
|
+
const { execGit } = require('./git-runner');
|
|
3
|
+
|
|
4
|
+
async function connectEngine(runtime) {
|
|
5
|
+
if (runtime.analysisOwner === 'legacy' || runtime.engineConnecting) return;
|
|
6
|
+
runtime.engineConnecting = true;
|
|
7
|
+
let step = 'engine-health';
|
|
8
|
+
let projectId = '';
|
|
9
|
+
let httpStatus = 0;
|
|
10
|
+
try {
|
|
11
|
+
const bridge = runtime.moduleBridge;
|
|
12
|
+
if (bridge.engineCompatible === undefined) bridge.engineCompatible = false;
|
|
13
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${bridge.runtimeToken}` };
|
|
14
|
+
const health = await bridge._fetch(`${bridge.engineUrl}/api/health`);
|
|
15
|
+
httpStatus = health.status;
|
|
16
|
+
if (!health.ok) throw new Error('ENGINE_UNAVAILABLE');
|
|
17
|
+
const engine = await health.json();
|
|
18
|
+
if (engine.name !== 'knowledge-engine' || engine.dataSchemaVersion !== 3) throw new Error('ENGINE_VERSION_MISMATCH');
|
|
19
|
+
step = 'runtime-authentication';
|
|
20
|
+
const auth = await bridge._fetch(`${bridge.engineUrl}/api/runtime-auth`, { headers });
|
|
21
|
+
httpStatus = auth.status;
|
|
22
|
+
if (!auth.ok) throw new Error('RUNTIME_AUTH_REQUIRED');
|
|
23
|
+
bridge.engineCompatible = true;
|
|
24
|
+
step = 'project-wiring';
|
|
25
|
+
for (const id of runtime.registryStore.listIds()) {
|
|
26
|
+
projectId = id;
|
|
27
|
+
const config = runtime.projectStore.readConfig(id);
|
|
28
|
+
const modules = await bridge.registerProjectLinks({ projectId: id, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
|
|
29
|
+
if (!modules.engine.ok || !modules.eventBridge.ok) throw new Error('PROJECT_WIRING_PENDING');
|
|
30
|
+
}
|
|
31
|
+
projectId = '';
|
|
32
|
+
step = 'runtime-configuration';
|
|
33
|
+
await bridge.syncEngineConfiguration();
|
|
34
|
+
step = 'engine-activation';
|
|
35
|
+
const activated = await bridge._fetch(`${bridge.engineUrl}/api/control`, { method: 'POST', headers, body: JSON.stringify({ enabled: true }) });
|
|
36
|
+
httpStatus = activated.status;
|
|
37
|
+
if (!activated.ok) throw new Error(activated.status === 403 ? 'RUNTIME_AUTH_REQUIRED' : 'ENGINE_ACTIVATION_FAILED');
|
|
38
|
+
runtime.ownership = { owner: 'engine', ready: true };
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const codes = ['ENGINE_UNAVAILABLE', 'ENGINE_VERSION_MISMATCH', 'PROJECT_WIRING_PENDING', 'RUNTIME_AUTH_REQUIRED', 'ENGINE_ACTIVATION_FAILED'];
|
|
41
|
+
const code = codes.includes(error.message) ? error.message : 'ENGINE_CONNECTION_FAILED';
|
|
42
|
+
if (['ENGINE_VERSION_MISMATCH', 'RUNTIME_AUTH_REQUIRED'].includes(code)) runtime.moduleBridge.engineCompatible = false;
|
|
43
|
+
const messages = {
|
|
44
|
+
ENGINE_VERSION_MISMATCH: '检测到旧版知识引擎,请停止旧版服务后重新启动新版。',
|
|
45
|
+
RUNTIME_AUTH_REQUIRED: '引擎服务与外壳鉴权不一致,请停止旧版服务后重新启动新版。',
|
|
46
|
+
PROJECT_WIRING_PENDING: '项目接线未完成,请检查模块连接。',
|
|
47
|
+
};
|
|
48
|
+
const message = messages[code] || '知识引擎连接未完成,服务恢复后自动重试。';
|
|
49
|
+
const previous = runtime.ownership?.error;
|
|
50
|
+
runtime.ownership = { owner: 'engine', ready: false, message, error: { code, step, httpStatus, ...(projectId ? { projectId } : {}) } };
|
|
51
|
+
if (previous?.code !== code || previous?.step !== step || (previous?.projectId || '') !== projectId) await runtime.logger.warn('engine.connection_pending', message, { component: 'engine-connection', phase: step, projectId, context: { code, httpStatus } });
|
|
52
|
+
} finally { runtime.engineConnecting = false; }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function forwardCommitToBridge(runtime, event) {
|
|
56
|
+
const config = runtime.projectStore.readConfig(event.projectId);
|
|
57
|
+
if (!runtime.layout.pathsEqual(config.repoPath, event.repoRoot) || !/^[a-f0-9]{40}$/i.test(event.head || '')) throw new DomainError('INVALID_ARGUMENT', '提交通知与项目绑定不匹配。');
|
|
58
|
+
const exists = await execGit(config.repoPath, ['cat-file', '-e', `${event.head}^{commit}`]);
|
|
59
|
+
if (!exists.ok) throw new DomainError('INVALID_ARGUMENT', '提交不属于当前仓库。');
|
|
60
|
+
const identity = await runtime.bridgeAdapter.resolveRepoIdentity(config.repoPath);
|
|
61
|
+
const result = await runtime.bridgeAdapter.appendCommitBoundary({ projectId: event.projectId, repoIdentity: identity.repoIdentity, commitSha: event.head, branch: event.branch || null, operationId: event.operationId });
|
|
62
|
+
if (result.status !== 'captured') throw new DomainError('MODULE_UNREACHABLE', '事件记录失败,请恢复事件模块后重试。', { status: 503 });
|
|
63
|
+
runtime.moduleBridge._fetch(`${runtime.moduleBridge.engineUrl}/api/bridge/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).catch(() => {});
|
|
64
|
+
return { journalSequence: result.boundary?.journalSequence || result.boundary?.sequence };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { connectEngine, forwardCommitToBridge };
|
|
@@ -111,7 +111,7 @@ function installHook(options = {}) {
|
|
|
111
111
|
// refusing — otherwise import promises a migration the installer
|
|
112
112
|
// rejects for every repo hooked by an older KB generation.
|
|
113
113
|
}
|
|
114
|
-
if (marker && marker.projectId !== projectId) {
|
|
114
|
+
if (marker && marker.projectId !== projectId && options.replaceManagedHookProjectId !== marker.projectId) {
|
|
115
115
|
throw new DomainError('HOOK_CONFLICT', 'The managed hook belongs to another project.', { status: 409 });
|
|
116
116
|
}
|
|
117
117
|
if (existing === body) return { ok: true, installed: true, updated: false, hookPath, projectId, managedVersion: MANAGED_VERSION };
|
|
@@ -6,6 +6,7 @@ const { spawnSync } = require('child_process');
|
|
|
6
6
|
const crossSpawn = require('cross-spawn');
|
|
7
7
|
const { applyEdits, modify, parse } = require('jsonc-parser');
|
|
8
8
|
const packageInfo = require('../../package.json');
|
|
9
|
+
const { resolveDataDirPath } = require('./data-dir');
|
|
9
10
|
|
|
10
11
|
// Fail-open module resolution: a missing/unloadable Bridge package must
|
|
11
12
|
// degrade Development Capture status, never crash server startup.
|
|
@@ -327,7 +328,7 @@ class IntegrationManager {
|
|
|
327
328
|
this.bridgeConsumerName = options.bridgeConsumerName || DEFAULT_BRIDGE_CONSUMER;
|
|
328
329
|
this.bridgeHomeDir = options.bridgeHomeDir
|
|
329
330
|
|| this.env.AI_CODING_EVENT_BRIDGE_HOME
|
|
330
|
-
|| path.join(this.homeDir, '
|
|
331
|
+
|| path.join(resolveDataDirPath({ dataDir: this.env.KB_DATA_DIR || '', homeDir: this.homeDir }), 'events');
|
|
331
332
|
this.claudeSettingsFile = options.claudeSettingsFile || '';
|
|
332
333
|
this.codexConfigFile = options.codexConfigFile || '';
|
|
333
334
|
this.openCodePluginsDir = options.openCodePluginsDir || '';
|