@sanqianx/project-knowledge 5.0.9-rc.8 → 5.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +140 -0
- package/README.md +11 -4
- package/_modules/knowledge-engine/README.md +7 -0
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/coordinator.js +1 -1
- package/_modules/knowledge-engine/src/headless-analyzer.js +4 -1
- package/_modules/knowledge-engine/src/knowledge-targets.js +46 -0
- package/_modules/knowledge-engine/src/retrieval.js +2 -1
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +6 -2
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +7 -2
- package/_modules/knowledge-engine/src/store.js +4 -6
- package/_site/_test/bin-cli-test.js +15 -2
- package/_site/_test/mcp-server-test.js +1 -1
- package/_site/_test/module-agent-start-proxy-test.js +65 -0
- package/_site/_test/module-bridge-eventbridge-test.js +1 -1
- package/_site/_test/module-wiring-unbind-race-test.js +59 -0
- package/_site/_test/path-consistency-test.js +1 -1
- package/_site/_test/product-diagnostics-ui-test.js +5 -1
- package/_site/_test/product-progress-status-test.js +70 -0
- package/_site/_test/shared-scope-root-binding-test.js +67 -0
- package/_site/_test/test-server-cleanup-test.js +5 -1
- package/_site/_test/vector-storage-root-test.js +16 -0
- package/_site/lib/engine-connection.js +4 -1
- package/_site/lib/knowledge-tool-runtime.js +1 -1
- package/_site/lib/module-bridge.js +65 -8
- package/_site/lib/server-app.js +4 -2
- package/_site/lib/settings-store.js +20 -0
- package/module-runtime-manifest.json +40 -40
- package/package.json +3 -3
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/ui/product.css +1 -0
- package/ui/product.js +4 -2
- package/vendor-manifest.json +10 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,145 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [5.0.9] - 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Freeze the accepted rc.19 web runtime and exact module versions; no new
|
|
6
|
+
product features, old-version migration or existing v5 data reset.
|
|
7
|
+
- Include index failure diagnostics, index-only recovery, knowledge-root
|
|
8
|
+
index storage, safe client discovery/notify chaining, maintenance isolation
|
|
9
|
+
and the embedded Workbench startup/streaming fixes from the candidate series.
|
|
10
|
+
- Publish initially to next. Official npm installation, Node 22/24 regression,
|
|
11
|
+
real MiniMax knowledge/embedding loops and existing-user upgrade acceptance
|
|
12
|
+
must pass before stable latest promotion.
|
|
13
|
+
|
|
14
|
+
## [5.0.9-rc.19] - 2026-10-12
|
|
15
|
+
|
|
16
|
+
- Pin Workbench 0.13.2-rc.7. Startup shows a loading state and keeps controls
|
|
17
|
+
inert until projects and event handlers are ready; the existing streaming
|
|
18
|
+
and session recovery paths are unchanged.
|
|
19
|
+
- Preserve the real rc.18 premature-click failure and require its actual
|
|
20
|
+
usable-page ready receipt in browser acceptance. Add a deterministic
|
|
21
|
+
startup gate regression and Node 22/24 retests.
|
|
22
|
+
- Include Windows test-server cleanup hardening and corrected storage-root
|
|
23
|
+
assertions. Retain existing v5 data, third-party configuration and the
|
|
24
|
+
official-package acceptance gate before stable latest promotion.
|
|
25
|
+
|
|
26
|
+
## [5.0.9-rc.18] - 2026-10-12
|
|
27
|
+
|
|
28
|
+
- Project resolution for MCP and CLI now reports the vector-hub storage root
|
|
29
|
+
selected in settings, not the retired shell database path. A running tool
|
|
30
|
+
rereads root changes; resolution does not move files or rebuild indexes.
|
|
31
|
+
- Preserve the reproduced rc.17 diagnostic mismatch and add Node 22/24
|
|
32
|
+
coverage for configured, changed and unset roots and MCP output.
|
|
33
|
+
- Retain existing v5 data and all accepted module pins. Publish to next;
|
|
34
|
+
stable latest remains behind official-package and actual-user acceptance.
|
|
35
|
+
|
|
36
|
+
## [5.0.9-rc.17] - 2026-10-11
|
|
37
|
+
|
|
38
|
+
- Pin Workbench 0.13.2-rc.6. Repeated embedded notifications for the current
|
|
39
|
+
project no longer abandon a newly created session; real project changes and
|
|
40
|
+
late project lookups still preserve isolation and per-project drafts.
|
|
41
|
+
- Keep a healthy progress stream labelled as connected on redundant online
|
|
42
|
+
events. Verify actual offline cleanup, snapshot recovery and exactly one
|
|
43
|
+
restored SSE connection with a real browser and HTTP proxy.
|
|
44
|
+
- Preserve the rc.16 real Workbench failure and require official-package
|
|
45
|
+
streaming/citation acceptance before upgrading the actual user instance.
|
|
46
|
+
- No data reset or third-party configuration overwrite. Stable latest remains
|
|
47
|
+
behind the full candidate and installed-product acceptance gates.
|
|
48
|
+
|
|
49
|
+
## [5.0.9-rc.16] - 2026-10-11
|
|
50
|
+
|
|
51
|
+
- Pin Workbench 0.13.2-rc.5. Bound OpenCode's health request at both
|
|
52
|
+
response headers and JSON body so a stalled local server cannot exceed
|
|
53
|
+
the startup handshake budget before the existing CLI fallback.
|
|
54
|
+
- Reproduce the old behavior using two live HTTP connections, retain the
|
|
55
|
+
failed rc.15 client acceptance and require a new official-package retest.
|
|
56
|
+
- No project, knowledge, history, provider or third-party hook reset.
|
|
57
|
+
Candidate is published to next; stable latest still requires acceptance.
|
|
58
|
+
|
|
59
|
+
## [5.0.9-rc.15] - 2026-10-11
|
|
60
|
+
|
|
61
|
+
- Serialize registration, module removal and registry deletion for each project.
|
|
62
|
+
Recheck membership inside the wiring lease so delayed supervisor registration
|
|
63
|
+
cannot resurrect a project after keep-data unbind. Unrelated projects remain
|
|
64
|
+
independent; failed deletion can still be repaired and retried.
|
|
65
|
+
- Reproduce the race with a delayed real HTTP registration and preserve the
|
|
66
|
+
downloaded rc.14 Node 22 UI failure. Include the regression in the package.
|
|
67
|
+
- Use non-binding CLI readiness probes in tests; the old bind probe could take
|
|
68
|
+
the exact port while its own foreground child was starting.
|
|
69
|
+
- Real MiniMax acceptance covers overlapping analyses, another commit and a
|
|
70
|
+
dirty developer draft during analysis, streaming, secondary development,
|
|
71
|
+
four installed clients and evidenced preference candidates. New downloaded
|
|
72
|
+
candidate acceptance and actual-user cutover still precede stable latest.
|
|
73
|
+
|
|
74
|
+
## [5.0.9-rc.14] - 2026-10-11
|
|
75
|
+
|
|
76
|
+
- Vendor engine 0.2.3-rc.7: isolated maintenance consumes frozen evidence even
|
|
77
|
+
while the user edits or commits in the source repository. Do not mistake live
|
|
78
|
+
development for analyzer writes; retain live-source protection for custom
|
|
79
|
+
analyzers without the isolation capability.
|
|
80
|
+
- Add a paused child-worker regression covering another commit, uncommitted
|
|
81
|
+
drafts, frozen attribution, same-project FIFO and unchanged path/tool guards.
|
|
82
|
+
- Existing-user npm acceptance verified real MiniMax streaming, retained v5
|
|
83
|
+
history/configuration, D-root indexes and index-only e9f6560 recovery. Preserve
|
|
84
|
+
the actual rc.13 PROJECT_BUSY failure and require a real mid-analysis commit
|
|
85
|
+
retest from downloaded rc.14 before stable promotion.
|
|
86
|
+
|
|
87
|
+
## [5.0.9-rc.13] - 2026-10-11
|
|
88
|
+
|
|
89
|
+
- Give native Agent session creation and input setup a bounded 90-second proxy
|
|
90
|
+
budget. Ordinary API reads/abort/permissions retain 10 seconds; SSE streaming
|
|
91
|
+
and folder-picker contracts are unchanged.
|
|
92
|
+
- Record safe proxy failure categories and deadlines without raw errors or URL
|
|
93
|
+
queries. Preserve MODULE_UNREACHABLE compatibility and add optional details.
|
|
94
|
+
- Reproduce the installed OpenCode cold-start failure with a real delayed HTTP
|
|
95
|
+
server; verify the targeted budget, unrelated routes and credential redaction.
|
|
96
|
+
- Retain rc.12 real acceptance and failed OpenCode results separately; require
|
|
97
|
+
a newly downloaded candidate and cold-client retest before user upgrade.
|
|
98
|
+
|
|
99
|
+
## [5.0.9-rc.12] - 2026-10-11
|
|
100
|
+
|
|
101
|
+
- Vendor engine 0.2.3-rc.6 to address the Windows transient state-file rename
|
|
102
|
+
failure exposed by installed rc.11 fault acceptance. Use exclusive random
|
|
103
|
+
temporary files and bounded sharing retries, retain durable state on failure,
|
|
104
|
+
and never unlink live state to force replacement.
|
|
105
|
+
- Add deterministic sharing-violation, permanent-lock, foreign-temp and
|
|
106
|
+
disk-full regressions; all 39 engine tests pass on Node 22 and Node 24.
|
|
107
|
+
- Preserve the rc.11 failure and require new installed acceptance before any
|
|
108
|
+
existing-user upgrade or stable latest promotion.
|
|
109
|
+
|
|
110
|
+
## [5.0.9-rc.11] - 2026-10-11
|
|
111
|
+
|
|
112
|
+
- Pin vector-hub 1.0.8-rc.3 and vendor engine 0.2.3-rc.5. Raw commit
|
|
113
|
+
conversations and internal backups are analysis evidence, not knowledge.
|
|
114
|
+
Exclude them from managed indexes, search and get; retain source evidence.
|
|
115
|
+
- Prune stale derived records during sync and rebuild once on source-policy
|
|
116
|
+
upgrade, without repeating paid whole-index work on ordinary restarts.
|
|
117
|
+
- Keep standalone vector-hub general document support unchanged.
|
|
118
|
+
- Record both original failing regressions and successful Node 22/24 reruns;
|
|
119
|
+
installed, real model and existing-v5 gates still precede latest promotion.
|
|
120
|
+
|
|
121
|
+
## [5.0.9-rc.10] - 2026-10-11
|
|
122
|
+
|
|
123
|
+
- Preserve acknowledged brain/domain knowledge-directory bindings when changing
|
|
124
|
+
the knowledge root. Only derived indexes and future imports/shared scopes use
|
|
125
|
+
the new root; existing knowledge and confirmed memories stay in place.
|
|
126
|
+
- Persist bindings in shell metadata, reject implicit rebinding, and report
|
|
127
|
+
shared-scope connection failures in UI and safe structured logs.
|
|
128
|
+
- Add real-engine regression for confirmed memory, domain retrieval, restart,
|
|
129
|
+
new domain registration, offline retry and protected bindings on Node 22/24.
|
|
130
|
+
- Candidate acceptance still precedes the existing-v5 upgrade and stable latest.
|
|
131
|
+
|
|
132
|
+
## [5.0.9-rc.9] - 2026-10-11
|
|
133
|
+
|
|
134
|
+
- Vendor engine 0.2.3-rc.4. Freeze existing target paths and hashes before
|
|
135
|
+
analysis so missing retrieval snippets cannot be mistaken for absent files.
|
|
136
|
+
Preserve full old documents when only partial context is available; illegal
|
|
137
|
+
create operations still fail safely instead of silently overwriting data.
|
|
138
|
+
- Keep the failed rc.8 secondary-development maintenance evidence and verify
|
|
139
|
+
that exact commit after downloading this candidate, not patched source bytes.
|
|
140
|
+
- Render long index-directory paths as a full-width block in System Status.
|
|
141
|
+
- latest remains unchanged until the real closed-loop and existing-v5 gates pass.
|
|
142
|
+
|
|
3
143
|
## [5.0.9-rc.8] - 2026-10-11
|
|
4
144
|
|
|
5
145
|
- Pin Workbench 0.13.2-rc.4: atomically replace all native Agent histories,
|
package/README.md
CHANGED
|
@@ -194,13 +194,18 @@ project-knowledge
|
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
The user selects a global knowledge root before importing projects. That root
|
|
197
|
-
|
|
197
|
+
chooses directories for future imports, new shared scopes, and derived vector
|
|
198
|
+
indexes (`<knowledge root>/.indexes`). Each imported project stores an
|
|
198
199
|
absolute, fixed `knowledgePath`; changing the global root does not move existing
|
|
199
200
|
projects.
|
|
200
201
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
Existing project and shared-knowledge directories do not move when the root
|
|
203
|
+
changes. Brain/domain directory bindings are retained in the shell settings
|
|
204
|
+
after the engine acknowledges them. Existing engine bindings are reused when
|
|
205
|
+
first recording those fields. A failed registration is shown as pending, not
|
|
206
|
+
healthy. Old index directories are retained; indexes rebuild at the new root.
|
|
207
|
+
Settings, registration metadata, caches, runtime state, logs, and recovery
|
|
208
|
+
assets remain in the internal data directory; vector payloads do not:
|
|
204
209
|
|
|
205
210
|
```text
|
|
206
211
|
~/.project-knowledge-v5/
|
|
@@ -220,6 +225,8 @@ remain in the internal data directory:
|
|
|
220
225
|
└── recovery/ # migrations, promotion/index backups
|
|
221
226
|
|
|
222
227
|
<selected knowledge root>/
|
|
228
|
+
├── .indexes/ # rebuildable vector payloads
|
|
229
|
+
├── _shared/<brain or domain ID>/ # fixed shared-knowledge bindings
|
|
223
230
|
└── <project storage name>/
|
|
224
231
|
├── README.md # when produced by verified knowledge
|
|
225
232
|
├── GOAL.md
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# knowledge-engine
|
|
2
2
|
|
|
3
|
+
正式知识检索(包括 `get`)排除 `dev-conversations`、内部会话、备份与隐藏目录。提交会话只作为分析证据,不直接充当已晋升知识;原始证据仍由事件模块保留。
|
|
4
|
+
|
|
3
5
|
> 开发前读 [AGENTS.md](AGENTS.md)。该指南中的脚手架状态是历史背景;当前运行契约及实现状态以本 README 为准。
|
|
4
6
|
|
|
5
7
|
`project-knowledge` 模块家族的第四块:**知识引擎**。owns 两个职责面,shell 只负责连接与投影("The shell owns no features"):
|
|
@@ -39,6 +41,10 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
39
41
|
|
|
40
42
|
本模块零新增 npm 依赖、CommonJS、Node ≥18;无头 Agent SDK 由宿主已安装的 Workbench/SDK 提供,缺失时明确失败,不退回假分析。完整产品的 Node 门槛为 ≥22。
|
|
41
43
|
|
|
44
|
+
隔离无头分析只读冻结证据、只写 staging,不以开发工作树的前后变化判定失败。用户可以在分析中继续编辑或提交;当前任务仍归属冻结提交,后续提交按项目 FIFO 执行。未声明隔离冻结证据能力的自定义分析器仍保留源码变更检查;目录越界、工具权限和晋升校验不变。
|
|
45
|
+
|
|
46
|
+
每次真实分析在只读证据区冻结已有知识的目标路径与哈希清单,覆盖未被检索选中的文件;重试复用该清单。Agent 不得把“未检索到”当作“文件不存在”,也不得用部分片段覆盖完整旧文档;无法保留原文时只追加当前提交的变更记录。已有目标仍由晋升校验拒绝非法 create,不自动改为覆盖。清单仅保存元数据,不把整个知识树复制到每次运行中。
|
|
47
|
+
|
|
42
48
|
## API
|
|
43
49
|
|
|
44
50
|
| 方法 | 路径 | 说明 |
|
|
@@ -80,6 +86,7 @@ node src/bin.js serve [--port 5790] [--host 127.0.0.1] [--data DIR]
|
|
|
80
86
|
- 已晋升提交的 SHA 或短 SHA 在自然语言查询中仍作为标识符匹配,只返回当前文件的晋升依据;仅在原始会话中提到相同 SHA 不构成知识晋升引用。多个提交标识符可以一起查询。
|
|
81
87
|
- 候选必须有已晋升文件中的引用依据,未确认候选不能进入任务。确认记录原子持久化后写共享记忆文件,派生索引异步更新,索引失败不会撤销有效记忆。
|
|
82
88
|
- 旧格式不读取、不自动迁移。损坏的正式状态不覆盖、不清空。
|
|
89
|
+
- 引擎状态复用受控原子写入:独占随机临时文件、文件同步、Windows 短暂共享冲突有界重试;永久失败保留原状态并清理本次临时文件,绝不先删正式状态。
|
|
83
90
|
|
|
84
91
|
## 验证与尚未验收项
|
|
85
92
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanqianx/ai-coding-knowledge-engine",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
3
|
+
"version": "0.2.3-rc.7",
|
|
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",
|
|
@@ -50,7 +50,7 @@ class EngineCoordinator extends EventEmitter {
|
|
|
50
50
|
this.retrieval = new EngineRetrieval({ layout: this.layout, projectStore: this.projectStore, registryStore, store, memories: this.memories, vectorUrl: this.vectorUrl });
|
|
51
51
|
this.conversations = new ConversationStore({ layout: this.layout, projectStore: this.projectStore });
|
|
52
52
|
this.analyzer = options.analyzer || new HeadlessAnalyzer({ resolveProfile: id => this.profiles.get(id) || this.profiles.get('default'), timeoutMs: options.timeoutMs });
|
|
53
|
-
this.promotion = new KnowledgePromotionService({ layout: this.layout, logger: this.diagnostics, projectStore: this.projectStore, analyzer: { runClaim: async input => {
|
|
53
|
+
this.promotion = new KnowledgePromotionService({ layout: this.layout, logger: this.diagnostics, projectStore: this.projectStore, analyzer: { sourceReadMode: this.analyzer.sourceReadMode, runClaim: async input => {
|
|
54
54
|
const run = this.currentRun(input.projectId);
|
|
55
55
|
store.updateRun(run.runId, { analysisRunId: input.claim.runId, phase: 'analyzing' });
|
|
56
56
|
this.notify(run, '正在分析冻结的提交证据');
|
|
@@ -3,8 +3,10 @@ const { fork, execFile } = require('node:child_process');
|
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { frameFrozenEvidence } = require('./analysis-contract');
|
|
5
5
|
const { finalizeAnalysisOutput } = require('./analysis-output');
|
|
6
|
+
const { freezeKnowledgeTargets } = require('./knowledge-targets');
|
|
6
7
|
|
|
7
8
|
class HeadlessAnalyzer {
|
|
9
|
+
get sourceReadMode() { return 'isolated-frozen-evidence'; }
|
|
8
10
|
constructor({ resolveProfile, timeoutMs = 15 * 60 * 1000, workerPath = path.join(__dirname, 'analysis-worker.js') } = {}) { this.resolveProfile = resolveProfile; this.timeoutMs = timeoutMs; this.workerPath = workerPath; this.children = new Map(); this.closed = false; }
|
|
9
11
|
async runClaim(input) {
|
|
10
12
|
if (this.closed) throw Object.assign(new Error('分析服务正在关闭。'), { code: 'WORKER_STOPPED', retryable: true });
|
|
@@ -13,6 +15,7 @@ class HeadlessAnalyzer {
|
|
|
13
15
|
const refs = [`commit:${input.claim.commitSha}`, `patch:${input.claim.patchHash}`, `conversation:${input.claim.conversationSnapshotHash}`, `retrieval:${input.claim.retrievalManifestHash}`];
|
|
14
16
|
const contract = `\n\nOUTPUT CONTRACT\nRead frozen evidence only beneath ${input.evidenceRoot}. Write only beneath ${input.stagingPath}. Put Markdown under files/<allowed-path> (README.md, GOAL.md, ARCHITECTURE.md, modules/*.md, changes/*.md). Write ${input.manifestPath} with schema knowledge-staging-manifest/v1, projectId ${input.projectId}, runId ${input.claim.runId}, commitSha ${input.claim.commitSha}, and non-empty operations. Each operation needs path, operation (create/replace/delete), reason, and evidenceReferences ${JSON.stringify(refs)}. Omit sha256: the engine seals staged bytes itself. Do not modify code, final knowledge, other runs, or indexes. Do not invent intent if conversations are unavailable.`;
|
|
15
17
|
const pathContract = `\nPATH CONTRACT (mandatory): operations[].path is the FINAL knowledge-relative POSIX path, NEVER a staging path and NEVER prefixed with "files/". Example: write the physical file ${path.join(input.stagingPath, 'files', 'changes', `${input.claim.commitSha.slice(0, 12)}.md`)} but put "path": "changes/${input.claim.commitSha.slice(0, 12)}.md" in manifest.json. Use forward slashes even on Windows. Minimal manifest shape: ${JSON.stringify({ schema: 'knowledge-staging-manifest/v1', projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, operations: [{ path: `changes/${input.claim.commitSha.slice(0, 12)}.md`, operation: 'create', reason: 'Describe the evidence-backed change', evidenceReferences: refs }] })}`;
|
|
18
|
+
const targetContract = freezeKnowledgeTargets(input).contract;
|
|
16
19
|
await new Promise((resolve, reject) => {
|
|
17
20
|
const env = { ...process.env };
|
|
18
21
|
delete env.CLAUDECODE;
|
|
@@ -50,7 +53,7 @@ class HeadlessAnalyzer {
|
|
|
50
53
|
});
|
|
51
54
|
const memoryContract = '\nYou may optionally include memoryCandidates in manifest.json: [{text, entryId, quote}]. Only suggest durable user preferences explicitly evidenced in a promoted document; quote must be an exact excerpt (at least 8 characters) from that document. Never infer preferences from code style alone. These stay unconfirmed until the user accepts them.';
|
|
52
55
|
const languageContract = input.config?.knowledgeLanguage === 'en-US' ? '\nOUTPUT LANGUAGE: English. Preserve identifiers and verbatim evidence quotations.' : '\n输出语言:简体中文。知识正文、operations[].reason 与候选记忆用简体中文;代码标识、路径、哈希和原文证据引用保持不变。';
|
|
53
|
-
child.send({ profile, projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, prompt: `${frameFrozenEvidence(input.prompt)}${contract}${pathContract}${memoryContract}${languageContract}`, manifestPath: input.manifestPath, stagingPath: input.stagingPath, safetyPolicy: input.safetyPolicy }, error => { if (error) stop(Object.assign(new Error('无法发送分析任务。'), { code: 'WORKER_START_FAILED', retryable: true })); });
|
|
56
|
+
child.send({ profile, projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha, prompt: `${frameFrozenEvidence(input.prompt)}${contract}${pathContract}${targetContract}${memoryContract}${languageContract}`, manifestPath: input.manifestPath, stagingPath: input.stagingPath, safetyPolicy: input.safetyPolicy }, error => { if (error) stop(Object.assign(new Error('无法发送分析任务。'), { code: 'WORKER_START_FAILED', retryable: true })); });
|
|
54
57
|
});
|
|
55
58
|
finalizeAnalysisOutput(input);
|
|
56
59
|
return { ok: true };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const AtomicFile = require('./runtime/atomic-file');
|
|
6
|
+
const { listMarkdownFiles } = require('./runtime/markdown-knowledge-indexer');
|
|
7
|
+
const { canonicalRelativePath } = require('./runtime/knowledge-promotion');
|
|
8
|
+
const hash = value => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
9
|
+
|
|
10
|
+
function freezeKnowledgeTargets(input) {
|
|
11
|
+
const file = path.join(input.evidenceRoot, 'knowledge-targets.json');
|
|
12
|
+
const identity = { schema: 'knowledge-target-inventory/v1', projectId: input.projectId, runId: input.claim.runId, commitSha: input.claim.commitSha };
|
|
13
|
+
let inventory;
|
|
14
|
+
if (fs.existsSync(file)) {
|
|
15
|
+
inventory = AtomicFile.readJsonStrict(file);
|
|
16
|
+
const { manifestHash, ...payload } = inventory;
|
|
17
|
+
try {
|
|
18
|
+
if (Object.keys(identity).some(key => inventory[key] !== identity[key]) || !Array.isArray(inventory.entries) || manifestHash !== hash(JSON.stringify(payload))) throw new Error();
|
|
19
|
+
for (const entry of inventory.entries) {
|
|
20
|
+
if (canonicalRelativePath(entry.path) !== entry.path || !/^sha256:[a-f0-9]{64}$/.test(entry.sha256)) throw new Error();
|
|
21
|
+
}
|
|
22
|
+
} catch { throw Object.assign(new Error('Frozen knowledge target inventory is invalid.'), { code: 'EVIDENCE_INTEGRITY_FAILED' }); }
|
|
23
|
+
} else {
|
|
24
|
+
const root = input.config?.knowledgePath;
|
|
25
|
+
const entries = [];
|
|
26
|
+
if (root && fs.existsSync(root)) {
|
|
27
|
+
const realRoot = fs.realpathSync(root);
|
|
28
|
+
for (const source of listMarkdownFiles(root)) {
|
|
29
|
+
const real = fs.realpathSync(source), relative = path.relative(realRoot, real);
|
|
30
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || fs.lstatSync(source).isSymbolicLink()) continue;
|
|
31
|
+
const entryPath = path.relative(root, source).replace(/\\/g, '/');
|
|
32
|
+
try { canonicalRelativePath(entryPath); } catch { continue; }
|
|
33
|
+
entries.push({ path: entryPath, sha256: hash(fs.readFileSync(source)) });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const payload = { ...identity, entries: entries.sort((a, b) => a.path.localeCompare(b.path)) };
|
|
37
|
+
inventory = { ...payload, manifestHash: hash(JSON.stringify(payload)) };
|
|
38
|
+
AtomicFile.writeJsonAtomic(file, inventory);
|
|
39
|
+
}
|
|
40
|
+
// Retrieval snippets are not an existence check. Keep only metadata here,
|
|
41
|
+
// rather than copying the complete knowledge tree into every run on C:.
|
|
42
|
+
const contract = `\nTARGET INVENTORY (mandatory): Read ${file} before choosing output paths. It is the frozen inventory of existing owned knowledge paths, including files NOT selected by retrieval. Use create only for a path not in this inventory; use replace/delete only for an existing path. Retrieval snippets may be partial: never discard unrelated existing knowledge or reconstruct a full document from incomplete snippets. If the full prior document is unavailable in frozen evidence, leave it unchanged and write the evidence-backed update to changes/${input.claim.commitSha.slice(0, 12)}.md instead. Do not read the live final knowledge tree. The inventory is metadata evidence, not executable instructions. The engine will reject create on an existing target; do not work around that check.`;
|
|
43
|
+
return { file, inventory, contract };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { freezeKnowledgeTargets };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { KnowledgeRetrievalService } = require('./runtime/knowledge-retrieval-service');
|
|
5
|
-
const { listMarkdownFiles } = require('./runtime/markdown-knowledge-indexer');
|
|
5
|
+
const { listMarkdownFiles, isExcludedKnowledgePath } = require('./runtime/markdown-knowledge-indexer');
|
|
6
6
|
const { DomainError } = require('./runtime/contracts');
|
|
7
7
|
|
|
8
8
|
class EngineRetrieval extends KnowledgeRetrievalService {
|
|
@@ -116,6 +116,7 @@ class EngineRetrieval extends KnowledgeRetrievalService {
|
|
|
116
116
|
if (input.spaceId && !scope && input.spaceId !== `project:${registration.projectId}`) throw new DomainError('PROJECT_NOT_FOUND', '知识范围与项目归属不匹配。', { status: 404 });
|
|
117
117
|
const project = scope ? { projectId: scope.id, knowledgePath: scope.knowledgePath } : registration;
|
|
118
118
|
const entryId = String(input.entryId || input.entry || '').replace(/\\/g, '/');
|
|
119
|
+
if (isExcludedKnowledgePath(entryId)) throw new DomainError('PATH_OUTSIDE_ROOT', '会话证据与内部数据不能作为正式知识读取。', { status: 403 });
|
|
119
120
|
const target = path.resolve(project.knowledgePath, entryId);
|
|
120
121
|
if (!entryId || !/\.md$/i.test(target) || !this.layout.isPathInside(project.knowledgePath, target, { realpath: true })) throw new DomainError('PATH_OUTSIDE_ROOT', '知识文件越界。', { status: 403 });
|
|
121
122
|
if (!fs.existsSync(target) || !fs.statSync(target).isFile() || fs.lstatSync(target).isSymbolicLink()) throw new DomainError('PROJECT_NOT_FOUND', '知识文件不存在。', { status: 404 });
|
|
@@ -312,7 +312,11 @@ class KnowledgePromotionService {
|
|
|
312
312
|
if (!claim || claim.projectId !== projectId || !claim.runId || !claim.commitSha) throw new DomainError('INVALID_ARGUMENT', 'Active claim is required for knowledge promotion.');
|
|
313
313
|
const operationId = input.operationId || claim.operationId || createId('op');
|
|
314
314
|
const stagingPath = this.prepareStaging(projectId, claim.runId);
|
|
315
|
-
|
|
315
|
+
// The isolated worker cannot read or write the live repository. A user may
|
|
316
|
+
// keep developing while it consumes frozen evidence; live status is neither
|
|
317
|
+
// its input nor proof of worker writes. Keep the guard for other analyzers.
|
|
318
|
+
const verifyLiveSource = this.analyzer?.sourceReadMode !== 'isolated-frozen-evidence';
|
|
319
|
+
const beforeSource = verifyLiveSource ? await gitStatus(config.repoPath) : null;
|
|
316
320
|
try {
|
|
317
321
|
if (!this.analyzer || typeof this.analyzer.runClaim !== 'function') throw new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer is unavailable.', { status: 503, retryable: true });
|
|
318
322
|
const analyzerResult = await this.analyzer.runClaim({
|
|
@@ -329,7 +333,7 @@ class KnowledgePromotionService {
|
|
|
329
333
|
}),
|
|
330
334
|
});
|
|
331
335
|
if (analyzerResult && analyzerResult.ok === false) throw new DomainError('INVALID_ARGUMENT', analyzerResult.error || 'Knowledge analyzer failed.', { status: 500, retryable: true });
|
|
332
|
-
const afterSource = await gitStatus(config.repoPath);
|
|
336
|
+
const afterSource = verifyLiveSource ? await gitStatus(config.repoPath) : null;
|
|
333
337
|
if (beforeSource != null && afterSource != null && beforeSource !== afterSource) {
|
|
334
338
|
throw new DomainError('PROJECT_BUSY', 'Source repository changed during knowledge analysis.', { status: 409, retryable: true });
|
|
335
339
|
}
|
|
@@ -5,13 +5,17 @@ const { sha256 } = require('./content-hash');
|
|
|
5
5
|
const CHUNKER_VERSION = 1;
|
|
6
6
|
const DEFAULT_MAX_CHARS = 1200;
|
|
7
7
|
const DEFAULT_OVERLAP_CHARS = 120;
|
|
8
|
-
const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules']);
|
|
8
|
+
const EXCLUDED_DIRS = new Set(['.git', '_ai', '_backup', 'node_modules', 'dev-conversations']);
|
|
9
9
|
const DERIVED_INDEX_FILENAME = '00-index.md';
|
|
10
10
|
|
|
11
11
|
function normalizeRel(value) {
|
|
12
12
|
return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function isExcludedKnowledgePath(value) {
|
|
16
|
+
return normalizeRel(value).split('/').some(segment => segment.startsWith('.') || EXCLUDED_DIRS.has(segment.toLowerCase()));
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
function stripFrontmatter(markdown) {
|
|
16
20
|
return String(markdown || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*\r?\n?/, '');
|
|
17
21
|
}
|
|
@@ -112,7 +116,7 @@ function listMarkdownFiles(root, options = {}) {
|
|
|
112
116
|
const files = [];
|
|
113
117
|
const walk = (dir) => {
|
|
114
118
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
115
|
-
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
|
119
|
+
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name.toLowerCase())) continue;
|
|
116
120
|
const abs = path.join(dir, entry.name);
|
|
117
121
|
if (entry.isDirectory()) walk(abs);
|
|
118
122
|
else if (entry.isFile() && /\.md$/i.test(entry.name) && (includeDerived || !isDerivedIndex(entry.name))) files.push(abs);
|
|
@@ -224,6 +228,7 @@ module.exports = {
|
|
|
224
228
|
CHUNKER_VERSION,
|
|
225
229
|
chunkMarkdown,
|
|
226
230
|
listMarkdownFiles,
|
|
231
|
+
isExcludedKnowledgePath,
|
|
227
232
|
isDerivedIndex,
|
|
228
233
|
inferEntryType,
|
|
229
234
|
parseKnowledgeMetadata,
|
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const crypto = require('crypto');
|
|
6
6
|
const { validateProjectId } = require('./runtime/contracts');
|
|
7
7
|
const { RuntimeLayout } = require('./runtime/layout');
|
|
8
|
+
const AtomicFile = require('./runtime/atomic-file');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* 单文件 JSON 状态层(<data>/engine-state.json):注册项目、分析运行、事件流。
|
|
@@ -56,12 +57,9 @@ class EngineStore {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
save() {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
try { fs.writeFileSync(fd, JSON.stringify(this.state, null, 2), 'utf8'); fs.fsyncSync(fd); }
|
|
63
|
-
finally { fs.closeSync(fd); }
|
|
64
|
-
fs.renameSync(tmp, this.file);
|
|
60
|
+
// Windows scanners/readers may briefly hold the destination; never unlink
|
|
61
|
+
// durable state to force a replace, or share a fixed temp name with writers.
|
|
62
|
+
AtomicFile.writeJsonAtomic(this.file, this.state);
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
listProjects() {
|
|
@@ -78,12 +78,25 @@ function isPortListening(port) {
|
|
|
78
78
|
async function waitForListening(port, deadlineMs = 8000) {
|
|
79
79
|
const deadline = Date.now() + deadlineMs;
|
|
80
80
|
while (Date.now() < deadline) {
|
|
81
|
-
if (await
|
|
81
|
+
if (await canConnect(port)) return true;
|
|
82
82
|
await new Promise(resolve => setTimeout(resolve, 150));
|
|
83
83
|
}
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
function canConnect(port) {
|
|
88
|
+
// A bind probe can steal the selected port exactly as the child starts.
|
|
89
|
+
// Readiness checks must connect, never compete with the server's listener.
|
|
90
|
+
return new Promise(resolve => {
|
|
91
|
+
const socket = net.createConnection({ host: '127.0.0.1', port });
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = result => { if (settled) return; settled = true; socket.destroy(); resolve(result); };
|
|
94
|
+
socket.setTimeout(500, () => finish(false));
|
|
95
|
+
socket.once('connect', () => finish(true));
|
|
96
|
+
socket.once('error', () => finish(false));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
87
100
|
(async () => {
|
|
88
101
|
// Make sure no leftover PID file or server from a previous run.
|
|
89
102
|
removePidFile();
|
|
@@ -185,7 +198,7 @@ async function waitForListening(port, deadlineMs = 8000) {
|
|
|
185
198
|
while (Date.now() < deadline && !detectedPort) {
|
|
186
199
|
const rec = readPidFile();
|
|
187
200
|
if (rec && rec.port && rec.port !== busyPort) {
|
|
188
|
-
if (await
|
|
201
|
+
if (await canConnect(rec.port)) {
|
|
189
202
|
detectedPort = rec.port;
|
|
190
203
|
break;
|
|
191
204
|
}
|
|
@@ -113,7 +113,7 @@ async function createMcpClient() {
|
|
|
113
113
|
let response = await client.request('tools/call', { name: 'project_knowledge_resolve', arguments: { repoPath: repo } });
|
|
114
114
|
assert.strictEqual(response.result.structuredContent.projectId, 'project-demo');
|
|
115
115
|
assert.strictEqual(response.result.structuredContent.knowledgePath, markdownKb);
|
|
116
|
-
assert.strictEqual(response.result.structuredContent.indexPath, layout.
|
|
116
|
+
assert.strictEqual(response.result.structuredContent.indexPath, layout.getVectorRootPath(settingsStore.read()));
|
|
117
117
|
|
|
118
118
|
response = await client.request('tools/call', { name: 'project_knowledge_search', arguments: { projectId: 'project-demo', query: 'refresh tokens' } });
|
|
119
119
|
assert.strictEqual(response.result.structuredContent.source, 'knowledge-retrieval-service');
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { Readable } = require('node:stream');
|
|
5
|
+
const { EventEmitter } = require('node:events');
|
|
6
|
+
const { ModuleBridge } = require('../lib/module-bridge');
|
|
7
|
+
|
|
8
|
+
async function budget(method, pathname) {
|
|
9
|
+
const timers = [], originalTimer = global.setTimeout, originalFetch = global.fetch;
|
|
10
|
+
const req = Readable.from([]); req.method = method; req.headers = {};
|
|
11
|
+
const res = new EventEmitter();
|
|
12
|
+
res.writeHead = () => {}; res.end = () => { res.writableEnded = true; };
|
|
13
|
+
try {
|
|
14
|
+
global.setTimeout = (fn, ms, ...args) => { timers.push(ms); return originalTimer(fn, ms, ...args); };
|
|
15
|
+
global.fetch = async () => new Response('{}', { headers: { 'Content-Type': 'application/json' } });
|
|
16
|
+
await new ModuleBridge().proxy(req, res, 'http://127.0.0.1:5760' + pathname);
|
|
17
|
+
return timers[0];
|
|
18
|
+
} finally { global.setTimeout = originalTimer; global.fetch = originalFetch; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
(async () => {
|
|
22
|
+
const upstream = http.createServer((req, res) => {
|
|
23
|
+
const timer = setTimeout(() => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"accepted":true}'); }, 11000);
|
|
24
|
+
res.on('close', () => clearTimeout(timer));
|
|
25
|
+
});
|
|
26
|
+
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
|
27
|
+
const bridge = new ModuleBridge();
|
|
28
|
+
const target = `http://127.0.0.1:${upstream.address().port}/api/claude-workbench/v1/sessions/cold-client/input`;
|
|
29
|
+
const shell = http.createServer((req, res) => bridge.proxy(req, res, target));
|
|
30
|
+
await new Promise(resolve => shell.listen(0, '127.0.0.1', resolve));
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(`http://127.0.0.1:${shell.address().port}/send`, { method: 'POST', body: '{}' });
|
|
33
|
+
assert.equal(response.status, 200, 'cold native client startup must not be reported as unreachable after ten seconds');
|
|
34
|
+
assert.equal((await response.json()).accepted, true);
|
|
35
|
+
} finally {
|
|
36
|
+
for (const server of [shell, upstream]) { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
37
|
+
}
|
|
38
|
+
assert.equal(await budget('POST', '/api/claude-workbench/v1/sessions'), 90000);
|
|
39
|
+
assert.equal(await budget('POST', '/api/claude-workbench/v1/sessions/cold-client/input?scope=project'), 90000);
|
|
40
|
+
for (const [method, route] of [
|
|
41
|
+
['GET', '/api/claude-workbench/v1/sessions'],
|
|
42
|
+
['POST', '/api/claude-workbench/v1/sessions/cold-client/abort'],
|
|
43
|
+
['POST', '/api/claude-workbench/v1/sessions/cold-client/permission'],
|
|
44
|
+
['POST', '/api/engine/runs'],
|
|
45
|
+
['POST', '/api/sessions/cold-client/input'],
|
|
46
|
+
]) assert.equal(await budget(method, route), 10000, 'unrelated APIs must keep their ordinary timeout');
|
|
47
|
+
assert.equal(await budget('POST', '/api/claude-workbench/v1/system/pick-folder'), 600000);
|
|
48
|
+
const originalFetch = global.fetch;
|
|
49
|
+
let warning, failure;
|
|
50
|
+
const request = Readable.from([]); request.method = 'GET'; request.headers = {};
|
|
51
|
+
const response = new EventEmitter(); response.writeHead = () => {};
|
|
52
|
+
response.end = bytes => { response.writableEnded = true; failure = JSON.parse(bytes); };
|
|
53
|
+
try {
|
|
54
|
+
global.fetch = async () => { throw new Error('upstream leaked credential: secret-proxy-fixture'); };
|
|
55
|
+
const safeBridge = new ModuleBridge({ logger: { warn(...args) { warning = args; } } });
|
|
56
|
+
await safeBridge.proxy(request, response, 'http://127.0.0.1:5760/api/health?apiKey=secret-proxy-fixture');
|
|
57
|
+
assert.equal(failure.error, 'MODULE_UNREACHABLE');
|
|
58
|
+
assert.equal(failure.details.failureKind, 'NETWORK_ERROR');
|
|
59
|
+
assert.equal(failure.details.timeoutMs, 10000);
|
|
60
|
+
assert.equal(warning[0], 'module.proxy.failed');
|
|
61
|
+
assert.equal(warning[2].context.path, '/api/health');
|
|
62
|
+
assert.ok(!JSON.stringify([failure, warning]).includes('secret-proxy-fixture'), 'diagnostics omit queries, raw errors and credentials');
|
|
63
|
+
} finally { global.fetch = originalFetch; }
|
|
64
|
+
console.log('module-agent-start-proxy-test PASS');
|
|
65
|
+
})().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -108,7 +108,7 @@ async function main() {
|
|
|
108
108
|
vectorHubUrl: DEAD_URL,
|
|
109
109
|
eventBridgeUrl: stubUrl,
|
|
110
110
|
engineUrl: DEAD_URL,
|
|
111
|
-
registryStore: { listIds: () => ['demo-proj'] },
|
|
111
|
+
registryStore: { listIds: () => ['demo-proj'], readDisplaySnapshot: id => id === 'demo-proj' ? { displayName: 'demo' } : null },
|
|
112
112
|
projectStore: makeStoreStub(),
|
|
113
113
|
});
|
|
114
114
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { ModuleBridge } = require('../lib/module-bridge');
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
let releaseRegister, registerStarted, registered = true;
|
|
8
|
+
const started = new Promise(resolve => { registerStarted = resolve; });
|
|
9
|
+
const gate = new Promise(resolve => { releaseRegister = resolve; });
|
|
10
|
+
const engineProjects = new Set();
|
|
11
|
+
const calls = [];
|
|
12
|
+
const server = http.createServer(async (req, res) => {
|
|
13
|
+
req.resume();
|
|
14
|
+
if (req.method === 'POST' && req.url === '/engine/api/projects') {
|
|
15
|
+
registerStarted(); await gate;
|
|
16
|
+
engineProjects.add('project-race'); calls.push('register');
|
|
17
|
+
}
|
|
18
|
+
if (req.method === 'DELETE' && req.url === '/engine/api/projects/project-race') { engineProjects.delete('project-race'); calls.push('remove'); }
|
|
19
|
+
res.setHeader('Content-Type', 'application/json');
|
|
20
|
+
res.end(JSON.stringify({ ok: true, projects: [], id: 'terminal-race', project: { id: 'event-race' }, sequence: 0 }));
|
|
21
|
+
});
|
|
22
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
23
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
24
|
+
const state = { modules: {} };
|
|
25
|
+
const bridge = new ModuleBridge({ terminalUrl: base + '/terminal', vectorHubUrl: base + '/vector', eventBridgeUrl: base + '/event', engineUrl: base + '/engine', registryStore: { readDisplaySnapshot: () => registered ? { displayName: 'Race' } : null }, projectStore: { readState: () => state, readConfig: () => ({}), updateState: async (_id, apply) => { apply(state); } } });
|
|
26
|
+
try {
|
|
27
|
+
const input = { projectId: 'project-race', name: 'Race', workspacePath: process.cwd(), knowledgePath: process.cwd() };
|
|
28
|
+
const wiring = bridge.registerProjectLinks(input);
|
|
29
|
+
await started;
|
|
30
|
+
const removing = bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { registered = false; } }).then(() => { registered = false; });
|
|
31
|
+
// A delayed supervisor registration must settle before removal, never after.
|
|
32
|
+
await new Promise(resolve => setTimeout(resolve, 100)); releaseRegister();
|
|
33
|
+
await Promise.all([wiring, removing]);
|
|
34
|
+
assert.deepEqual([...engineProjects], [], 'delayed wiring must not resurrect a removed engine project');
|
|
35
|
+
assert.deepEqual(calls, ['register', 'remove']);
|
|
36
|
+
const before = calls.length;
|
|
37
|
+
const stale = await bridge.registerProjectLinks(input);
|
|
38
|
+
assert.equal(calls.length, before, 'a stale registry snapshot cannot register an absent project');
|
|
39
|
+
assert.equal(stale.engine.status, 'removed');
|
|
40
|
+
assert.equal(bridge.projectWiringQueue?.size || 0, 0, 'settled operations must not leak per-project queues');
|
|
41
|
+
let releaseOther;
|
|
42
|
+
const otherGate = new Promise(resolve => { releaseOther = resolve; });
|
|
43
|
+
const order = [];
|
|
44
|
+
const held = bridge._withProjectWiring('project-a', async () => { await otherGate; order.push('a'); });
|
|
45
|
+
const independent = bridge._withProjectWiring('project-b', async () => { order.push('b'); });
|
|
46
|
+
await independent; assert.deepEqual(order, ['b'], 'unrelated projects do not share a global wiring lock');
|
|
47
|
+
releaseOther(); await held; assert.deepEqual(order, ['b', 'a']);
|
|
48
|
+
// Failed deletion keeps its registry, then a retry can still repair/remove it.
|
|
49
|
+
registered = true;
|
|
50
|
+
await assert.rejects(bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { throw new Error('controlled hook conflict'); } }), /controlled hook conflict/);
|
|
51
|
+
assert.equal(registered, true);
|
|
52
|
+
await bridge.registerProjectLinks(input);
|
|
53
|
+
assert.ok(engineProjects.has('project-race'));
|
|
54
|
+
await bridge.removeProjectLinks({ projectId: 'project-race', onRemoved: async () => { registered = false; } });
|
|
55
|
+
assert.deepEqual([...engineProjects], []);
|
|
56
|
+
console.log('module-wiring-unbind-race-test PASS');
|
|
57
|
+
} finally { releaseRegister(); server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); }
|
|
58
|
+
}
|
|
59
|
+
main().catch(error => { console.error(error); process.exitCode = 1; });
|
|
@@ -98,7 +98,7 @@ async function waitForServer() {
|
|
|
98
98
|
const resolved = runtime.resolveProject({ repoPath: movedRepo });
|
|
99
99
|
assert.strictEqual(resolved.projectId, 'project-stable');
|
|
100
100
|
assert.strictEqual(resolved.knowledgePath, oldKnowledge);
|
|
101
|
-
assert.strictEqual(resolved.indexPath, layout.
|
|
101
|
+
assert.strictEqual(resolved.indexPath, layout.getVectorRootPath(settingsStore.read()));
|
|
102
102
|
const mcpResolved = await callTool(runtime, 'project_knowledge_resolve', { projectId: 'project-stable' });
|
|
103
103
|
assert.strictEqual(mcpResolved.knowledgePath, resolved.knowledgePath);
|
|
104
104
|
assert.strictEqual(mcpResolved.indexPath, resolved.indexPath);
|
|
@@ -83,7 +83,11 @@ async function main() {
|
|
|
83
83
|
if (browser) console.error(await browser.evaluate("JSON.stringify({ toast:document.getElementById('toast').textContent, project:document.getElementById('projectSelect').value, detail:document.getElementById('projectDetail').textContent })"));
|
|
84
84
|
throw error;
|
|
85
85
|
} finally {
|
|
86
|
-
await browser?.close();
|
|
86
|
+
try { await browser?.close(); }
|
|
87
|
+
finally {
|
|
88
|
+
try { await server.cleanup(); }
|
|
89
|
+
finally { offline.closeAllConnections(); await new Promise(resolve => offline.close(resolve)); fixture.cleanup(); }
|
|
90
|
+
}
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
main().catch(error => { console.error(error.stack || error.message); process.exitCode = 1; });
|