@sanqianx/project-knowledge 5.0.24 → 5.0.26

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.
Files changed (401) hide show
  1. package/.agents/plugins/marketplace.json +20 -20
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +1361 -1353
  4. package/LICENSE +201 -201
  5. package/NOTICE +5 -5
  6. package/README.md +349 -349
  7. package/_modules/knowledge-engine/package.json +1 -1
  8. package/_modules/knowledge-engine/src/coordinator.js +48 -3
  9. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -140
  10. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +238 -238
  11. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -26
  12. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +193 -193
  13. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +394 -394
  14. package/_modules/knowledge-engine/src/runtime/contracts.js +282 -282
  15. package/_modules/knowledge-engine/src/runtime/conversation-store.js +389 -389
  16. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -228
  17. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -62
  18. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +505 -505
  19. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +400 -400
  20. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +171 -171
  21. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -298
  22. package/_modules/knowledge-engine/ui/app.js +23 -2
  23. package/_site/README.md +30 -30
  24. package/_site/_test/agent-capture-mcp-ui-test.js +68 -68
  25. package/_site/_test/agent-capture-ui-test.js +101 -101
  26. package/_site/_test/ai-profile-resolver-test.js +137 -137
  27. package/_site/_test/ai-profile-test.js +75 -75
  28. package/_site/_test/ai-vendor-presets-test.js +91 -91
  29. package/_site/_test/automatic-context-test.js +70 -70
  30. package/_site/_test/automation-queue-test.js +14 -14
  31. package/_site/_test/automation-ui-test.js +40 -40
  32. package/_site/_test/background-task-registry-test.js +43 -43
  33. package/_site/_test/baseline-schema-test.js +91 -91
  34. package/_site/_test/bin-cli-test.js +230 -230
  35. package/_site/_test/bridge-adapter-test.js +95 -95
  36. package/_site/_test/bridge-consumer-service-test.js +310 -310
  37. package/_site/_test/candidate-bin-content-test.js +35 -35
  38. package/_site/_test/candidate-product-assets-test.js +13 -13
  39. package/_site/_test/candidate-release-contract-test.js +14 -14
  40. package/_site/_test/capture-hook-dedup-test.js +158 -158
  41. package/_site/_test/cdp-browser-failure-test.js +103 -103
  42. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  43. package/_site/_test/claude-executable-discovery-test.js +90 -90
  44. package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
  45. package/_site/_test/claude-workbench-test.js +174 -174
  46. package/_site/_test/cli-instance-management-test.js +62 -62
  47. package/_site/_test/client-capture-restart-e2e-test.js +189 -189
  48. package/_site/_test/codex-conversation-projection-test.js +60 -60
  49. package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
  50. package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
  51. package/_site/_test/codex-notify-fanout-test.js +141 -134
  52. package/_site/_test/codex-notify-upgrade-test.js +253 -253
  53. package/_site/_test/commit-boundary-freeze-test.js +147 -147
  54. package/_site/_test/commit-conversation-binding-test.js +99 -99
  55. package/_site/_test/commit-evidence-test.js +110 -110
  56. package/_site/_test/configuration-generation-test.js +22 -22
  57. package/_site/_test/context-pack-test.js +200 -200
  58. package/_site/_test/conversation-api-test.js +119 -119
  59. package/_site/_test/conversation-origin-test.js +15 -15
  60. package/_site/_test/conversation-store-test.js +138 -138
  61. package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
  62. package/_site/_test/desktop-browser-compat-test.js +28 -28
  63. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  64. package/_site/_test/embedding-config-test.js +32 -32
  65. package/_site/_test/explicit-commit-processor-test.js +37 -37
  66. package/_site/_test/fixtures/fake-browser-exit.js +10 -10
  67. package/_site/_test/fixtures/fake-browser-hang.js +10 -10
  68. package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
  69. package/_site/_test/fixtures/make-git-repos.js +92 -92
  70. package/_site/_test/fixtures/retrieval-gold-queries.json +32 -32
  71. package/_site/_test/folder-picker-output-test.js +19 -19
  72. package/_site/_test/fresh-hook-reimport-test.js +53 -53
  73. package/_site/_test/fresh-product-data-test.js +56 -56
  74. package/_site/_test/full-integration-e2e-test.js +167 -167
  75. package/_site/_test/git-path-compatibility-test.js +80 -80
  76. package/_site/_test/git-validation-test.js +81 -81
  77. package/_site/_test/glm-ui-refresh-review-test.js +313 -404
  78. package/_site/_test/harness-isolation-test.js +138 -0
  79. package/_site/_test/helpers/cdp-browser.js +383 -0
  80. package/_site/_test/helpers/cdp-network-capture.js +66 -0
  81. package/_site/_test/helpers/find-chrome.js +34 -0
  82. package/_site/_test/helpers/free-port.js +8 -0
  83. package/_site/_test/helpers/logging-ui-fixture.js +131 -0
  84. package/_site/_test/helpers/module-fixture.js +282 -0
  85. package/_site/_test/helpers/parallel-engine-advanced-engine-child.js +45 -0
  86. package/_site/_test/helpers/parallel-engine-advanced-fault-writer.js +171 -0
  87. package/_site/_test/helpers/parallel-engine-advanced-harness.js +245 -0
  88. package/_site/_test/helpers/parallel-engine-advanced-standin-analyzer.js +80 -0
  89. package/_site/_test/helpers/parallel-engine-advanced-ui-host.js +177 -0
  90. package/_site/_test/helpers/parallel-engine-final-harness.js +314 -0
  91. package/_site/_test/helpers/parallel-engine-final-standin-analyzer.js +159 -0
  92. package/_site/_test/helpers/parallel-knowledge-fixture.js +255 -0
  93. package/_site/_test/helpers/parallel-logging-acceptance-fixture.js +111 -0
  94. package/_site/_test/helpers/parallel-logging-acceptance-proxy.js +106 -0
  95. package/_site/_test/helpers/parallel-logging-fix-r2-proxy.js +205 -0
  96. package/_site/_test/helpers/parallel-logging-integration-5025-env.js +432 -0
  97. package/_site/_test/helpers/parallel-memory-analyzer.js +81 -0
  98. package/_site/_test/helpers/parallel-memory-event-bridge.js +128 -0
  99. package/_site/_test/helpers/parallel-memory-shell.js +470 -0
  100. package/_site/_test/helpers/parallel-nav-isolation-env.js +389 -0
  101. package/_site/_test/helpers/parallel-nav-module-ui.js +186 -0
  102. package/_site/_test/helpers/parallel-nav-proxy.js +148 -0
  103. package/_site/_test/helpers/parallel-nav-shell.js +361 -0
  104. package/_site/_test/helpers/parallel-qa-engine-endpoint-guard.js +206 -0
  105. package/_site/_test/helpers/parallel-qa-engine-endpoint-isolate.js +257 -0
  106. package/_site/_test/helpers/parallel-retrieval-followup-corpus.js +293 -0
  107. package/_site/_test/helpers/parallel-retrieval-followup-next-session.js +70 -0
  108. package/_site/_test/helpers/parallel-retrieval-followup-r2-shell.js +737 -0
  109. package/_site/_test/helpers/parallel-retrieval-followup-r2-wb-host.js +82 -0
  110. package/_site/_test/helpers/parallel-retrieval-followup-shell.js +279 -0
  111. package/_site/_test/helpers/parallel-workbench-fixture.js +371 -0
  112. package/_site/_test/helpers/parallel-workbench-host.js +80 -0
  113. package/_site/_test/helpers/parallel-workbench-integration-5025-fixture.js +408 -0
  114. package/_site/_test/helpers/parallel-workbench-integration-5025-harness.js +593 -0
  115. package/_site/_test/helpers/parallel-workbench-integration-5025-host.js +70 -0
  116. package/_site/_test/helpers/parallel-workbench-integration-5025-launcher.js +240 -0
  117. package/_site/_test/helpers/parallel-workbench-integration-5025-runtime-harness.js +630 -0
  118. package/_site/_test/helpers/parallel-workbench-source-map.js +196 -0
  119. package/_site/_test/helpers/spawn-server.js +106 -0
  120. package/_site/_test/helpers/version-visibility-fixture.js +251 -0
  121. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  122. package/_site/_test/hook-status-repair-api-test.js +152 -152
  123. package/_site/_test/hook-trigger-test.js +85 -85
  124. package/_site/_test/hook-worktree-test.js +42 -42
  125. package/_site/_test/import-preflight-api-test.js +234 -234
  126. package/_site/_test/index-builder-test.js +41 -41
  127. package/_site/_test/index-error-display-contract-test.js +10 -10
  128. package/_site/_test/index-writer-concurrency-test.js +135 -135
  129. package/_site/_test/integration-adapters-test.js +262 -262
  130. package/_site/_test/integration-auto-repair-test.js +113 -113
  131. package/_site/_test/integration-capture-setup-test.js +118 -118
  132. package/_site/_test/integration-intent-test.js +81 -81
  133. package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
  134. package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
  135. package/_site/_test/integration-runtime-binding-test.js +473 -473
  136. package/_site/_test/integration-status-scan-test.js +29 -29
  137. package/_site/_test/integration-surface-coverage-test.js +164 -164
  138. package/_site/_test/kb-framework-test.js +57 -57
  139. package/_site/_test/kbpath-follow-test.js +63 -63
  140. package/_site/_test/knowledge-db-test.js +144 -144
  141. package/_site/_test/knowledge-engine-client-test.js +120 -109
  142. package/_site/_test/knowledge-language-control-test.js +170 -170
  143. package/_site/_test/knowledge-maintenance-test.js +40 -40
  144. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  145. package/_site/_test/knowledge-query-test.js +54 -54
  146. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  147. package/_site/_test/knowledge-scopes-test.js +37 -37
  148. package/_site/_test/knowledge-storage-location-test.js +28 -28
  149. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  150. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  151. package/_site/_test/launcher-readiness-test.js +80 -80
  152. package/_site/_test/legacy-claude-session-alias-test.js +106 -106
  153. package/_site/_test/legacy-routes-removed-test.js +47 -47
  154. package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
  155. package/_site/_test/live-gateway-test.js +48 -48
  156. package/_site/_test/local-marketplace-test.js +16 -16
  157. package/_site/_test/log-redaction-test.js +58 -58
  158. package/_site/_test/logging-api-test.js +89 -89
  159. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  160. package/_site/_test/logging-ui-test.js +107 -107
  161. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  162. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  163. package/_site/_test/markdown-maintenance-test.js +11 -11
  164. package/_site/_test/mcp-server-test.js +149 -149
  165. package/_site/_test/model-context-windows-test.js +89 -89
  166. package/_site/_test/module-agent-start-proxy-test.js +65 -65
  167. package/_site/_test/module-artifact-integrity-test.js +27 -27
  168. package/_site/_test/module-boundary-test.js +30 -30
  169. package/_site/_test/module-bridge-eventbridge-test.js +202 -202
  170. package/_site/_test/module-dynamic-port-test.js +118 -118
  171. package/_site/_test/module-index-proxy-test.js +33 -33
  172. package/_site/_test/module-model-configuration-test.js +44 -44
  173. package/_site/_test/module-process-lifecycle-test.js +35 -35
  174. package/_site/_test/module-stream-proxy-test.js +34 -34
  175. package/_site/_test/module-terminal-restart-test.js +63 -63
  176. package/_site/_test/module-wiring-unbind-race-test.js +59 -59
  177. package/_site/_test/native-context-integrations-test.js +86 -86
  178. package/_site/_test/non-release-ci-test.js +34 -34
  179. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  180. package/_site/_test/orphaned-claim-recovery-test.js +80 -80
  181. package/_site/_test/package-boundary-test.js +43 -43
  182. package/_site/_test/package-startup-test.js +56 -56
  183. package/_site/_test/packaged-ui-smoke-test.js +88 -88
  184. package/_site/_test/parallel-ci-r9-posix-socket-budget-test.js +156 -0
  185. package/_site/_test/parallel-engine-advanced-eng005-test.js +136 -0
  186. package/_site/_test/parallel-engine-advanced-eng006-test.js +171 -0
  187. package/_site/_test/parallel-engine-advanced-eng007-reconcile-test.js +231 -0
  188. package/_site/_test/parallel-engine-advanced-eng009-test.js +208 -0
  189. package/_site/_test/parallel-engine-advanced-eng010-test.js +213 -0
  190. package/_site/_test/parallel-engine-advanced-eng011-ui-probe.js +459 -0
  191. package/_site/_test/parallel-engine-final-eng002-test.js +255 -0
  192. package/_site/_test/parallel-engine-final-eng007-test.js +269 -0
  193. package/_site/_test/parallel-engine-final-eng011-test.js +197 -0
  194. package/_site/_test/parallel-engine-final-eng012-test.js +354 -0
  195. package/_site/_test/parallel-knowledge-client-error-code-test.js +184 -0
  196. package/_site/_test/parallel-knowledge-engine-test.js +353 -0
  197. package/_site/_test/parallel-knowledge-retrieval-test.js +292 -0
  198. package/_site/_test/parallel-knowledge-vector-test.js +168 -0
  199. package/_site/_test/parallel-linux-ci-r7-log007-day-test.js +110 -0
  200. package/_site/_test/parallel-logging-acceptance-log003-test.js +224 -0
  201. package/_site/_test/parallel-logging-acceptance-log004-test.js +231 -0
  202. package/_site/_test/parallel-logging-acceptance-log005-test.js +208 -0
  203. package/_site/_test/parallel-logging-acceptance-log006-test.js +214 -0
  204. package/_site/_test/parallel-logging-acceptance-log007-test.js +223 -0
  205. package/_site/_test/parallel-logging-acceptance-log008-test.js +180 -0
  206. package/_site/_test/parallel-logging-fix-r2-crash-probe.js +271 -0
  207. package/_site/_test/parallel-logging-fix-r2-gap-test.js +294 -0
  208. package/_site/_test/parallel-logging-fix-r2-normalhook-test.js +330 -0
  209. package/_site/_test/parallel-logging-integration-5025-portability-test.js +554 -0
  210. package/_site/_test/parallel-memory-candidate-test.js +301 -0
  211. package/_site/_test/parallel-memory-manual-test.js +197 -0
  212. package/_site/_test/parallel-memory-scope-test.js +228 -0
  213. package/_site/_test/parallel-memory-source-test.js +227 -0
  214. package/_site/_test/parallel-memory-sync-test.js +238 -0
  215. package/_site/_test/parallel-memory-view-r8-reload-gate-test.js +123 -0
  216. package/_site/_test/parallel-nav-failure-test.js +119 -0
  217. package/_site/_test/parallel-nav-isolation-test.js +308 -0
  218. package/_site/_test/parallel-nav-isolation-vector-path-test.js +90 -0
  219. package/_site/_test/parallel-nav-offline-test.js +272 -0
  220. package/_site/_test/parallel-nav-operability-test.js +257 -0
  221. package/_site/_test/parallel-nav-switch-test.js +247 -0
  222. package/_site/_test/parallel-nav-theme-test.js +130 -0
  223. package/_site/_test/parallel-nav-viewport-test.js +310 -0
  224. package/_site/_test/parallel-qa-engine-endpoint-guard-test.js +273 -0
  225. package/_site/_test/parallel-retrieval-followup-citation-test.js +219 -0
  226. package/_site/_test/parallel-retrieval-followup-corpus-test.js +287 -0
  227. package/_site/_test/parallel-workbench-independent-host.js +262 -0
  228. package/_site/_test/parallel-workbench-integration-5025-abort-probe.js +340 -0
  229. package/_site/_test/parallel-workbench-integration-5025-drafts-probe.js +588 -0
  230. package/_site/_test/parallel-workbench-integration-5025-env-check.js +317 -0
  231. package/_site/_test/parallel-workbench-integration-5025-model-probe.js +342 -0
  232. package/_site/_test/parallel-workbench-integration-5025-ordering-probe.js +326 -0
  233. package/_site/_test/parallel-workbench-model-probe.js +336 -0
  234. package/_site/_test/parallel-workbench-offline-test.js +169 -0
  235. package/_site/_test/parallel-workbench-provenance-trace.js +134 -0
  236. package/_site/_test/parallel-workbench-source-match.js +206 -0
  237. package/_site/_test/parallel-workbench-standalone-turn.js +349 -0
  238. package/_site/_test/path-consistency-test.js +151 -151
  239. package/_site/_test/pending-sweep-test.js +9 -9
  240. package/_site/_test/post-commit-automation-test.js +87 -87
  241. package/_site/_test/pr-consumer-contract-test.js +55 -55
  242. package/_site/_test/product-brand-contract-test.js +25 -25
  243. package/_site/_test/product-diagnostics-ui-test.js +93 -93
  244. package/_site/_test/product-empty-state-test.js +148 -0
  245. package/_site/_test/product-import-ui-test.js +219 -104
  246. package/_site/_test/product-navigation-test.js +152 -0
  247. package/_site/_test/product-progress-status-test.js +84 -84
  248. package/_site/_test/project-delete-recovery-test.js +64 -64
  249. package/_site/_test/project-domain-create-test.js +91 -0
  250. package/_site/_test/project-domain-scope-retrieval-test.js +214 -0
  251. package/_site/_test/project-git-init-confirm-test.js +115 -0
  252. package/_site/_test/project-goal-editor-test.js +144 -144
  253. package/_site/_test/project-import-duplicate-alias-test.js +134 -0
  254. package/_site/_test/project-import-unicode-restart-test.js +140 -0
  255. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  256. package/_site/_test/project-module-partial-recovery-test.js +222 -0
  257. package/_site/_test/project-remove-running-guard-test.js +79 -79
  258. package/_site/_test/project-store-test.js +90 -90
  259. package/_site/_test/project-unbind-late-read-ui-test.js +250 -0
  260. package/_site/_test/project-unbind-writer-guard-test.js +231 -0
  261. package/_site/_test/prompt-settings-test.js +115 -115
  262. package/_site/_test/protected-architecture-gate-test.js +122 -122
  263. package/_site/_test/refactor-characterization-test.js +36 -36
  264. package/_site/_test/registry-events-test.js +38 -38
  265. package/_site/_test/release-version-sync-test.js +115 -115
  266. package/_site/_test/requirement-binding-test.js +113 -113
  267. package/_site/_test/requirement-recorder-test.js +173 -173
  268. package/_site/_test/run-all-tests.js +173 -161
  269. package/_site/_test/runtime-endpoint-test.js +64 -64
  270. package/_site/_test/scanner-test.js +83 -83
  271. package/_site/_test/server-security-test.js +96 -96
  272. package/_site/_test/sessions-stream-test.js +93 -93
  273. package/_site/_test/shared-contracts-test.js +58 -58
  274. package/_site/_test/shared-scope-root-binding-test.js +74 -67
  275. package/_site/_test/simple-import-test.js +98 -98
  276. package/_site/_test/startup-analysis-disabled-test.js +16 -16
  277. package/_site/_test/storage-foundation-test.js +78 -78
  278. package/_site/_test/structured-logger-test.js +101 -101
  279. package/_site/_test/t07b-cln-005-coverage-test.js +212 -0
  280. package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
  281. package/_site/_test/team-knowledge-store-test.js +132 -132
  282. package/_site/_test/test-server-cleanup-test.js +35 -35
  283. package/_site/_test/test-server-module-isolation-test.js +37 -37
  284. package/_site/_test/tracking-start-test.js +144 -144
  285. package/_site/_test/vector-storage-root-test.js +75 -75
  286. package/_site/_test/version-visibility-browser-probe.js +249 -0
  287. package/_site/_test/version-visibility-test.js +92 -0
  288. package/_site/_test/workbench-context-receipts-test.js +22 -22
  289. package/_site/_test/workbench-permission-test.js +117 -117
  290. package/_site/_test/zcode-capture-integration-test.js +112 -112
  291. package/_site/lib/ai-profile-resolver.js +78 -78
  292. package/_site/lib/ai-vendor-presets.js +83 -83
  293. package/_site/lib/ai-workspace.js +104 -104
  294. package/_site/lib/atomic-file.js +204 -204
  295. package/_site/lib/automation-config.js +140 -140
  296. package/_site/lib/bridge-adapter.js +260 -260
  297. package/_site/lib/bridge-consumer-service.js +402 -402
  298. package/_site/lib/capture-hook-config.js +108 -108
  299. package/_site/lib/claude-cli-runner.js +1599 -1599
  300. package/_site/lib/cli-instances.js +43 -43
  301. package/_site/lib/codex-context-status.js +17 -17
  302. package/_site/lib/codex-notify-config.js +193 -193
  303. package/_site/lib/commit-conversation-binder.js +247 -247
  304. package/_site/lib/commit-processing-ledger.js +27 -27
  305. package/_site/lib/commit-prompt.js +202 -202
  306. package/_site/lib/commit-reconciler.js +444 -444
  307. package/_site/lib/context-pack-builder.js +299 -299
  308. package/_site/lib/contracts.js +291 -291
  309. package/_site/lib/conversation-exclusions.js +204 -204
  310. package/_site/lib/conversation-query-service.js +161 -161
  311. package/_site/lib/conversation-store.js +387 -387
  312. package/_site/lib/data-dir.js +13 -13
  313. package/_site/lib/embedding-service.js +85 -85
  314. package/_site/lib/engine-connection.js +93 -93
  315. package/_site/lib/engine-knowledge-client.js +76 -72
  316. package/_site/lib/evidence-bundle.js +228 -228
  317. package/_site/lib/folder-picker-output.js +24 -24
  318. package/_site/lib/git-path.js +14 -14
  319. package/_site/lib/git-runner.js +62 -62
  320. package/_site/lib/github-team-store.js +1175 -1175
  321. package/_site/lib/hook-manager.js +203 -203
  322. package/_site/lib/index-builder.js +157 -157
  323. package/_site/lib/index-service.js +173 -173
  324. package/_site/lib/integration-auto-repair.js +77 -77
  325. package/_site/lib/integration-installer.js +1240 -1240
  326. package/_site/lib/integration-intent.js +88 -88
  327. package/_site/lib/integration-status-scan.js +99 -99
  328. package/_site/lib/integration-status-worker.js +14 -14
  329. package/_site/lib/kb-framework.js +191 -191
  330. package/_site/lib/kb-validator.js +124 -124
  331. package/_site/lib/knowledge-db.js +363 -363
  332. package/_site/lib/knowledge-promotion.js +435 -435
  333. package/_site/lib/knowledge-retrieval-service.js +401 -401
  334. package/_site/lib/knowledge-schema.js +121 -121
  335. package/_site/lib/knowledge-tool-runtime.js +366 -366
  336. package/_site/lib/live-gateway.js +78 -78
  337. package/_site/lib/llm-client.js +173 -173
  338. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  339. package/_site/lib/model-context-windows.js +82 -82
  340. package/_site/lib/module-bridge.js +874 -874
  341. package/_site/lib/module-port-resolver.js +57 -57
  342. package/_site/lib/post-commit-automation.js +141 -141
  343. package/_site/lib/product-data.js +35 -35
  344. package/_site/lib/project-lifecycle-service.js +554 -554
  345. package/_site/lib/project-registry-store.js +129 -129
  346. package/_site/lib/project-store.js +268 -268
  347. package/_site/lib/prompt-registry.js +148 -148
  348. package/_site/lib/public-version-info.js +37 -0
  349. package/_site/lib/registry-events.js +16 -16
  350. package/_site/lib/requirement-adapters.js +10 -10
  351. package/_site/lib/requirement-binder.js +112 -112
  352. package/_site/lib/requirement-recorder.js +278 -278
  353. package/_site/lib/runtime-control.js +89 -89
  354. package/_site/lib/runtime-endpoint.js +160 -160
  355. package/_site/lib/scanner.js +298 -298
  356. package/_site/lib/server-app.js +1672 -1664
  357. package/_site/lib/settings-store.js +153 -153
  358. package/_site/lib/storage-layout.js +166 -166
  359. package/_site/lib/structured-logger.js +574 -574
  360. package/_site/lib/zcode-context-integration.js +60 -60
  361. package/_site/scripts/codex-notify-fanout.cjs +95 -95
  362. package/_site/scripts/folder-picker.ps1 +156 -156
  363. package/_site/scripts/hook-trigger.js +158 -158
  364. package/_site/scripts/install-module-candidates.js +69 -69
  365. package/_site/scripts/pack-module-candidates.js +89 -89
  366. package/_site/scripts/sync-product-content.js +28 -28
  367. package/_site/scripts/sync-release-version.js +164 -164
  368. package/_site/scripts/vendor-modules.js +49 -49
  369. package/_site/scripts/verify-module-runtime.js +50 -50
  370. package/_site/scripts/verify-product-runtime.js +36 -36
  371. package/_site/scripts/verify-retrieval-gold.js +83 -83
  372. package/_site/server.js +15 -15
  373. package/_site/start.bat +26 -26
  374. package/_site/stop.bat +11 -11
  375. package/bin/project-knowledge-integrations.js +254 -254
  376. package/bin/project-knowledge-kb.js +67 -67
  377. package/bin/project-knowledge-mcp.js +201 -201
  378. package/bin/project-knowledge.js +269 -170
  379. package/claude-prompts.json +21 -21
  380. package/docs/README.zh-CN.md +207 -205
  381. package/docs/assets/architecture.svg +181 -181
  382. package/docs/assets/logo.svg +49 -49
  383. package/docs/fresh-v5-start.md +44 -44
  384. package/docs/leader-glm-workbench-playbook.md +122 -122
  385. package/docs/project-registry-schema.md +33 -33
  386. package/docs/release-5.0.16-acceptance.md +176 -176
  387. package/docs/release-5.0.17-acceptance.md +117 -117
  388. package/docs/release-5.0.19-acceptance.md +39 -39
  389. package/docs/team-knowledge-mode-a-plan.md +73 -73
  390. package/docs/testing-strategy.md +33 -33
  391. package/module-runtime-manifest.json +68 -65
  392. package/package.json +113 -110
  393. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  394. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  395. package/plugins/project-knowledge/.mcp.json +1 -1
  396. package/templates/change.md +30 -30
  397. package/templates/project-readme.md +52 -52
  398. package/ui/index.html +3 -2
  399. package/ui/version-info.css +44 -0
  400. package/ui/version-info.js +24 -0
  401. package/vendor-manifest.json +55 -55
@@ -1,1664 +1,1672 @@
1
- const http = require('http');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const crypto = require('crypto');
5
-
6
- const dataDir = require('./data-dir');
7
- const runtimeEndpoint = require('./runtime-endpoint');
8
- const runtimeControl = require('./runtime-control');
9
- const hookManager = require('./hook-manager');
10
- const claudeCliRunner = require('./claude-cli-runner');
11
- const { StorageLayout } = require('./storage-layout');
12
- const { SettingsStore } = require('./settings-store');
13
- const { ProjectRegistryStore } = require('./project-registry-store');
14
- const { ProjectStore } = require('./project-store');
15
- const { ProjectLifecycleService } = require('./project-lifecycle-service');
16
- const { RequirementRecorder } = require('./requirement-recorder');
17
- const { ConversationStore } = require('./conversation-store');
18
- const { ConversationQueryService } = require('./conversation-query-service');
19
- const { BridgeAdapter } = require('./bridge-adapter');
20
- const { BridgeConsumerService } = require('./bridge-consumer-service');
21
- const { IntegrationManager } = require('./integration-installer');
22
- const { Logger, LogRepository } = require('./structured-logger');
23
- const { CommitReconciler } = require('./commit-reconciler');
24
- const { handlePostCommitEvent, recoverOrphanedClaims } = require('./post-commit-automation');
25
- const { KnowledgePromotionService, hashBuffer } = require('./knowledge-promotion');
26
- const { resolveEffectiveAiProfile } = require('./ai-profile-resolver');
27
- const { ModuleBridge } = require('./module-bridge');
28
- const { connectEngine, forwardCommitToBridge } = require('./engine-connection');
29
- const { initializeProductData } = require('./product-data');
30
- const { IndexService } = require('./index-service');
31
- const { KnowledgeDatabase } = require('./knowledge-db');
32
- const { LocalEmbeddingService } = require('./embedding-service');
33
- const { MarkdownKnowledgeIndexer } = require('./markdown-knowledge-indexer');
34
- const { KnowledgeToolRuntime } = require('./knowledge-tool-runtime');
35
- const { buildContextPack } = require('./context-pack-builder');
36
- const { execGit } = require('./git-runner');
37
- const {
38
- SCHEMAS,
39
- DomainError,
40
- createId,
41
- serializeErrorEnvelope,
42
- publicAiProfilesConfig,
43
- validateProjectId,
44
- LOG_LEVELS,
45
- REDACTION_KEY_PATTERN,
46
- } = require('./contracts');
47
-
48
- const DEFAULT_PORT = 5757;
49
- const UI_ROOT = path.resolve(__dirname, '..', '..', 'ui');
50
- const TRIGGER_SCRIPT_PATH = path.resolve(__dirname, '..', 'scripts', 'hook-trigger.js');
51
- const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
52
- const CONTENT_TYPES = Object.freeze({
53
- '.html': 'text/html; charset=utf-8',
54
- '.css': 'text/css; charset=utf-8',
55
- '.js': 'text/javascript; charset=utf-8',
56
- '.svg': 'image/svg+xml',
57
- '.json': 'application/json; charset=utf-8',
58
- '.png': 'image/png',
59
- '.ico': 'image/x-icon',
60
- });
61
-
62
- function isLoopback(host) {
63
- return LOOPBACK_HOSTS.has(String(host || '').trim().toLowerCase());
64
- }
65
-
66
- function parsePort(value) {
67
- const port = Number(value || DEFAULT_PORT);
68
- if (!Number.isInteger(port) || port < 1 || port > 65535) throw new DomainError('INVALID_ARGUMENT', 'KB_SITE_PORT is invalid.', { status: 500 });
69
- return port;
70
- }
71
-
72
- function parseAllowedOrigins(value) {
73
- return new Set(String(value || '').split(',').map(item => item.trim()).filter(Boolean));
74
- }
75
-
76
- function configuredSecrets(settingsStore, extras = []) {
77
- const secrets = [...extras];
78
- try {
79
- const settings = settingsStore.read({ allowMissing: true });
80
- for (const profile of settings.ai && Array.isArray(settings.ai.profiles) ? settings.ai.profiles : []) {
81
- for (const [key, value] of Object.entries(profile || {})) {
82
- if (REDACTION_KEY_PATTERN.test(key) && typeof value === 'string') secrets.push(value);
83
- }
84
- }
85
- } catch {
86
- // Logger construction must survive missing/corrupt pre-initialization settings without exposing secrets.
87
- }
88
- return [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length >= 4))];
89
- }
90
-
91
- function normalizeLoggingPatch(input) {
92
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
93
- throw new DomainError('INVALID_ARGUMENT', 'Logging settings must be an object.');
94
- }
95
- const allowed = new Set(['levels']);
96
- const unknown = Object.keys(input).find(key => !allowed.has(key));
97
- if (unknown) throw new DomainError('INVALID_ARGUMENT', `Unknown logging setting: ${unknown}.`, { details: { field: unknown } });
98
- const patch = {};
99
- if (Object.prototype.hasOwnProperty.call(input, 'levels')) {
100
- if (!Array.isArray(input.levels) || !input.levels.length || input.levels.some(level => !LOG_LEVELS.includes(level))) {
101
- throw new DomainError('INVALID_ARGUMENT', 'Logging levels must contain one or more supported levels.');
102
- }
103
- patch.levels = [...new Set(input.levels)];
104
- }
105
- if (!Object.keys(patch).length) throw new DomainError('INVALID_ARGUMENT', 'No mutable logging settings were provided.');
106
- return patch;
107
- }
108
-
109
- function requestOriginAllowed(req, origin, options) {
110
- if (!origin) return true;
111
- if (origin === 'null') return false;
112
- let parsed;
113
- try { parsed = new URL(origin); } catch { return false; }
114
- if (!['http:', 'https:'].includes(parsed.protocol)) return false;
115
- const hostHeader = String(req.headers.host || '').toLowerCase();
116
- if (hostHeader && parsed.host.toLowerCase() === hostHeader) return true;
117
- if (options.allowedOrigins.has(origin)) return true;
118
- if (!options.loopbackBind) return false;
119
- return isLoopback(parsed.hostname) && Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)) === options.port;
120
- }
121
-
122
- function authorizationValid(req, token) {
123
- if (!token) return false;
124
- const bearer = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
125
- const supplied = bearer ? bearer[1] : String(req.headers['x-project-knowledge-token'] || '');
126
- if (!supplied) return false;
127
- const expectedBuffer = Buffer.from(token);
128
- const suppliedBuffer = Buffer.from(supplied);
129
- return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
130
- }
131
-
132
- function applyCors(req, res, security) {
133
- const origin = String(req.headers.origin || '');
134
- res.setHeader('Vary', 'Origin');
135
- if (origin && requestOriginAllowed(req, origin, security)) res.setHeader('Access-Control-Allow-Origin', origin);
136
- }
137
-
138
- function enforceSecurity(req, security) {
139
- const origin = String(req.headers.origin || '');
140
- if (origin && !requestOriginAllowed(req, origin, security)) {
141
- throw new DomainError('ORIGIN_FORBIDDEN', 'Request Origin is not allowed.', { status: 403 });
142
- }
143
- if (!security.loopbackBind && !authorizationValid(req, security.authToken)) {
144
- throw new DomainError('AUTH_REQUIRED', 'Authentication is required for a non-loopback server.', { status: 401 });
145
- }
146
- }
147
-
148
- function send(res, status, body, contentType = '') {
149
- if (res.writableEnded) return;
150
- const isBuffer = Buffer.isBuffer(body);
151
- const isText = typeof body === 'string' || isBuffer;
152
- const payload = isText ? body : JSON.stringify(body);
153
- res.statusCode = status;
154
- res.setHeader('Content-Type', contentType || (isText ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8'));
155
- res.setHeader('Cache-Control', 'no-store');
156
- res.setHeader('X-Content-Type-Options', 'nosniff');
157
- res.setHeader('Referrer-Policy', 'no-referrer');
158
- res.end(payload);
159
- }
160
-
161
- async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
162
- const chunks = [];
163
- let total = 0;
164
- for await (const chunk of req) {
165
- total += chunk.length;
166
- if (total > maxBytes) throw new DomainError('INVALID_ARGUMENT', 'Request body is too large.', { status: 413 });
167
- chunks.push(chunk);
168
- }
169
- if (!chunks.length) return {};
170
- try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
171
- catch (error) { throw new DomainError('INVALID_ARGUMENT', 'Request body must be valid JSON.', { status: 400, cause: error }); }
172
- }
173
-
174
- function profileById(settings, profileId) {
175
- return (settings.ai && Array.isArray(settings.ai.profiles) ? settings.ai.profiles : []).find(profile => profile.id === profileId) || null;
176
- }
177
-
178
- function mergeAiProfiles(current, input) {
179
- if (!input || typeof input !== 'object' || !Array.isArray(input.profiles)) {
180
- throw new DomainError('INVALID_ARGUMENT', 'AI profiles must contain a profiles array.');
181
- }
182
- const existing = new Map((current && Array.isArray(current.profiles) ? current.profiles : []).map(profile => [profile.id, profile]));
183
- const ids = new Set();
184
- const profiles = input.profiles.map(raw => {
185
- if (!raw || typeof raw !== 'object') throw new DomainError('INVALID_ARGUMENT', 'AI profile is invalid.');
186
- const id = String(raw.id || '').trim();
187
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$/.test(id) || ids.has(id)) throw new DomainError('INVALID_ARGUMENT', 'AI profile id is invalid or duplicated.');
188
- ids.add(id);
189
- const prior = existing.get(id) || {};
190
- const update = raw.apiKeyUpdate && typeof raw.apiKeyUpdate === 'object' ? raw.apiKeyUpdate : {};
191
- const mode = String(update.mode || raw.apiKeyMode || (Object.prototype.hasOwnProperty.call(raw, 'apiKey') ? 'replace' : 'preserve'));
192
- if (!['preserve', 'replace', 'clear'].includes(mode)) throw new DomainError('INVALID_ARGUMENT', 'apiKeyUpdate.mode must be preserve, replace, or clear.');
193
- const next = { ...raw, id };
194
- delete next.apiKeyUpdate;
195
- delete next.apiKeyMode;
196
- delete next.hasApiKey;
197
- delete next.apiKeyMasked;
198
- delete next.authToken;
199
- if (mode === 'preserve') {
200
- if (prior.apiKey) next.apiKey = prior.apiKey;
201
- else delete next.apiKey;
202
- } else if (mode === 'clear') {
203
- delete next.apiKey;
204
- } else {
205
- const replacement = String(update.value || raw.apiKey || '');
206
- if (!replacement) throw new DomainError('INVALID_ARGUMENT', 'A replacement API key is required.');
207
- next.apiKey = replacement;
208
- }
209
- return next;
210
- });
211
- const defaultProfileId = input.defaultProfileId == null ? current && current.defaultProfileId || null : input.defaultProfileId;
212
- if (defaultProfileId && !ids.has(defaultProfileId)) throw new DomainError('INVALID_ARGUMENT', 'defaultProfileId must reference a configured profile.');
213
- return { ...current, ...input, schema: input.schema || current && current.schema || 'ai-profiles/v1', profiles, defaultProfileId };
214
- }
215
-
216
- function projectPublicView(projectId, projectStore) {
217
- const config = projectStore.readConfig(projectId);
218
- const state = projectStore.readState(projectId);
219
- return { projectId, config, state };
220
- }
221
-
222
- function listProjectViews(registryStore, projectStore) {
223
- return registryStore.listIds().map(projectId => projectPublicView(projectId, projectStore));
224
- }
225
-
226
- async function inspectGitRepository(repoPath) {
227
- const root = path.resolve(String(repoPath || ''));
228
- if (!repoPath || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
229
- return { repoStatus: 'missing', repoPath: root, headCommit: null, branch: '', dirty: false, error: { code: 'PROJECT_NOT_FOUND', message: 'Repository path is missing.' } };
230
- }
231
- const inside = await execGit(root, ['rev-parse', '--is-inside-work-tree']);
232
- if (!inside.ok) return { repoStatus: 'not-git', repoPath: root, headCommit: null, branch: '', dirty: false, error: { code: 'INVALID_ARGUMENT', message: 'Path is not a Git work tree.' } };
233
- const [top, head, branch, status, commonDir] = await Promise.all([
234
- execGit(root, ['rev-parse', '--show-toplevel']),
235
- execGit(root, ['rev-parse', '--verify', 'HEAD']),
236
- execGit(root, ['branch', '--show-current']),
237
- execGit(root, ['status', '--porcelain=v1', '--untracked-files=normal']),
238
- execGit(root, ['rev-parse', '--git-common-dir']),
239
- ]);
240
- return {
241
- repoStatus: head.ok ? 'ok' : 'empty',
242
- repoPath: top.ok ? path.resolve(top.stdout.trim()) : root,
243
- commonDir: commonDir.ok ? path.resolve(root, commonDir.stdout.trim()) : '',
244
- headCommit: head.ok ? head.stdout.trim() : null,
245
- branch: branch.ok ? branch.stdout.trim() : '',
246
- dirty: status.ok && Boolean(status.stdout.trim()),
247
- changes: status.ok ? status.stdout.split(/\r?\n/).filter(Boolean).slice(0, 200) : [],
248
- error: status.ok ? null : { code: 'INVALID_ARGUMENT', message: 'Git status could not be read.' },
249
- };
250
- }
251
-
252
- function listClaudeSessions(runtime, projectId = '') {
253
- const projectIds = projectId ? [projectId] : runtime.registryStore.listIds();
254
- const sessions = new Map();
255
- for (const id of projectIds) {
256
- for (const session of claudeCliRunner.listSessions({ projectSlug: id })) sessions.set(session.sessionId, session);
257
- }
258
- return [...sessions.values()].sort((left, right) => String(right.startedAt || '').localeCompare(String(left.startedAt || '')));
259
- }
260
-
261
- function isProjectBusy(projectId, projectStore, activeTasks) {
262
- if ((activeTasks.get(projectId)?.size || 0) > 0) return true;
263
- const state = projectStore.readState(projectId);
264
- if (state.analysis.activeClaim) return true;
265
- return claudeCliRunner.listSessions({ projectSlug: projectId }).some(session => ['spawning', 'running', 'pending-permission'].includes(session.state));
266
- }
267
-
268
- class RuntimeIndexAdapter {
269
- constructor(options) {
270
- this.layout = options.layout;
271
- this.settingsStore = options.settingsStore;
272
- this.runtime = null;
273
- }
274
-
275
- getRuntime() {
276
- if (this.runtime) return this.runtime;
277
- const settings = this.settingsStore.read();
278
- const database = new KnowledgeDatabase({
279
- dbPath: this.layout.getIndexPath(),
280
- maintenancePath: this.layout.getRuntimePath('index-maintenance.json'),
281
- });
282
- const embedding = settings.embedding || {};
283
- const embedder = this.createEmbedder(embedding);
284
- this.runtime = { database, indexer: new MarkdownKnowledgeIndexer({ database, embedder }) };
285
- return this.runtime;
286
- }
287
-
288
- createEmbedder(embedding) {
289
- if (process.env.KB_EMBEDDING_FAKE === '1') {
290
- const embed = async text => {
291
- const vector = new Array(require('./knowledge-schema').EMBEDDING_DIMENSIONS).fill(0);
292
- for (let index = 0; index < String(text).length; index += 1) vector[String(text).charCodeAt(index) % vector.length] += 1;
293
- const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
294
- return vector.map(value => value / norm);
295
- };
296
- return { embedPassage: embed, embedQuery: embed };
297
- }
298
- return new LocalEmbeddingService({
299
- modelId: embedding.modelId,
300
- cacheDir: this.layout.getCachePath('models'),
301
- remoteHost: embedding.remoteHost,
302
- localModelPath: embedding.localModelPath,
303
- localFilesOnly: embedding.localFilesOnly === true,
304
- });
305
- }
306
-
307
- async indexProject(input) {
308
- const { indexer } = this.getRuntime();
309
- return indexer.indexDirectory({
310
- kbPath: input.knowledgePath,
311
- spaceId: `project:${input.projectId}`,
312
- sourceProjectId: input.projectId,
313
- sourceCommit: input.sinceCommit || '',
314
- });
315
- }
316
-
317
- createIndexer(targetPath) {
318
- const settings = this.settingsStore.read();
319
- const embedding = settings.embedding || {};
320
- const database = new KnowledgeDatabase({
321
- dbPath: targetPath,
322
- maintenancePath: `${targetPath}.maintenance.json`,
323
- });
324
- const embedder = this.createEmbedder(embedding);
325
- return { database, indexer: new MarkdownKnowledgeIndexer({ database, embedder }) };
326
- }
327
-
328
- async buildFull({ targetPath, projects }) {
329
- const built = this.createIndexer(targetPath);
330
- try {
331
- for (const { projectId, config } of projects) {
332
- await built.indexer.indexDirectory({
333
- kbPath: config.knowledgePath,
334
- spaceId: `project:${projectId}`,
335
- sourceProjectId: projectId,
336
- deferMaintenance: true,
337
- });
338
- }
339
- return { ok: true, projects: projects.length, rows: await built.database.count() };
340
- } finally {
341
- await built.database.close();
342
- try { fs.rmSync(`${targetPath}.maintenance.json`, { force: true }); } catch {
343
- // Maintenance metadata is non-authoritative and is replaced on the next rebuild.
344
- }
345
- }
346
- }
347
-
348
- async validateIndex(targetPath) {
349
- const database = new KnowledgeDatabase({ dbPath: targetPath, maintenancePath: `${targetPath}.maintenance.json` });
350
- try {
351
- await database.open();
352
- return { ok: true, rows: await database.count() };
353
- } catch (error) {
354
- return { ok: false, error: { code: error.code || 'DATA_CORRUPT', message: error.message } };
355
- } finally {
356
- await database.close();
357
- }
358
- }
359
-
360
- async close() {
361
- if (this.runtime) await this.runtime.database.close();
362
- this.runtime = null;
363
- }
364
- }
365
-
366
- class RuntimeKnowledgeAnalyzer {
367
- constructor(options) {
368
- this.settingsStore = options.settingsStore;
369
- this.logger = options.logger;
370
- }
371
-
372
- fakeResult(input) {
373
- const relativePath = `changes/${input.claim.commitSha.slice(0, 12)}.md`;
374
- const outputPath = path.join(input.stagingPath, 'files', ...relativePath.split('/'));
375
- const content = `# Commit ${input.claim.commitSha.slice(0, 12)}\n\nThis knowledge entry was generated by the isolated automation test adapter from commit evidence ${input.claim.commitSha}.\n`;
376
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
377
- fs.writeFileSync(outputPath, content, 'utf8');
378
- fs.writeFileSync(input.manifestPath, `${JSON.stringify({
379
- schema: 'knowledge-staging-manifest/v1',
380
- projectId: input.projectId,
381
- runId: input.claim.runId,
382
- commitSha: input.claim.commitSha,
383
- operations: [{
384
- path: relativePath,
385
- operation: 'create',
386
- sha256: hashBuffer(Buffer.from(content)),
387
- reason: 'Commit evidence was accepted by the isolated test analyzer.',
388
- evidenceReferences: [
389
- `commit:${input.claim.commitSha}`,
390
- `patch:${input.claim.patchHash}`,
391
- input.claim.conversationSnapshotHash && `conversation:${input.claim.conversationSnapshotHash}`,
392
- input.claim.retrievalManifestHash && `retrieval:${input.claim.retrievalManifestHash}`,
393
- ].filter(Boolean),
394
- }],
395
- }, null, 2)}\n`, 'utf8');
396
- return { ok: true, fake: true };
397
- }
398
-
399
- async runClaim(input) {
400
- if (process.env.KB_AUTOMATION_FAKE_CLAUDE === '1') return this.fakeResult(input);
401
- const settings = this.settingsStore.read();
402
- const resolved = resolveEffectiveAiProfile(settings, input.config);
403
- const profile = resolved.profile;
404
- const contract = `\n\nOUTPUT CONTRACT\nRead exact Git evidence only beneath ${input.evidenceRoot}. Write only beneath ${input.stagingPath}. Put Markdown under files/<allowed-path> and 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, sha256, reason, and evidenceReferences containing commit:${input.claim.commitSha}, patch:${input.claim.patchHash}, conversation:${input.claim.conversationSnapshotHash}, and retrieval:${input.claim.retrievalManifestHash}. Do not read or modify source files, final knowledge, other runs, or indexes.`;
405
- const started = claudeCliRunner.startAutomationSession({
406
- slug: input.projectId,
407
- projectPath: input.config.repoPath,
408
- kbPath: input.stagingPath,
409
- userPrompt: `${input.prompt}${contract}`,
410
- aiProfile: profile,
411
- permissionMode: 'acceptEdits',
412
- allowedTools: input.safetyPolicy.allowedTools,
413
- safetyPolicy: input.safetyPolicy,
414
- metadata: { source: 'git-hook', automationRunId: input.claim.runId, commitHash: input.claim.commitSha, projectId: input.projectId },
415
- });
416
- const current = claudeCliRunner.getSession(started.sessionId);
417
- if (current && claudeCliRunner.TERMINAL_STATES.has(current.state)) {
418
- if (current.state !== 'idle') throw new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer did not complete successfully.', { status: 500, retryable: true });
419
- return { ok: true, sessionId: started.sessionId };
420
- }
421
- const timeoutMs = Math.max(10_000, Number(process.env.KB_AUTOMATION_TIMEOUT_MS || 15 * 60 * 1000));
422
- return new Promise((resolve, reject) => {
423
- let timer;
424
- const unsubscribe = claudeCliRunner.onSessionEnded(session => {
425
- if (session.sessionId !== started.sessionId) return;
426
- clearTimeout(timer);
427
- unsubscribe();
428
- if (session.state === 'idle') resolve({ ok: true, sessionId: session.sessionId });
429
- else reject(new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer did not complete successfully.', { status: 500, retryable: true }));
430
- });
431
- timer = setTimeout(() => {
432
- unsubscribe();
433
- try { claudeCliRunner.abort(started.sessionId); } catch {
434
- // Timeout remains the public failure when the child already exited.
435
- }
436
- reject(new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer timed out.', { status: 504, retryable: true }));
437
- }, timeoutMs);
438
- timer.unref?.();
439
- });
440
- }
441
- }
442
-
443
- function createRuntime(options = {}) {
444
- const rootDir = path.resolve(options.rootDir || path.resolve(__dirname, '..', '..'));
445
- const dataPath = path.resolve(options.dataDir || dataDir.getDataDir());
446
- if (options.analysisOwner === 'engine') initializeProductData(dataPath);
447
- const layout = options.layout || new StorageLayout({ dataDir: dataPath });
448
- const settingsStore = options.settingsStore || new SettingsStore({ layout });
449
- const registryStore = options.registryStore || new ProjectRegistryStore({ layout });
450
- const projectStore = options.projectStore || new ProjectStore({ layout });
451
- const secretsProvider = () => configuredSecrets(settingsStore, [
452
- String(options.authToken || process.env.KB_SITE_AUTH_TOKEN || ''),
453
- String(process.env.ANTHROPIC_API_KEY || ''),
454
- String(process.env.ANTHROPIC_AUTH_TOKEN || ''),
455
- ]);
456
- const logger = options.logger || new Logger({
457
- layout,
458
- settingsProvider: () => {
459
- try { return settingsStore.read({ allowMissing: true }).logging; }
460
- catch { return {}; }
461
- },
462
- secretsProvider,
463
- });
464
- claudeCliRunner.setLogger(logger);
465
- const logRepository = options.logRepository || new LogRepository({
466
- layout,
467
- settingsProvider: () => settingsStore.read({ allowMissing: true }).logging,
468
- secretsProvider,
469
- });
470
- const activeTasks = new Map();
471
- const bridgeAdapter = options.bridgeAdapter || new BridgeAdapter({ dataDir: dataPath, ...(options.analysisOwner === 'engine' ? { bridgeHomeDir: process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(dataPath, 'events') } : {}) });
472
- const conversationStore = options.conversationStore || new ConversationStore({ layout, projectStore, logger });
473
- const bridgeConsumerService = options.bridgeConsumerService || new BridgeConsumerService({
474
- bridgeAdapter,
475
- conversationStore,
476
- registryStore,
477
- projectStore,
478
- logger,
479
- });
480
- const conversationQuery = options.conversationQuery || new ConversationQueryService({
481
- layout, registryStore, projectStore, conversationStore, logger,
482
- });
483
- const indexAdapter = options.indexAdapter || new RuntimeIndexAdapter({ layout, settingsStore });
484
- const indexService = options.indexService || new IndexService({ layout, registryStore, projectStore, adapter: indexAdapter, logger });
485
- const analyzer = options.analyzer || new RuntimeKnowledgeAnalyzer({ settingsStore, logger });
486
- const promotionService = options.promotionService || new KnowledgePromotionService({ layout, projectStore, analyzer, indexService, logger });
487
- const requirementRecorder = options.requirementRecorder || new RequirementRecorder({ layout, registryStore, projectStore, conversationStore, logger });
488
- const knowledgeRuntime = options.knowledgeRuntime || new KnowledgeToolRuntime({ layout, settingsStore, registryStore, projectStore, requirementRecorder, logger });
489
- const reconciler = options.reconciler || new CommitReconciler({
490
- layout,
491
- registryStore,
492
- projectStore,
493
- conversationStore,
494
- retrievalService: knowledgeRuntime.retrievalService,
495
- claimProcessor: promotionService,
496
- settingsStore,
497
- logger,
498
- });
499
- const lifecycleService = options.lifecycleService || new ProjectLifecycleService({
500
- layout,
501
- settingsStore,
502
- registryStore,
503
- projectStore,
504
- hookManager,
505
- triggerScriptPath: TRIGGER_SCRIPT_PATH,
506
- logger,
507
- isProjectBusy: projectId => isProjectBusy(projectId, projectStore, activeTasks),
508
- bridgeAdapter,
509
- });
510
- const moduleBridge = options.moduleBridge || new ModuleBridge({ registryStore, projectStore, settingsStore, logger, dataDir: dataPath });
511
- return {
512
- analysisOwner: options.analysisOwner || 'legacy', ownership: { owner: options.analysisOwner || 'legacy', ready: false },
513
- rootDir, dataPath, layout, settingsStore, registryStore, projectStore, logger, logRepository,
514
- activeTasks, bridgeAdapter, bridgeConsumerService, conversationStore, conversationQuery, indexAdapter, indexService, promotionService, reconciler, lifecycleService, requirementRecorder, knowledgeRuntime, moduleBridge,
515
- registryEvents: new (require('./registry-events').RegistryEvents)(),
516
- };
517
- }
518
-
519
- async function migrateManagedHooks(runtime) {
520
- const results = [];
521
- for (const projectId of runtime.registryStore.listIds()) {
522
- const config = runtime.projectStore.readConfig(projectId);
523
- const state = runtime.projectStore.readState(projectId);
524
- if (!fs.existsSync(config.repoPath)) continue;
525
- if (runtime.analysisOwner !== 'legacy') {
526
- const status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
527
- if (status.managedVersion === 2) {
528
- const result = hookManager.installHook({ repoPath: config.repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH, ...runtime.bridgeAdapter.hookBinding() });
529
- results.push({ projectId, ...result }); continue;
530
- }
531
- }
532
- if (state.hook.migrationVersion >= 2) {
533
- results.push({ projectId, ok: true, status: 'verified', reason: 'already-migrated', migrationVersion: state.hook.migrationVersion });
534
- continue;
535
- }
536
- let outcome;
537
- try {
538
- outcome = await reconcileLegacyHookMigration(runtime, projectId, config, state);
539
- } catch (error) {
540
- await runtime.logger.warn('hook.migration_failed', 'Managed Hook migration could not be completed.', { projectId, error });
541
- outcome = { ok: false, status: 'failed', reason: error.code || 'HOOK_INVALID', error: { code: error.code || 'HOOK_INVALID', message: error.message } };
542
- }
543
- if (outcome.status === 'verified') {
544
- await runtime.projectStore.updateState(projectId, draft => {
545
- draft.hook.migrationVersion = 2;
546
- draft.hook.managedVersion = Number(outcome.managedVersion || 2);
547
- draft.hook.lastVerifiedAt = new Date().toISOString();
548
- if (outcome.conflict) draft.hook.lastConflict = outcome.conflict;
549
- else delete draft.hook.lastConflict;
550
- });
551
- results.push({ projectId, ok: true, status: 'verified', managedVersion: outcome.managedVersion, reason: outcome.reason });
552
- } else if (outcome.status === 'conflict') {
553
- await runtime.projectStore.updateState(projectId, draft => {
554
- if (outcome.conflict) draft.hook.lastConflict = outcome.conflict;
555
- });
556
- results.push({ projectId, ok: false, status: 'conflict', reason: outcome.reason, conflict: outcome.conflict });
557
- } else {
558
- // 'failed' or 'pending' — leave migrationVersion at its prior value so the
559
- // next startup can re-attempt. Persist the failure reason so the UI can
560
- // surface it (T04 will read this).
561
- await runtime.projectStore.updateState(projectId, draft => {
562
- draft.hook.lastConflict = outcome.conflict || { code: outcome.reason || 'HOOK_INVALID', message: outcome.error && outcome.error.message || 'Hook migration did not verify.', retryable: true };
563
- });
564
- results.push({ projectId, ok: false, status: outcome.status || 'failed', reason: outcome.reason, error: outcome.error });
565
- }
566
- }
567
- return results;
568
- }
569
-
570
- // One-pass state machine for a single project's legacy hook migration.
571
- // Returns an outcome object — the caller (migrateManagedHooks) is responsible
572
- // for persisting the resulting state. The outcome shape:
573
- // { status: 'verified', managedVersion, reason } — hook is installed + verified
574
- // { status: 'conflict', reason, conflict } — third-party hook present, no overwrite
575
- // { status: 'failed', reason, error } — write/verify failed; no migration
576
- // { status: 'pending', reason } — unknown state; leave migrationVersion alone
577
- async function reconcileLegacyHookMigration(runtime, projectId, config, state) {
578
- const repoPath = config.repoPath;
579
- // Use the public hook-manager surface; we never write to the repo ourselves.
580
- const status = hookManager.readHookStatus({ repoPath, projectId });
581
- // Case 1: already current v2 + verified.
582
- if (status.kbManaged && status.managedVersion === 2 && status.installed) {
583
- return { status: 'verified', managedVersion: status.managedVersion, reason: 'current' };
584
- }
585
- // Case 4: third-party hook. Do not overwrite.
586
- if (!status.kbManaged && status.reason && /third-party/i.test(status.reason)) {
587
- return {
588
- status: 'conflict',
589
- reason: 'third-party',
590
- conflict: { code: 'HOOK_CONFLICT', message: 'A third-party post-commit hook is installed; Project-Knowledge will not overwrite it.', reason: status.reason },
591
- };
592
- }
593
- // Case 5: managed hook exists but its marker belongs to another project.
594
- if (status.kbManaged && status.managedVersion === 2 && !status.installed) {
595
- return {
596
- status: 'conflict',
597
- reason: 'wrong-project',
598
- conflict: { code: 'HOOK_CONFLICT', message: 'A managed hook belonging to another project is installed.', projectId: status.projectId },
599
- };
600
- }
601
- // Case 2 + 3: delegate to migrateManagedHook which handles v1 replacement
602
- // and reports reason=missing for the missing case. Then install + verify
603
- // for the missing path.
604
- let migrateOutcome;
605
- try {
606
- migrateOutcome = hookManager.migrateManagedHook({
607
- repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
608
- ...runtime.bridgeAdapter.hookBinding(),
609
- });
610
- } catch (error) {
611
- if (error && error.code === 'HOOK_CONFLICT') {
612
- return {
613
- status: 'conflict',
614
- reason: 'third-party',
615
- conflict: { code: 'HOOK_CONFLICT', message: error.message, reason: error.details && error.details.reason },
616
- };
617
- }
618
- throw error;
619
- }
620
- if (migrateOutcome.migrated) {
621
- // Case 2: v1 -> v2 succeeded via the legacy replacement path.
622
- const verify = hookManager.readHookStatus({ repoPath, projectId });
623
- if (!verify.kbManaged || verify.managedVersion !== 2 || !verify.installed) {
624
- throw new DomainError('HOOK_INVALID', 'Migrated v1 -> v2 hook did not verify.', { status: 500 });
625
- }
626
- return { status: 'verified', managedVersion: verify.managedVersion, reason: 'v1-migrated' };
627
- }
628
- if (migrateOutcome.reason === 'current') {
629
- // migrateManagedHook returned reason=current but we already filtered that
630
- // path above; this is a defensive fallback.
631
- return { status: 'verified', managedVersion: migrateOutcome.managedVersion, reason: 'current' };
632
- }
633
- if (migrateOutcome.reason === 'missing') {
634
- // Case 3: missing entirely. Install + verify.
635
- const installed = hookManager.installHook({
636
- repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
637
- ...runtime.bridgeAdapter.hookBinding(),
638
- });
639
- if (!installed || installed.ok !== true) {
640
- throw new DomainError('HOOK_INVALID', 'Newly installed hook failed to install.', { status: 500 });
641
- }
642
- const verify = hookManager.readHookStatus({ repoPath, projectId });
643
- if (!verify.kbManaged || verify.managedVersion !== 2 || !verify.installed) {
644
- throw new DomainError('HOOK_INVALID', 'Newly installed hook did not verify.', { status: 500 });
645
- }
646
- return { status: 'verified', managedVersion: verify.managedVersion, reason: 'installed' };
647
- }
648
- // Unknown — leave pending so the next startup re-attempts.
649
- return { status: 'pending', reason: migrateOutcome.reason || 'unknown' };
650
- }
651
-
652
- async function initializeRuntime(runtime) {
653
- await runtime.logger.info('server.startup_started', 'Server startup began with fresh product data.', { phase: 'initialization' });
654
- const migration = { ok: true, migrated: false, reason: 'fresh-data-only' };
655
- dataDir.ensureDataDir(runtime.dataPath);
656
- await runtime.settingsStore.initialize();
657
- await runtime.registryStore.initialize();
658
- const recoveredPromotions = runtime.analysisOwner === 'legacy' ? await runtime.promotionService.recoverAll() : [];
659
- // Promotion recovery runs first because it can legitimately finish and clear
660
- // a claim. Any claim left after that belonged to the previous process. Make
661
- // it terminal without invoking Claude or selecting another Git commit; only
662
- // a future explicit Hook event may start fresh work.
663
- const recoveredClaims = runtime.analysisOwner === 'legacy' ? await recoverOrphanedClaims(runtime) : [];
664
- const recoveredIndexes = runtime.registryStore.listIds().filter(projectId => runtime.projectStore.readState(projectId).index.dirty);
665
- const orphanedOperations = runtime.logRepository.findOrphanedOperations();
666
- if (orphanedOperations.length) {
667
- await runtime.logger.warn('server.orphaned_operations_detected', 'Incomplete operations from a previous process were detected.', {
668
- phase: 'recovery',
669
- context: { operationIds: orphanedOperations.map(entry => entry.operationId), count: orphanedOperations.length },
670
- });
671
- }
672
- const hooks = await migrateManagedHooks(runtime);
673
- await runtime.logger.info('server.startup_ready', 'Server stores and recovery services are ready.', {
674
- phase: 'listen',
675
- context: { migration, recoveredPromotions: recoveredPromotions.length, recoveredClaims: recoveredClaims.length, recoveredIndexes: recoveredIndexes.length, orphanedOperations: orphanedOperations.length, hooks: hooks.length },
676
- });
677
- return { migration, recoveredPromotions, recoveredClaims, recoveredIndexes, orphanedOperations, hooks };
678
- }
679
-
680
- function safeStaticFile(pathname) {
681
- const decoded = decodeURIComponent(pathname === '/' ? '/index.html' : pathname);
682
- const candidate = path.resolve(UI_ROOT, `.${decoded}`);
683
- const relative = path.relative(UI_ROOT, candidate);
684
- if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
685
- return candidate;
686
- }
687
-
688
- function streamSse(res, subscribe) {
689
- res.statusCode = 200;
690
- res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
691
- res.setHeader('Cache-Control', 'no-cache, no-transform');
692
- res.setHeader('Connection', 'keep-alive');
693
- res.flushHeaders?.();
694
- res.write(': connected\n\n');
695
- const write = event => {
696
- if (!res.writableEnded) {
697
- const eventName = String(event && event.type || 'message').replace(/[^A-Za-z0-9._/-]/g, '') || 'message';
698
- res.write(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`);
699
- }
700
- };
701
- const unsubscribe = subscribe(write);
702
- setImmediate(() => { if (!res.writableEnded) res.write(': ready\n\n'); });
703
- const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 20_000);
704
- heartbeat.unref?.();
705
- res.on('close', () => { clearInterval(heartbeat); unsubscribe?.(); });
706
- }
707
-
708
- function logFiltersFromUrl(url) {
709
- return {
710
- from: url.searchParams.get('from') || '',
711
- to: url.searchParams.get('to') || '',
712
- levels: url.searchParams.get('levels') || '',
713
- scope: url.searchParams.get('scope') || '',
714
- projectId: url.searchParams.get('projectId') || '',
715
- component: url.searchParams.get('component') || '',
716
- event: url.searchParams.get('event') || '',
717
- commitSha: url.searchParams.get('commitSha') || '',
718
- operationId: url.searchParams.get('operationId') || '',
719
- q: url.searchParams.get('q') || '',
720
- };
721
- }
722
-
723
- function encodeLogStreamCursor(entry, snapshotAt = new Date().toISOString()) {
724
- return Buffer.from(JSON.stringify({ v: 1, afterId: entry && entry.id || '', afterTs: entry && entry.ts || snapshotAt }), 'utf8').toString('base64url');
725
- }
726
-
727
- function decodeLogStreamCursor(value) {
728
- if (!value) return { v: 1, afterId: '', afterTs: new Date().toISOString() };
729
- try {
730
- const decoded = JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8'));
731
- if (!decoded || decoded.v !== 1 || typeof decoded.afterId !== 'string' || Number.isNaN(Date.parse(decoded.afterTs))) throw new Error('invalid');
732
- return decoded;
733
- } catch (error) {
734
- throw new DomainError('LOG_CURSOR_EXPIRED', 'Log stream cursor is invalid; reload the snapshot.', { status: 409, retryable: true, cause: error });
735
- }
736
- }
737
-
738
- function writeSseEvent(res, event) {
739
- if (res.writableEnded) return;
740
- const eventName = String(event && event.type || 'message').replace(/[^A-Za-z0-9._/-]/g, '') || 'message';
741
- res.write(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`);
742
- }
743
-
744
- function streamLogsWithoutGap(res, runtime, filters, streamCursor) {
745
- const watermark = decodeLogStreamCursor(streamCursor);
746
- const buffered = [];
747
- let replaying = true;
748
- const unsubscribe = runtime.logger.subscribe(record => {
749
- if (!runtime.logRepository.matches(record, filters)) return;
750
- if (replaying) buffered.push(record);
751
- else writeSseEvent(res, { type: 'logs/appended', record });
752
- });
753
- const catchup = [];
754
- let cursor = '';
755
- let found = !watermark.afterId;
756
- try {
757
- outer: do {
758
- const page = runtime.logRepository.query({ ...filters, cursor, pageSize: 5000 });
759
- for (const entry of page.entries) {
760
- if (watermark.afterId && entry.id === watermark.afterId) { found = true; break outer; }
761
- if (!watermark.afterId && String(entry.ts) <= watermark.afterTs) { found = true; break outer; }
762
- catchup.push(entry);
763
- }
764
- cursor = page.nextCursor || '';
765
- } while (cursor);
766
- if (!found) throw new DomainError('LOG_CURSOR_EXPIRED', 'Log stream cursor source is no longer available; reload the snapshot.', { status: 409, retryable: true });
767
- } catch (error) {
768
- unsubscribe();
769
- throw error;
770
- }
771
-
772
- res.statusCode = 200;
773
- res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
774
- res.setHeader('Cache-Control', 'no-cache, no-transform');
775
- res.setHeader('Connection', 'keep-alive');
776
- res.flushHeaders?.();
777
- res.write(': connected\n\n');
778
- const seen = new Set();
779
- const latestCatchup = catchup[0] || null;
780
- for (const record of catchup.reverse()) {
781
- seen.add(record.id);
782
- writeSseEvent(res, { type: 'logs/appended', record, replay: true });
783
- }
784
- replaying = false;
785
- for (const record of buffered) {
786
- if (seen.has(record.id)) continue;
787
- seen.add(record.id);
788
- writeSseEvent(res, { type: 'logs/appended', record });
789
- }
790
- writeSseEvent(res, { type: 'logs/ready', streamCursor: encodeLogStreamCursor(buffered.at(-1) || latestCatchup, watermark.afterTs) });
791
- const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 20_000);
792
- heartbeat.unref?.();
793
- res.on('close', () => { clearInterval(heartbeat); unsubscribe(); });
794
- }
795
-
796
- function taskForProject(runtime, projectId, operationId, task, kind = 'background') {
797
- let projectTasks = runtime.activeTasks.get(projectId);
798
- if (!projectTasks) {
799
- projectTasks = new Map();
800
- runtime.activeTasks.set(projectId, projectTasks);
801
- }
802
- if (projectTasks.has(operationId)) throw new DomainError('INVALID_ARGUMENT', 'Background operationId is already registered.');
803
- const startedAt = new Date().toISOString();
804
- const work = Promise.resolve().then(task);
805
- const promise = (async () => {
806
- await runtime.logger.debug('background.operation_registered', 'Background operation registered.', {
807
- projectId,
808
- operationId,
809
- phase: 'registered',
810
- context: { kind, startedAt },
811
- });
812
- try {
813
- const result = await work;
814
- await runtime.logger.debug('background.operation_completed', 'Background operation completed.', {
815
- projectId,
816
- operationId,
817
- phase: 'completed',
818
- durationMs: Date.now() - Date.parse(startedAt),
819
- context: { kind },
820
- });
821
- return result;
822
- } catch (error) {
823
- await runtime.logger.error('background.operation_failed', 'A background operation failed.', {
824
- projectId,
825
- operationId,
826
- phase: 'failed',
827
- durationMs: Date.now() - Date.parse(startedAt),
828
- error,
829
- context: { kind },
830
- });
831
- throw error;
832
- } finally {
833
- const current = runtime.activeTasks.get(projectId);
834
- current?.delete(operationId);
835
- if (current && current.size === 0) runtime.activeTasks.delete(projectId);
836
- }
837
- })();
838
- projectTasks.set(operationId, { operationId, kind, startedAt, promise });
839
- return promise;
840
- }
841
-
842
- function activeTaskPromises(activeTasks) {
843
- const promises = [];
844
- for (const projectTasks of activeTasks.values()) {
845
- for (const entry of projectTasks.values()) promises.push(entry.promise);
846
- }
847
- return promises;
848
- }
849
-
850
- function createRequestHandler(runtime, options = {}) {
851
- const port = parsePort(options.port || process.env.KB_SITE_PORT);
852
- const host = String(options.host || process.env.KB_SITE_HOST || '127.0.0.1');
853
- const security = {
854
- port,
855
- loopbackBind: isLoopback(host),
856
- authToken: String(options.authToken || process.env.KB_SITE_AUTH_TOKEN || ''),
857
- allowedOrigins: parseAllowedOrigins(options.allowedOrigins || process.env.KB_ALLOWED_ORIGINS),
858
- };
859
- if (!security.loopbackBind && !security.authToken) {
860
- throw new DomainError('AUTH_REQUIRED', 'KB_SITE_AUTH_TOKEN is required for a non-loopback bind.', { status: 500 });
861
- }
862
-
863
- return async function handle(req, res) {
864
- const requestOperationId = createId('op');
865
- const requestStartedAt = Date.now();
866
- const requestPathname = String(req.url || '').split('?')[0];
867
- let requestFailed = false;
868
- res.setHeader('X-Operation-Id', requestOperationId);
869
- res.once('finish', () => {
870
- if (requestFailed || res.statusCode >= 400) return;
871
- if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)
872
- && /^\/api\/(?:projects(?:\/|$)|domains(?:\/|$)|brain$|settings$|modules\/configure$)/.test(requestPathname)
873
- && requestPathname !== '/api/projects/preflight-import') runtime.registryEvents?.publish();
874
- Promise.resolve(runtime.logger.info('http.request_completed', 'HTTP request completed.', {
875
- operationId: requestOperationId,
876
- phase: 'completed',
877
- durationMs: Date.now() - requestStartedAt,
878
- context: { method: req.method, pathname: requestPathname, status: res.statusCode },
879
- })).catch(() => {});
880
- });
881
- applyCors(req, res, security);
882
- try {
883
- await runtime.logger.debug('http.request_started', 'HTTP request started.', {
884
- operationId: requestOperationId,
885
- phase: 'started',
886
- context: { method: req.method, pathname: requestPathname },
887
- });
888
- enforceSecurity(req, security);
889
- if (req.method === 'OPTIONS') {
890
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS');
891
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Project-Knowledge-Token');
892
- return send(res, 204, '');
893
- }
894
- const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
895
- const method = req.method || 'GET';
896
- const pathname = url.pathname;
897
- if (method === 'GET' && pathname === '/api/registry/events') {
898
- return streamSse(res, write => runtime.registryEvents.subscribe(write));
899
- }
900
-
901
- const moduleUi = pathname.match(/^\/modules\/(terminal|vectorhub|eventbridge|engine)\/(.*)$/);
902
- if (moduleUi && method === 'GET') return runtime.moduleBridge.proxyModuleUi(req, res, moduleUi[1], moduleUi[2], url.search);
903
- if (pathname.startsWith('/api/engine/') || pathname === '/api/engine') {
904
- if (['/api/engine/runtime-config', '/api/engine/runtime-auth', '/api/engine/scopes', '/api/engine/control', '/api/engine/migrate'].includes(pathname)) throw new DomainError('FORBIDDEN', 'Runtime configuration is not exposed to browsers.', { status: 403 });
905
- return runtime.moduleBridge.proxyEngine(req, res, pathname, url.search);
906
- }
907
-
908
- // ===== module bridge:壳 ↔ 模块(同源代理 + 聚合 + 登记/移除) =====
909
- if (pathname.startsWith('/api/terminal/') || pathname === '/api/terminal') {
910
- if (pathname.startsWith('/api/terminal/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
911
- return runtime.moduleBridge.proxyTerminal(req, res, pathname, url.search);
912
- }
913
- if (pathname.startsWith('/api/vectorhub/') || pathname === '/api/vectorhub') {
914
- if (pathname.startsWith('/api/vectorhub/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
915
- return runtime.moduleBridge.proxyVectorHub(req, res, pathname, url.search);
916
- }
917
- if (pathname.startsWith('/api/eventbridge/') || pathname === '/api/eventbridge') {
918
- if (pathname.startsWith('/api/eventbridge/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
919
- return runtime.moduleBridge.proxyEventBridge(req, res, pathname, url.search);
920
- }
921
- if (method === 'GET' && pathname === '/api/modules/health') {
922
- return send(res, 200, { ...await runtime.moduleBridge.modulesHealth(), ownership: runtime.ownership });
923
- }
924
- if (method === 'POST' && pathname === '/api/modules/configure') {
925
- await readJsonBody(req);
926
- runtime.moduleBridge.resetRuntimeConfiguration();
927
- runtime.projectConnections?.clear();
928
- await connectEngine(runtime);
929
- return send(res, 200, { ok: true, configuration: await runtime.moduleBridge.syncEngineConfiguration({ force: true }) });
930
- }
931
- if (method === 'GET' && pathname === '/api/brain') {
932
- const settings = runtime.settingsStore.read();
933
- return send(res, 200, { ok: true, brain: settings.brain, knowledgeRoot: settings.knowledge.rootPath, indexStoragePath: runtime.layout.getVectorRootPath(settings), configuration: runtime.moduleBridge.configStatus || null });
934
- }
935
- if (method === 'PATCH' && pathname === '/api/brain') {
936
- const body = await readJsonBody(req);
937
- if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new DomainError('INVALID_ARGUMENT', '请输入 1–100 个字符的智脑名称。');
938
- const settings = await runtime.settingsStore.updatePatch({ brain: { name: body.name.trim() } });
939
- const configuration = await runtime.moduleBridge.syncEngineConfiguration();
940
- return send(res, 200, { ok: true, brain: settings.brain, configuration });
941
- }
942
- if (method === 'POST' && pathname === '/api/domains') {
943
- const body = await readJsonBody(req);
944
- if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new DomainError('INVALID_ARGUMENT', '请输入 1–100 个字符的领域名称。');
945
- const domain = { id: `domain-${crypto.randomUUID()}`, name: body.name.trim(), createdAt: new Date().toISOString() };
946
- await runtime.settingsStore.addDomain(domain);
947
- const configuration = await runtime.moduleBridge.syncEngineConfiguration();
948
- return send(res, 201, { ok: true, domain, configuration });
949
- }
950
- if (method === 'GET' && pathname === '/api/projects/aggregated') {
951
- return send(res, 200, { ok: true, ...(await runtime.moduleBridge.aggregatedProjects()) });
952
- }
953
- const moduleRegisterMatch = pathname.match(/^\/api\/projects\/([^/]+)\/module-register$/);
954
- if (moduleRegisterMatch && method === 'POST') {
955
- const projectId = validateProjectId(decodeURIComponent(moduleRegisterMatch[1]));
956
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
957
- const config = runtime.projectStore.readConfig(projectId) || {};
958
- const body = await readJsonBody(req).catch(() => ({}));
959
- const result = await runtime.moduleBridge.registerProjectLinks({
960
- projectId,
961
- name: config.displayName || projectId,
962
- workspacePath: String(body.workspacePath || config.repoPath || ''),
963
- knowledgePath: String(body.knowledgePath || config.knowledgePath || ''),
964
- });
965
- return send(res, 200, { ok: true, projectId, modules: result });
966
- }
967
- const moduleRemoveMatch = pathname.match(/^\/api\/projects\/([^/]+)\/module-remove$/);
968
- if (moduleRemoveMatch && method === 'POST') {
969
- const projectId = validateProjectId(decodeURIComponent(moduleRemoveMatch[1]));
970
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
971
- const result = await runtime.moduleBridge.removeProjectLinks({ projectId });
972
- return send(res, 200, { ok: true, projectId, modules: result });
973
- }
974
-
975
- if (method === 'GET' && pathname === '/api/health') {
976
- return send(res, 200, { ok: true, schema: 'server-health/v2', logger: runtime.logger.getHealth(), projects: runtime.registryStore.listIds().length });
977
- }
978
- if (method === 'GET' && pathname === '/api/runtime/identity') {
979
- if (!isLoopback(req.socket?.remoteAddress?.replace(/^::ffff:/, ''))) throw new DomainError('ORIGIN_FORBIDDEN', 'Runtime identity is local-only.', { status: 403 });
980
- return send(res, 200, runtime.instanceIdentity);
981
- }
982
- if (method === 'GET' && pathname === '/api/state') {
983
- const settings = runtime.settingsStore.readPublicView();
984
- return send(res, 200, { ok: true, schema: 'server-state/v2', settings, projects: listProjectViews(runtime.registryStore, runtime.projectStore), logger: runtime.logger.getHealth() });
985
- }
986
- if (method === 'GET' && pathname === '/api/settings') return send(res, 200, { ok: true, settings: runtime.settingsStore.readPublicView() });
987
- if (method === 'PATCH' && pathname === '/api/settings') {
988
- const body = await readJsonBody(req);
989
- const patch = {};
990
- if (body.knowledge) patch.knowledge = { rootPath: String(body.knowledge.rootPath || '') };
991
- if (body.logging) throw new DomainError('IMMUTABLE_FIELD', 'Logging storage and capture policy are not user settings.', { status: 409 });
992
- if (!Object.keys(patch).length) throw new DomainError('INVALID_ARGUMENT', 'Only the knowledge root is mutable on this route.');
993
- const settings = await runtime.settingsStore.updatePatch(patch);
994
- const configuration = await runtime.moduleBridge.syncEngineConfiguration();
995
- return send(res, 200, { ok: true, settings: { ...settings, ai: publicAiProfilesConfig(settings.ai) }, configuration });
996
- }
997
-
998
- if (method === 'GET' && pathname === '/api/projects/projection') return send(res, 200, { ok: true, projects: listProjectViews(runtime.registryStore, runtime.projectStore).map(p => ({ projectId: p.projectId, name: p.config.displayName, workspacePath: p.config.repoPath, knowledgePath: p.config.knowledgePath, brainId: p.config.brainId, domainId: p.config.domainId, modules: Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => { const module = p.state.modules?.[key] || {}; return [key, { projectId: module.projectId, projectName: module.projectName, status: module.status, registered: module.ok === true && module.status === 'registered' }]; })) })) });
999
- if (method === 'GET' && pathname === '/api/projects') return send(res, 200, { ok: true, projects: listProjectViews(runtime.registryStore, runtime.projectStore) });
1000
- // T06: pure preflight endpoint — never mutates state. The UI calls
1001
- // this on path pick / language change / profile change to keep the
1002
- // "Import" button disabled until every prerequisite is satisfied.
1003
- if (method === 'POST' && pathname === '/api/projects/preflight-import') {
1004
- const body = await readJsonBody(req);
1005
- const result = await runtime.lifecycleService.preflightImport(body);
1006
- return send(res, 200, { ok: true, ...result });
1007
- }
1008
- if (method === 'POST' && pathname === '/api/projects/import') {
1009
- const body = await readJsonBody(req);
1010
- const result = await runtime.lifecycleService.importProject(body);
1011
- await runtime.projectStore.updateState(result.projectId, state => {
1012
- state.hook.managedVersion = Number(result.hook.managedVersion || 2);
1013
- state.hook.migrationVersion = 2;
1014
- state.hook.lastVerifiedAt = new Date().toISOString();
1015
- });
1016
- // T3.2 导入联动:双地址登记到两模块(尽力而为,模块未启动记 pending)
1017
- const config = runtime.projectStore.readConfig(result.projectId) || {};
1018
- const moduleResult = await runtime.moduleBridge.registerProjectLinks({
1019
- projectId: result.projectId,
1020
- name: config.displayName || result.projectId,
1021
- workspacePath: String(config.repoPath || body.path || ''),
1022
- knowledgePath: String(body.knowledgePath || config.knowledgePath || ''),
1023
- });
1024
- void connectEngine(runtime);
1025
- return send(res, 201, { ...result, modules: moduleResult, project: projectPublicView(result.projectId, runtime.projectStore) });
1026
- }
1027
- const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
1028
- if (projectMatch && method === 'GET') {
1029
- const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1030
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1031
- return send(res, 200, { ok: true, project: projectPublicView(projectId, runtime.projectStore) });
1032
- }
1033
- if (projectMatch && method === 'PATCH') {
1034
- const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1035
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1036
- const patch = await readJsonBody(req);
1037
- if (Object.prototype.hasOwnProperty.call(patch, 'displayName') && (typeof patch.displayName !== 'string' || !patch.displayName.trim() || patch.displayName.length > 200)) {
1038
- throw new DomainError('INVALID_ARGUMENT', 'displayName must be a non-empty string of at most 200 characters.');
1039
- }
1040
- if (Object.prototype.hasOwnProperty.call(patch, 'enabled') && typeof patch.enabled !== 'boolean') {
1041
- throw new DomainError('INVALID_ARGUMENT', 'enabled must be a boolean.');
1042
- }
1043
- if (Object.prototype.hasOwnProperty.call(patch, 'knowledgeLanguage') && !['zh-CN', 'en-US'].includes(patch.knowledgeLanguage)) {
1044
- throw new DomainError('INVALID_ARGUMENT', 'knowledgeLanguage must be zh-CN or en-US.');
1045
- }
1046
- if (Object.prototype.hasOwnProperty.call(patch, 'relatedProjectIds')) {
1047
- if (!Array.isArray(patch.relatedProjectIds)) throw new DomainError('INVALID_ARGUMENT', 'relatedProjectIds must be an array.');
1048
- patch.relatedProjectIds = [...new Set(patch.relatedProjectIds.map(validateProjectId))];
1049
- if (patch.relatedProjectIds.includes(projectId)) throw new DomainError('INVALID_ARGUMENT', 'A project cannot relate to itself.');
1050
- for (const relatedId of patch.relatedProjectIds) {
1051
- if (!runtime.registryStore.readDisplaySnapshot(relatedId)) throw new DomainError('PROJECT_NOT_FOUND', 'A related project was not found.', { status: 404, details: { projectId: relatedId } });
1052
- }
1053
- }
1054
- if (patch.aiProfileId) {
1055
- const profile = profileById(runtime.settingsStore.read(), patch.aiProfileId);
1056
- if (!profile || profile.enabled === false) throw new DomainError('INVALID_ARGUMENT', 'aiProfileId must reference an enabled profile.');
1057
- }
1058
- if (patch.brainId && patch.brainId !== runtime.settingsStore.read().brain.id) throw new DomainError('INVALID_ARGUMENT', '智脑不存在。');
1059
- if (patch.domainId && !runtime.settingsStore.read().brain.domains.some(d => d.id === patch.domainId)) throw new DomainError('INVALID_ARGUMENT', '领域不存在。');
1060
- const config = await runtime.projectStore.updateConfig(projectId, patch);
1061
- let modules;
1062
- if (['domainId', 'knowledgeLanguage'].some(field => Object.prototype.hasOwnProperty.call(patch, field))) modules = await runtime.moduleBridge.registerProjectLinks({ projectId, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
1063
- return send(res, 200, { ok: true, projectId, config, modules });
1064
- }
1065
- if (projectMatch && method === 'DELETE') {
1066
- const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1067
- if (!runtime.registryStore.readDisplaySnapshot(projectId) && !runtime.lifecycleService.hasPendingDeletion(projectId)) {
1068
- throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1069
- }
1070
- const body = await readJsonBody(req);
1071
- const remove = () => runtime.lifecycleService.deleteProject(projectId, {
1072
- deleteKnowledge: body.deleteKnowledge === true,
1073
- confirmationToken: body.confirmationToken || '',
1074
- });
1075
- let result;
1076
- if (runtime.registryStore.readDisplaySnapshot(projectId)) await runtime.moduleBridge.removeProjectLinks({ projectId, requireEngine: runtime.analysisOwner !== 'legacy', onRemoved: async () => { result = await remove(); } });
1077
- else result = await remove();
1078
- return send(res, 200, result);
1079
- }
1080
- const gitStatusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/git-status$/);
1081
- if (gitStatusMatch && method === 'GET') {
1082
- const projectId = validateProjectId(decodeURIComponent(gitStatusMatch[1]));
1083
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1084
- const config = runtime.projectStore.readConfig(projectId);
1085
- return send(res, 200, { ok: true, projectId, ...(await inspectGitRepository(config.repoPath)) });
1086
- }
1087
- // T04: per-project hook health snapshot. Lets the UI explain exactly why
1088
- // commit auto-update is unavailable (missing / conflict / wrong-project /
1089
- // broken-managed) without forcing the user to inspect .git/hooks manually.
1090
- const hookStatusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/hook-status$/);
1091
- if (hookStatusMatch && method === 'GET') {
1092
- const projectId = validateProjectId(decodeURIComponent(hookStatusMatch[1]));
1093
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1094
- const config = runtime.projectStore.readConfig(projectId);
1095
- const state = runtime.projectStore.readState(projectId);
1096
- let status;
1097
- try {
1098
- status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1099
- } catch (error) {
1100
- status = { ok: true, installed: false, repoPath: config.repoPath, reason: error.message };
1101
- }
1102
- const repairable = fs.existsSync(config.repoPath)
1103
- && !(status.installed && status.kbManaged && status.managedVersion === 2)
1104
- && !(!status.kbManaged && status.reason && /third-party/i.test(status.reason || ''))
1105
- && !(status.kbManaged && !status.installed);
1106
- return send(res, 200, {
1107
- ok: true,
1108
- projectId,
1109
- hook: {
1110
- repoPath: status.repoPath,
1111
- hookPath: status.hookPath || null,
1112
- installed: Boolean(status.installed),
1113
- managed: Boolean(status.kbManaged),
1114
- managedVersion: status.managedVersion || 0,
1115
- runtimeTarget: 'node', // T02 contract: hook body uses ELECTRON_RUN_AS_NODE=1 prefix
1116
- lastVerifiedAt: state.hook && state.hook.lastVerifiedAt || null,
1117
- migrationVersion: state.hook && state.hook.migrationVersion || 0,
1118
- conflict: state.hook && state.hook.lastConflict || null,
1119
- reason: status.reason || null,
1120
- repairAvailable: Boolean(repairable),
1121
- },
1122
- });
1123
- }
1124
- // T04: explicit repair action. Only repairs missing/broken managed hooks;
1125
- // never overwrites a third-party hook (that requires a separate UX decision).
1126
- const hookRepairMatch = pathname.match(/^\/api\/projects\/([^/]+)\/hook-repair$/);
1127
- if (hookRepairMatch && method === 'POST') {
1128
- const projectId = validateProjectId(decodeURIComponent(hookRepairMatch[1]));
1129
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1130
- const config = runtime.projectStore.readConfig(projectId);
1131
- if (!fs.existsSync(config.repoPath)) {
1132
- throw new DomainError('PROJECT_NOT_FOUND', 'Project repository path is missing on disk.', { status: 404 });
1133
- }
1134
- const status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1135
- if (!status.kbManaged && status.reason && /third-party/i.test(status.reason || '')) {
1136
- throw new DomainError('HOOK_CONFLICT', 'A third-party post-commit hook is installed; Project-Knowledge will not overwrite it.', {
1137
- status: 409,
1138
- details: { reason: status.reason },
1139
- });
1140
- }
1141
- const installResult = hookManager.installHook({
1142
- repoPath: config.repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
1143
- ...runtime.bridgeAdapter.hookBinding(),
1144
- });
1145
- const verify = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1146
- await runtime.projectStore.updateState(projectId, draft => {
1147
- draft.hook.migrationVersion = 2;
1148
- draft.hook.managedVersion = verify.managedVersion || 2;
1149
- draft.hook.lastVerifiedAt = new Date().toISOString();
1150
- delete draft.hook.lastConflict;
1151
- });
1152
- await runtime.logger.info('hook.repair_completed', 'Hook repair succeeded.', { projectId, phase: 'verify', managedVersion: verify.managedVersion });
1153
- return send(res, 200, {
1154
- ok: true,
1155
- projectId,
1156
- installed: verify.kbManaged && verify.installed,
1157
- managedVersion: verify.managedVersion,
1158
- hookPath: verify.hookPath,
1159
- });
1160
- }
1161
- // T13: Project Goal editor — read / write GOAL.md atomically.
1162
- // GET /api/projects/:projectId/goal -> { content, exists }
1163
- // PUT /api/projects/:projectId/goal -> { ok } body: { content }
1164
- // Atomic write guarantees the file is never partially written, and
1165
- // only the project's own knowledgePath is touched — no cross-project
1166
- // side effects. Other knowledge files in the same project are
1167
- // untouched; the goal file is a single dedicated file.
1168
- const goalMatch = pathname.match(/^\/api\/projects\/([^/]+)\/goal$/);
1169
- if (goalMatch) {
1170
- const projectId = validateProjectId(decodeURIComponent(goalMatch[1]));
1171
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1172
- const config = runtime.projectStore.readConfig(projectId);
1173
- const goalPath = path.join(config.knowledgePath, 'GOAL.md');
1174
- if (method === 'GET') {
1175
- if (!fs.existsSync(goalPath)) {
1176
- return send(res, 200, { ok: true, projectId, exists: false, content: '' });
1177
- }
1178
- return send(res, 200, { ok: true, projectId, exists: true, content: fs.readFileSync(goalPath, 'utf8') });
1179
- }
1180
- if (method === 'PUT') {
1181
- if (runtime.analysisOwner !== 'legacy') throw new DomainError('KNOWLEDGE_WRITE_MOVED', '知识写入由引擎维护,外壳不再直接修改知识文件。', { status: 409 });
1182
- const body = await readJsonBody(req);
1183
- if (typeof body.content !== 'string') throw new DomainError('INVALID_ARGUMENT', 'content must be a string.');
1184
- if (body.content.length > 256 * 1024) throw new DomainError('INVALID_ARGUMENT', 'content exceeds 256KB limit.', { status: 413 });
1185
- const AtomicFile = require('./atomic-file');
1186
- AtomicFile.writeFileAtomic(goalPath, body.content, { mode: 0o644 });
1187
- await runtime.logger.info('goal.updated', 'Project GOAL.md updated.', { projectId, phase: 'goal', context: { bytes: body.content.length } });
1188
- return send(res, 200, { ok: true, projectId, exists: true, path: goalPath });
1189
- }
1190
- }
1191
- const contextPackMatch = pathname.match(/^\/api\/projects\/([^/]+)\/context-pack$/);
1192
- if (contextPackMatch && method === 'POST') {
1193
- const projectId = validateProjectId(decodeURIComponent(contextPackMatch[1]));
1194
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1195
- const body = await readJsonBody(req);
1196
- const config = runtime.projectStore.readConfig(projectId);
1197
- const trigger = body.trigger === 'commits' ? 'commits' : 'initial';
1198
- const commits = Array.isArray(body.commits) ? body.commits.slice(0, 200).map(commit => ({
1199
- hash: String(commit.hash || ''), short: String(commit.short || ''), subject: String(commit.subject || ''),
1200
- date: String(commit.date || ''), author: String(commit.author || ''),
1201
- })).filter(commit => /^[a-f0-9]{7,64}$/i.test(commit.hash)) : [];
1202
- const pack = await buildContextPack({
1203
- project: {
1204
- slug: projectId, gitPath: config.repoPath, kbPath: config.knowledgePath,
1205
- currentBranch: body.currentBranch || '', defaultBranch: body.defaultBranch || '', remoteUrl: body.remoteUrl || '',
1206
- },
1207
- runId: body.runId,
1208
- trigger,
1209
- commits,
1210
- options: { maxFiles: body.maxFiles, outputDir: runtime.layout.getCachePath('context-packs', projectId) },
1211
- });
1212
- return send(res, 200, { ok: true, projectId, runId: pack.runId, entryCount: pack.entries.length, contextPack: pack });
1213
- }
1214
-
1215
- if (method === 'POST' && (pathname === '/api/knowledge/search' || pathname === '/api/knowledge/ask')) {
1216
- if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, pathname.replace('/api/knowledge/', '/api/engine/'), url.search);
1217
- const body = await readJsonBody(req);
1218
- const input = { projectId: body.projectId, projectSlug: body.projectSlug, repoPath: body.repoPath, query: body.query, limit: body.limit };
1219
- const result = pathname.endsWith('/ask') ? await runtime.knowledgeRuntime.ask(input) : await runtime.knowledgeRuntime.search(input);
1220
- return send(res, 200, result);
1221
- }
1222
- if (method === 'GET' && pathname === '/api/knowledge/entry') {
1223
- if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/get', url.search);
1224
- const result = await runtime.knowledgeRuntime.get({
1225
- projectId: url.searchParams.get('projectId') || '',
1226
- projectSlug: url.searchParams.get('projectSlug') || '',
1227
- repoPath: url.searchParams.get('repoPath') || '',
1228
- entryId: url.searchParams.get('entryId') || '',
1229
- });
1230
- return send(res, 200, result);
1231
- }
1232
- if (method === 'GET' && pathname === '/api/knowledge/history') {
1233
- if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/history', url.search);
1234
- const result = await runtime.knowledgeRuntime.history({
1235
- projectId: url.searchParams.get('projectId') || '',
1236
- projectSlug: url.searchParams.get('projectSlug') || '',
1237
- repoPath: url.searchParams.get('repoPath') || '',
1238
- limit: url.searchParams.get('limit') || undefined,
1239
- });
1240
- return send(res, 200, result);
1241
- }
1242
- if (method === 'GET' && pathname === '/api/knowledge/maintenance') {
1243
- if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/health', url.search);
1244
- const projects = runtime.registryStore.listIds().map(projectId => ({ projectId, index: runtime.projectStore.readState(projectId).index }));
1245
- return send(res, 200, { ok: true, schema: 'index-maintenance/v2', indexPath: runtime.layout.getIndexPath(), projects, active: runtime.indexService.pending.size > 0 });
1246
- }
1247
- if (method === 'POST' && pathname === '/api/knowledge/maintenance/rebuild') {
1248
- if (runtime.analysisOwner !== 'legacy') throw new DomainError('INVALID_ARGUMENT', '索引由向量模块管理,请在系统状态中修复。', { status: 409 });
1249
- const operationId = createId('op');
1250
- await runtime.knowledgeRuntime.close();
1251
- await runtime.indexAdapter.close();
1252
- const result = await runtime.indexService.fullRebuild({ operationId });
1253
- for (const projectId of runtime.registryStore.listIds()) {
1254
- await runtime.projectStore.updateState(projectId, state => {
1255
- state.index.dirty = false;
1256
- state.index.sinceCommit = null;
1257
- state.index.lastError = null;
1258
- state.index.lastIndexedAt = new Date().toISOString();
1259
- });
1260
- }
1261
- await runtime.logger.info('index.full_rebuild_completed', 'Derived knowledge index was rebuilt.', { operationId, phase: 'index.applied', context: { validation: result.validation } });
1262
- return send(res, 200, result);
1263
- }
1264
-
1265
- if (method === 'POST' && pathname === '/api/hooks/post-commit') {
1266
- const event = await readJsonBody(req, 256 * 1024);
1267
- if (event.schema !== SCHEMAS.hookEvent) throw new DomainError('SCHEMA_UNSUPPORTED', 'Hook event must use hook-event/v2.', { status: 409 });
1268
- const projectId = validateProjectId(event.projectId);
1269
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Hook project was not found.', { status: 404 });
1270
- if (typeof event.repoRoot !== 'string' || !event.repoRoot.trim()) throw new DomainError('INVALID_ARGUMENT', 'Hook event repoRoot is required.');
1271
- const operationId = /^op-[A-Za-z0-9-]{8,120}$/.test(String(event.operationId || ''))
1272
- ? event.operationId
1273
- : requestOperationId;
1274
- if (runtime.analysisOwner !== 'legacy') {
1275
- const journal = await forwardCommitToBridge(runtime, event);
1276
- return send(res, 202, { ok: true, accepted: true, projectId, operationId, ...journal });
1277
- }
1278
- taskForProject(runtime, projectId, operationId, () => handlePostCommitEvent(event, {
1279
- layout: runtime.layout,
1280
- registryStore: runtime.registryStore,
1281
- projectStore: runtime.projectStore,
1282
- reconciler: runtime.reconciler,
1283
- claimProcessor: runtime.promotionService,
1284
- logger: runtime.logger,
1285
- operationId,
1286
- conversationStore: runtime.conversationStore,
1287
- bridgeConsumerService: runtime.bridgeConsumerService,
1288
- }), 'git-hook');
1289
- await runtime.logger.info('hook.event_accepted', 'Hook event accepted for background reconciliation.', { projectId, operationId, phase: 'accepted' });
1290
- return send(res, 202, { ok: true, accepted: true, projectId, operationId });
1291
- }
1292
-
1293
- // Bridge consumer wake-up (I-16): the notification body carries no
1294
- // conversation truth — the durable journal is drained regardless.
1295
- if (method === 'POST' && pathname === '/api/bridge/notify') {
1296
- if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/bridge/notify', url.search);
1297
- if (runtime.bridgeConsumerService) {
1298
- runtime.bridgeConsumerService.handleNotification().catch(() => {
1299
- // Drain failures are logged by the service; wake-up stays 204.
1300
- });
1301
- }
1302
- return send(res, 204, {});
1303
- }
1304
- if (method === 'GET' && pathname === '/api/bridge/status') {
1305
- if (!runtime.bridgeConsumerService) return send(res, 200, { ok: true, available: false });
1306
- const status = await runtime.bridgeConsumerService.status();
1307
- return send(res, 200, { ok: true, available: true, status });
1308
- }
1309
-
1310
- // Unified Integration Setup surface: one action manages Knowledge
1311
- // Integration and Development Capture per client, with separate status.
1312
- if (method === 'GET' && pathname === '/api/integrations/status') {
1313
- runtime.integrationStatusScan ||= new (require('./integration-status-scan').IntegrationStatusScan)();
1314
- const intentStore = require('./integration-intent');
1315
- const managerOptions = { ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir };
1316
- const projectId = url.searchParams.get('projectId');
1317
- if (projectId && !runtime.registryStore.listIds().includes(projectId)) throw new DomainError('PROJECT_NOT_FOUND', '当前项目尚未登记。', { status: 404 });
1318
- const cwd = projectId ? runtime.projectStore.readConfig(projectId).repoPath : undefined;
1319
- const requested = url.searchParams.get('clients');
1320
- const clients = requested ? requested.split(',').filter(Boolean).map(require('./integration-installer').normalizeClient) : undefined;
1321
- const summary = await runtime.integrationStatusScan.read(managerOptions, { captureOnly: url.searchParams.get('captureOnly') === '1', cwd, clients }, { force: url.searchParams.get('refresh') === '1' });
1322
- const repair = runtime.integrationAutoRepair?.status() || null;
1323
- const intent = intentStore.bootstrapIntent(runtime.dataPath, summary.clients);
1324
- summary.clients = summary.clients.map(entry => ({
1325
- ...entry,
1326
- intent: intent.clients[entry.client] || 'disconnected',
1327
- summary: intentStore.summarizeClient(entry, repair),
1328
- repair: repair?.[entry.client] || null,
1329
- }));
1330
- return send(res, 200, { ok: true, ...summary });
1331
- }
1332
- if (method === 'POST' && pathname === '/api/integrations/install') {
1333
- runtime.integrationStatusScan?.invalidate();
1334
- const body = await readJsonBody(req);
1335
- await runtime.integrationAutoRepair?.close();
1336
- const manager = new IntegrationManager({ ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir });
1337
- const clients = Array.isArray(body.clients) && body.clients.length
1338
- ? body.clients.map(client => String(client))
1339
- : undefined;
1340
- const summary = await manager.installAll({
1341
- clients,
1342
- knowledgeOnly: body.knowledgeOnly === true,
1343
- captureOnly: body.captureOnly === true,
1344
- scope: body.scope || 'user',
1345
- });
1346
- const complete = summary.bridge.ok && summary.clients.every(entry => (!entry.knowledgeIntegration || entry.knowledgeIntegration.ok) && (!entry.developmentCapture || entry.developmentCapture.ok));
1347
- if (complete) {
1348
- const intentStore = require('./integration-intent');
1349
- for (const entry of summary.clients) {
1350
- if (entry.developmentCapture?.ok || entry.knowledgeIntegration?.ok) intentStore.markIntent(runtime.dataPath, entry.client, 'connected');
1351
- }
1352
- runtime.integrationAutoRepair?.markSucceeded?.(summary.clients.map(entry => entry.client));
1353
- }
1354
- await runtime.logger[complete ? 'info' : 'error'](`integrations.install_${complete ? 'completed' : 'failed'}`, complete ? 'Agent 接入完成。' : 'Agent 接入未完成,请查看各工具的失败原因。', { operationId: requestOperationId, context: { clients: summary.clients.map(entry => entry.client) } });
1355
- return send(res, 200, { ok: true, complete, operationId: requestOperationId, ...summary });
1356
- }
1357
- if (method === 'POST' && pathname === '/api/integrations/uninstall') {
1358
- const body = await readJsonBody(req);
1359
- await runtime.integrationAutoRepair?.close();
1360
- const manager = new IntegrationManager({ ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir });
1361
- if (body.disableAllCapture === true) {
1362
- const summary = await manager.disableAllCapture({});
1363
- return send(res, 200, { ok: true, ...summary });
1364
- }
1365
- const clients = Array.isArray(body.clients) && body.clients.length
1366
- ? body.clients.map(client => String(client))
1367
- : undefined;
1368
- const knowledgeResults = body.captureOnly === true ? [] : await manager.operate('uninstall', clients && clients.length ? clients : manager.detectClients(), { scope: body.scope || 'user' });
1369
- const captureResults = body.knowledgeOnly === true ? [] : await manager.operateCapture('uninstall', clients && clients.length ? clients : manager.detectClients(), {});
1370
- const complete = [...knowledgeResults, ...captureResults].every(entry => entry.ok);
1371
- if (complete) {
1372
- const intentStore = require('./integration-intent');
1373
- const removed = new Set([...knowledgeResults, ...captureResults].map(entry => entry.client));
1374
- for (const client of removed) intentStore.markIntent(runtime.dataPath, client, 'disconnected');
1375
- }
1376
- return send(res, 200, { ok: true, complete, operationId: requestOperationId, clients: knowledgeResults, capture: captureResults });
1377
- }
1378
-
1379
- if (method === 'GET' && pathname === '/api/ai-profiles') {
1380
- const settings = runtime.settingsStore.read();
1381
- return send(res, 200, { ok: true, config: publicAiProfilesConfig(settings.ai) });
1382
- }
1383
- if (method === 'PUT' && pathname === '/api/ai-profiles') {
1384
- if (runtime.analysisOwner !== 'legacy') throw new DomainError('MODEL_CONFIG_MOVED', '请在工作台的模型设置中管理配置,外壳不再保存凭据。', { status: 409 });
1385
- const body = await readJsonBody(req);
1386
- const current = runtime.settingsStore.read();
1387
- const ai = mergeAiProfiles(current.ai, body);
1388
- await runtime.settingsStore.updatePatch({ ai });
1389
- return send(res, 200, { ok: true, config: publicAiProfilesConfig(ai) });
1390
- }
1391
-
1392
- if (method === 'GET' && pathname === '/api/conversations/projects') {
1393
- return send(res, 200, { ok: true, projects: runtime.conversationQuery.listProjects() });
1394
- }
1395
- if (method === 'GET' && pathname === '/api/conversations/turns') {
1396
- const result = await runtime.conversationQuery.turns({
1397
- projectId: url.searchParams.get('projectId') || '',
1398
- date: url.searchParams.get('date') || '',
1399
- cursor: url.searchParams.get('cursor') || '',
1400
- limit: url.searchParams.get('limit') || undefined,
1401
- });
1402
- return send(res, 200, result);
1403
- }
1404
-
1405
- if (method === 'GET' && pathname === '/api/logs') {
1406
- const result = runtime.logRepository.query({
1407
- ...logFiltersFromUrl(url),
1408
- cursor: url.searchParams.get('cursor') || '',
1409
- pageSize: Number(url.searchParams.get('pageSize') || 500),
1410
- });
1411
- return send(res, 200, { ok: true, ...result, streamCursor: encodeLogStreamCursor(result.entries[0]), health: runtime.logger.getHealth() });
1412
- }
1413
- if (method === 'GET' && pathname === '/api/logs/stream') {
1414
- return streamLogsWithoutGap(res, runtime, logFiltersFromUrl(url), url.searchParams.get('streamCursor') || '');
1415
- }
1416
- if (method === 'GET' && pathname === '/api/logs/export') {
1417
- const output = runtime.layout.getCachePath('exports', `${requestOperationId}.jsonl`);
1418
- const result = runtime.logRepository.exportToFile(output, logFiltersFromUrl(url));
1419
- const payload = fs.readFileSync(result.filePath);
1420
- fs.rmSync(result.filePath, { force: true });
1421
- res.setHeader('Content-Disposition', `attachment; filename="project-knowledge-logs-${new Date().toISOString().slice(0, 10)}.jsonl"`);
1422
- return send(res, 200, payload, 'application/x-ndjson; charset=utf-8');
1423
- }
1424
-
1425
- if (method === 'POST' && pathname === '/api/claude/sessions') {
1426
- const body = await readJsonBody(req);
1427
- const projectId = validateProjectId(body.projectId);
1428
- if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1429
- const config = runtime.projectStore.readConfig(projectId);
1430
- const settings = runtime.settingsStore.read();
1431
- const resolved = resolveEffectiveAiProfile(settings, config);
1432
- const profile = resolved.profile;
1433
- const result = claudeCliRunner.startChatSession({ slug: projectId, projectPath: config.repoPath, kbPath: config.knowledgePath, aiProfile: profile, permissionMode: body.permissionMode });
1434
- return send(res, 201, { ok: true, projectId, ...result });
1435
- }
1436
- if (method === 'GET' && pathname === '/api/claude/sessions') {
1437
- const projectId = url.searchParams.get('projectId') || '';
1438
- if (projectId) validateProjectId(projectId);
1439
- return send(res, 200, { ok: true, sessions: listClaudeSessions(runtime, projectId) });
1440
- }
1441
- if (method === 'GET' && pathname === '/api/claude/sessions-stream') {
1442
- return streamSse(res, write => {
1443
- write({ type: 'claude/snapshot', sessions: listClaudeSessions(runtime) });
1444
- return claudeCliRunner.subscribeList(event => write({ type: 'claude/sessions-changed', ...event }));
1445
- });
1446
- }
1447
- const sessionMatch = pathname.match(/^\/api\/claude\/sessions\/([^/]+)(?:\/(events|input|permission|abort))?$/);
1448
- if (sessionMatch) {
1449
- const sessionId = decodeURIComponent(sessionMatch[1]);
1450
- const action = sessionMatch[2] || '';
1451
- const session = claudeCliRunner.getSession(sessionId);
1452
- if (!session) throw new DomainError('PROJECT_NOT_FOUND', 'Claude session was not found.', { status: 404 });
1453
- if (method === 'GET' && !action) return send(res, 200, { ok: true, session: claudeCliRunner.getState(sessionId) });
1454
- if (method === 'GET' && action === 'events') return streamSse(res, write => claudeCliRunner.subscribe(sessionId, write));
1455
- if (method === 'POST' && action === 'input') {
1456
- const body = await readJsonBody(req);
1457
- if (typeof body.text !== 'string' || !body.text.trim()) throw new DomainError('INVALID_ARGUMENT', 'text must be a non-empty string.');
1458
- const config = runtime.projectStore.readConfig(session.projectSlug);
1459
- const settings = runtime.settingsStore.read();
1460
- const resolved = resolveEffectiveAiProfile(settings, config);
1461
- const profile = resolved.profile;
1462
- // Workbench chat is internal AI runtime (I-02): sending a message
1463
- // must NOT auto-record requirements or append Development
1464
- // Conversation events. External capture flows through the Bridge.
1465
- const result = await claudeCliRunner.sendInput(sessionId, body.text, profile, { permissionMode: body.permissionMode });
1466
- return send(res, 200, { ok: true, sessionId, ...result });
1467
- }
1468
- if (method === 'POST' && action === 'permission') {
1469
- const body = await readJsonBody(req);
1470
- const result = claudeCliRunner.resolvePermission(sessionId, body.requestId, body.decision);
1471
- return send(res, 200, { ok: true, sessionId, ...result });
1472
- }
1473
- if (method === 'POST' && action === 'abort') {
1474
- claudeCliRunner.abort(sessionId);
1475
- return send(res, 200, { ok: true, sessionId });
1476
- }
1477
- }
1478
-
1479
- if (!pathname.startsWith('/api/') && method === 'GET') {
1480
- const filePath = safeStaticFile(pathname);
1481
- if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1482
- return send(res, 200, fs.readFileSync(filePath), CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream');
1483
- }
1484
- }
1485
- return send(res, 404, { ok: false, error: { code: 'PROJECT_NOT_FOUND', message: 'Route not found.', operationId: requestOperationId, retryable: false, details: {} } });
1486
- } catch (error) {
1487
- requestFailed = true;
1488
- const status = error instanceof DomainError ? error.status : 500;
1489
- if (!error.operationId) error.operationId = requestOperationId;
1490
- await runtime.logger.error('http.request_failed', 'HTTP request failed.', {
1491
- operationId: requestOperationId,
1492
- phase: 'request',
1493
- error,
1494
- context: { method: req.method, pathname: String(req.url || '').split('?')[0], status },
1495
- });
1496
- if (res.headersSent) {
1497
- res.destroy(error);
1498
- return;
1499
- }
1500
- return send(res, status, serializeErrorEnvelope(error, requestOperationId));
1501
- }
1502
- };
1503
- }
1504
-
1505
- async function startServer(options = {}) {
1506
- const runtime = options.runtime || createRuntime({ ...options, analysisOwner: options.analysisOwner || 'engine' });
1507
- const port = parsePort(options.port || process.env.KB_SITE_PORT);
1508
- const host = String(options.host || process.env.KB_SITE_HOST || '127.0.0.1');
1509
- let endpointClaimed = false;
1510
- if (isLoopback(host)) {
1511
- const claim = runtimeEndpoint.claimEndpoint(runtime.dataPath, { pid: process.pid, host, port, mode: process.env.KB_RUNTIME_MODE || 'cli' });
1512
- const alreadyOwned = !claim.claimed && claim.endpoint && claim.endpoint.pid === process.pid
1513
- && claim.endpoint.host === host && claim.endpoint.port === port;
1514
- if (!claim.claimed && !alreadyOwned) throw new DomainError('PROJECT_BUSY', 'Another Project Knowledge server owns this data directory.', { status: 409, retryable: true });
1515
- endpointClaimed = claim.claimed;
1516
- }
1517
- let startup;
1518
- let server;
1519
- let controlServer;
1520
- try {
1521
- startup = await initializeRuntime(runtime);
1522
- const handler = createRequestHandler(runtime, { ...options, port, host });
1523
- server = http.createServer(handler);
1524
- runtime.liveGateway = require('./live-gateway').attachLiveGateway(server, runtime);
1525
- runtime.liveAddress = `${host}:${port}`;
1526
- await new Promise((resolve, reject) => {
1527
- server.once('error', reject);
1528
- server.listen(port, host, resolve);
1529
- });
1530
- } catch (error) {
1531
- if (endpointClaimed) runtimeEndpoint.clearEndpoint(runtime.dataPath, { pid: process.pid });
1532
- throw error;
1533
- }
1534
- await runtime.logger.info('server.listening', 'Project Knowledge server is listening.', { phase: 'running', context: { host, port } });
1535
-
1536
- // Startup drains durable conversation/boundary facts. It deliberately does
1537
- // not infer or submit Git commits for knowledge analysis.
1538
- const bridgeReady = Promise.resolve(
1539
- runtime.analysisOwner === 'legacy' && runtime.bridgeConsumerService ? runtime.bridgeConsumerService.start(`http://127.0.0.1:${port}/api/bridge/notify`) : null
1540
- ).catch(error => runtime.logger.error('bridge_consumer.startup_failed', 'Bridge consumer startup drain failed.', { error }));
1541
-
1542
- const startupPromise = Promise.all([
1543
- runtime.analysisOwner === 'legacy' ? runtime.indexService.retryDirtyProjects() : Promise.resolve(),
1544
- bridgeReady,
1545
- ]).catch(error => runtime.logger.error('startup.recovery_failed', 'Startup recovery failed.', { error }));
1546
-
1547
- const maintenanceTimer = setInterval(() => {
1548
- if (runtime.analysisOwner === 'legacy') runtime.indexService.retryDirtyProjects().catch(error => runtime.logger.error('index.retry_failed', 'Index retry failed.', { error }));
1549
- }, Number(process.env.KB_MAINTENANCE_INTERVAL_MS || 60 * 60 * 1000));
1550
- maintenanceTimer.unref?.();
1551
- const configurationTimer = setInterval(() => { connectEngine(runtime).catch(() => {}); }, 30000);
1552
- configurationTimer.unref?.();
1553
- let readyTimer;
1554
- runtime.moduleBridge.onReady = () => { runtime.registryEvents?.publish(['health', 'projects']); clearTimeout(readyTimer); readyTimer = setTimeout(() => { runtime.projectConnections?.clear(); connectEngine(runtime).catch(() => {}); }, 100); };
1555
- runtime.moduleBridge.onStateChange = () => runtime.registryEvents?.publish(['health']);
1556
- connectEngine(runtime).catch(() => {});
1557
-
1558
- // T3.5 进程编排:拉起包内自带的模块服务并守护(默认开启,KB_MODULES_AUTOSTART=0 关闭)
1559
- try {
1560
- runtime.moduleBridge.startSupervised().catch(error => {
1561
- runtime.logger.warn('modules.supervisor_failed', 'Module supervisor failed to start.', { error });
1562
- });
1563
- } catch (error) {
1564
- runtime.logger.warn('modules.supervisor_failed', 'Module supervisor failed to start.', { error });
1565
- }
1566
-
1567
- // Startup-event integration repair: one attempt per connected-but-broken
1568
- // client, failures surfaced and never retried in this run. Native checks
1569
- // run in an owned worker so they cannot block HTTP or launcher readiness.
1570
- try {
1571
- runtime.integrationAutoRepair = new (require('./startup-integration-repair').StartupIntegrationRepair)({
1572
- dataDir: runtime.dataPath,
1573
- logger: runtime.logger,
1574
- managerOptions: {
1575
- ...options.integrationOptions,
1576
- rootDir: runtime.rootDir,
1577
- bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir,
1578
- },
1579
- });
1580
- runtime.integrationAutoRepair.runOnce().catch(error => {
1581
- runtime.logger.warn('integrations.auto_repair_skipped', '启动自动修复未完成。', { error });
1582
- });
1583
- } catch (error) {
1584
- runtime.logger.warn('integrations.auto_repair_unavailable', '启动自动修复不可用。', { error });
1585
- }
1586
-
1587
- let stopping = null;
1588
- const stop = async reason => {
1589
- if (stopping) return stopping;
1590
- stopping = (async () => {
1591
- runtime.integrationStatusScan?.close();
1592
- await runtime.integrationAutoRepair?.close();
1593
- clearInterval(maintenanceTimer);
1594
- clearInterval(configurationTimer);
1595
- clearTimeout(readyTimer); runtime.moduleBridge.onReady = null;
1596
- runtime.moduleBridge.onStateChange = null;
1597
- runtime.engineConnectionClosed = true;
1598
- runtime.liveGateway?.close();
1599
- await runtime.logger.info('server.shutdown_started', 'Server shutdown started.', { phase: 'shutdown', context: { reason } });
1600
- const forceConnections = setTimeout(() => server.closeAllConnections?.(), Number(process.env.KB_SERVER_CLOSE_GRACE_MS || 2000));
1601
- await new Promise(resolve => server.close(resolve));
1602
- clearTimeout(forceConnections);
1603
- const drainTimeoutMs = Number(process.env.KB_SHUTDOWN_DRAIN_TIMEOUT_MS || 30000);
1604
- let drainTimer;
1605
- const drained = await Promise.race([
1606
- Promise.allSettled(activeTaskPromises(runtime.activeTasks).concat(IndexService.flush())).then(() => true),
1607
- new Promise(resolve => { drainTimer = setTimeout(() => resolve(false), drainTimeoutMs); }),
1608
- ]);
1609
- clearTimeout(drainTimer);
1610
- if (!drained) await runtime.logger.warn('server.shutdown_drain_timeout', 'Server shutdown timed out waiting for background work.', { phase: 'shutdown', context: { drainTimeoutMs } });
1611
- await runtime.knowledgeRuntime.close();
1612
- await runtime.indexAdapter.close();
1613
- if (runtime.bridgeConsumerService) await runtime.bridgeConsumerService.stop().catch(() => {});
1614
- await runtime.moduleBridge.stopSupervised();
1615
- await runtime.logger.close();
1616
- await controlServer?.close();
1617
- runtimeEndpoint.clearEndpoint(runtime.dataPath, { pid: process.pid });
1618
- })();
1619
- return stopping;
1620
- };
1621
- runtime.instanceIdentity = runtimeControl.identity(runtime.dataPath, { host, port, version: require('../../package.json').version, startedAt: runtimeEndpoint.readEndpoint(runtime.dataPath)?.startedAt || new Date().toISOString() });
1622
- try { controlServer = await runtimeControl.listen(runtime.dataPath, runtime.instanceIdentity, () => stop('cli-control')); }
1623
- catch (error) { await stop('control-startup-failed'); throw error; }
1624
- return { server, runtime, startup, startupPromise, stop, host, port };
1625
- }
1626
-
1627
- function installProcessHandlers(instancePromise) {
1628
- let terminating = false;
1629
- const terminate = async (reason, error, exitCode) => {
1630
- if (terminating) return;
1631
- terminating = true;
1632
- try {
1633
- const instance = await instancePromise;
1634
- if (error) await instance.runtime.logger.fatal('server.process_failure', 'A process-level failure stopped the server.', { phase: 'shutdown', error });
1635
- await instance.stop(reason);
1636
- } catch (shutdownError) {
1637
- try { process.stderr.write(`[project-knowledge] shutdown failed: ${String(shutdownError && shutdownError.message || shutdownError)}\n`); } catch {
1638
- // stderr is the final fallback after the structured logger could not shut down.
1639
- }
1640
- }
1641
- process.exitCode = exitCode;
1642
- };
1643
- process.once('SIGINT', () => terminate('SIGINT', null, 0));
1644
- process.once('SIGTERM', () => terminate('SIGTERM', null, 0));
1645
- process.once('uncaughtException', error => terminate('uncaughtException', error, 1));
1646
- process.once('unhandledRejection', error => terminate('unhandledRejection', error instanceof Error ? error : new Error(String(error)), 1));
1647
- }
1648
-
1649
- module.exports = {
1650
- RuntimeIndexAdapter,
1651
- RuntimeKnowledgeAnalyzer,
1652
- createRuntime,
1653
- initializeRuntime,
1654
- migrateManagedHooks,
1655
- createRequestHandler,
1656
- startServer,
1657
- installProcessHandlers,
1658
- mergeAiProfiles,
1659
- requestOriginAllowed,
1660
- isLoopback,
1661
- isProjectBusy,
1662
- taskForProject,
1663
- activeTaskPromises,
1664
- };
1
+ const http = require('http');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+
6
+ const dataDir = require('./data-dir');
7
+ const runtimeEndpoint = require('./runtime-endpoint');
8
+ const runtimeControl = require('./runtime-control');
9
+ const hookManager = require('./hook-manager');
10
+ const claudeCliRunner = require('./claude-cli-runner');
11
+ const { StorageLayout } = require('./storage-layout');
12
+ const { SettingsStore } = require('./settings-store');
13
+ const { ProjectRegistryStore } = require('./project-registry-store');
14
+ const { ProjectStore } = require('./project-store');
15
+ const { ProjectLifecycleService } = require('./project-lifecycle-service');
16
+ const { RequirementRecorder } = require('./requirement-recorder');
17
+ const { ConversationStore } = require('./conversation-store');
18
+ const { ConversationQueryService } = require('./conversation-query-service');
19
+ const { BridgeAdapter } = require('./bridge-adapter');
20
+ const { BridgeConsumerService } = require('./bridge-consumer-service');
21
+ const { IntegrationManager } = require('./integration-installer');
22
+ const { Logger, LogRepository } = require('./structured-logger');
23
+ const { CommitReconciler } = require('./commit-reconciler');
24
+ const { handlePostCommitEvent, recoverOrphanedClaims } = require('./post-commit-automation');
25
+ const { KnowledgePromotionService, hashBuffer } = require('./knowledge-promotion');
26
+ const { resolveEffectiveAiProfile } = require('./ai-profile-resolver');
27
+ const { ModuleBridge } = require('./module-bridge');
28
+ const { coreVersionSnapshot } = require('./public-version-info');
29
+ const { connectEngine, forwardCommitToBridge } = require('./engine-connection');
30
+ const { initializeProductData } = require('./product-data');
31
+ const { IndexService } = require('./index-service');
32
+ const { KnowledgeDatabase } = require('./knowledge-db');
33
+ const { LocalEmbeddingService } = require('./embedding-service');
34
+ const { MarkdownKnowledgeIndexer } = require('./markdown-knowledge-indexer');
35
+ const { KnowledgeToolRuntime } = require('./knowledge-tool-runtime');
36
+ const { buildContextPack } = require('./context-pack-builder');
37
+ const { execGit } = require('./git-runner');
38
+ const {
39
+ SCHEMAS,
40
+ DomainError,
41
+ createId,
42
+ serializeErrorEnvelope,
43
+ publicAiProfilesConfig,
44
+ validateProjectId,
45
+ LOG_LEVELS,
46
+ REDACTION_KEY_PATTERN,
47
+ } = require('./contracts');
48
+
49
+ const DEFAULT_PORT = 5757;
50
+ const UI_ROOT = path.resolve(__dirname, '..', '..', 'ui');
51
+ const TRIGGER_SCRIPT_PATH = path.resolve(__dirname, '..', 'scripts', 'hook-trigger.js');
52
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
53
+ const CONTENT_TYPES = Object.freeze({
54
+ '.html': 'text/html; charset=utf-8',
55
+ '.css': 'text/css; charset=utf-8',
56
+ '.js': 'text/javascript; charset=utf-8',
57
+ '.svg': 'image/svg+xml',
58
+ '.json': 'application/json; charset=utf-8',
59
+ '.png': 'image/png',
60
+ '.ico': 'image/x-icon',
61
+ });
62
+
63
+ function isLoopback(host) {
64
+ return LOOPBACK_HOSTS.has(String(host || '').trim().toLowerCase());
65
+ }
66
+
67
+ function parsePort(value) {
68
+ const port = Number(value || DEFAULT_PORT);
69
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new DomainError('INVALID_ARGUMENT', 'KB_SITE_PORT is invalid.', { status: 500 });
70
+ return port;
71
+ }
72
+
73
+ function parseAllowedOrigins(value) {
74
+ return new Set(String(value || '').split(',').map(item => item.trim()).filter(Boolean));
75
+ }
76
+
77
+ function configuredSecrets(settingsStore, extras = []) {
78
+ const secrets = [...extras];
79
+ try {
80
+ const settings = settingsStore.read({ allowMissing: true });
81
+ for (const profile of settings.ai && Array.isArray(settings.ai.profiles) ? settings.ai.profiles : []) {
82
+ for (const [key, value] of Object.entries(profile || {})) {
83
+ if (REDACTION_KEY_PATTERN.test(key) && typeof value === 'string') secrets.push(value);
84
+ }
85
+ }
86
+ } catch {
87
+ // Logger construction must survive missing/corrupt pre-initialization settings without exposing secrets.
88
+ }
89
+ return [...new Set(secrets.filter(secret => typeof secret === 'string' && secret.length >= 4))];
90
+ }
91
+
92
+ function normalizeLoggingPatch(input) {
93
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
94
+ throw new DomainError('INVALID_ARGUMENT', 'Logging settings must be an object.');
95
+ }
96
+ const allowed = new Set(['levels']);
97
+ const unknown = Object.keys(input).find(key => !allowed.has(key));
98
+ if (unknown) throw new DomainError('INVALID_ARGUMENT', `Unknown logging setting: ${unknown}.`, { details: { field: unknown } });
99
+ const patch = {};
100
+ if (Object.prototype.hasOwnProperty.call(input, 'levels')) {
101
+ if (!Array.isArray(input.levels) || !input.levels.length || input.levels.some(level => !LOG_LEVELS.includes(level))) {
102
+ throw new DomainError('INVALID_ARGUMENT', 'Logging levels must contain one or more supported levels.');
103
+ }
104
+ patch.levels = [...new Set(input.levels)];
105
+ }
106
+ if (!Object.keys(patch).length) throw new DomainError('INVALID_ARGUMENT', 'No mutable logging settings were provided.');
107
+ return patch;
108
+ }
109
+
110
+ function requestOriginAllowed(req, origin, options) {
111
+ if (!origin) return true;
112
+ if (origin === 'null') return false;
113
+ let parsed;
114
+ try { parsed = new URL(origin); } catch { return false; }
115
+ if (!['http:', 'https:'].includes(parsed.protocol)) return false;
116
+ const hostHeader = String(req.headers.host || '').toLowerCase();
117
+ if (hostHeader && parsed.host.toLowerCase() === hostHeader) return true;
118
+ if (options.allowedOrigins.has(origin)) return true;
119
+ if (!options.loopbackBind) return false;
120
+ return isLoopback(parsed.hostname) && Number(parsed.port || (parsed.protocol === 'https:' ? 443 : 80)) === options.port;
121
+ }
122
+
123
+ function authorizationValid(req, token) {
124
+ if (!token) return false;
125
+ const bearer = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
126
+ const supplied = bearer ? bearer[1] : String(req.headers['x-project-knowledge-token'] || '');
127
+ if (!supplied) return false;
128
+ const expectedBuffer = Buffer.from(token);
129
+ const suppliedBuffer = Buffer.from(supplied);
130
+ return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
131
+ }
132
+
133
+ function applyCors(req, res, security) {
134
+ const origin = String(req.headers.origin || '');
135
+ res.setHeader('Vary', 'Origin');
136
+ if (origin && requestOriginAllowed(req, origin, security)) res.setHeader('Access-Control-Allow-Origin', origin);
137
+ }
138
+
139
+ function enforceSecurity(req, security) {
140
+ const origin = String(req.headers.origin || '');
141
+ if (origin && !requestOriginAllowed(req, origin, security)) {
142
+ throw new DomainError('ORIGIN_FORBIDDEN', 'Request Origin is not allowed.', { status: 403 });
143
+ }
144
+ if (!security.loopbackBind && !authorizationValid(req, security.authToken)) {
145
+ throw new DomainError('AUTH_REQUIRED', 'Authentication is required for a non-loopback server.', { status: 401 });
146
+ }
147
+ }
148
+
149
+ function send(res, status, body, contentType = '') {
150
+ if (res.writableEnded) return;
151
+ const isBuffer = Buffer.isBuffer(body);
152
+ const isText = typeof body === 'string' || isBuffer;
153
+ const payload = isText ? body : JSON.stringify(body);
154
+ res.statusCode = status;
155
+ res.setHeader('Content-Type', contentType || (isText ? 'text/plain; charset=utf-8' : 'application/json; charset=utf-8'));
156
+ res.setHeader('Cache-Control', 'no-store');
157
+ res.setHeader('X-Content-Type-Options', 'nosniff');
158
+ res.setHeader('Referrer-Policy', 'no-referrer');
159
+ res.end(payload);
160
+ }
161
+
162
+ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
163
+ const chunks = [];
164
+ let total = 0;
165
+ for await (const chunk of req) {
166
+ total += chunk.length;
167
+ if (total > maxBytes) throw new DomainError('INVALID_ARGUMENT', 'Request body is too large.', { status: 413 });
168
+ chunks.push(chunk);
169
+ }
170
+ if (!chunks.length) return {};
171
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
172
+ catch (error) { throw new DomainError('INVALID_ARGUMENT', 'Request body must be valid JSON.', { status: 400, cause: error }); }
173
+ }
174
+
175
+ function profileById(settings, profileId) {
176
+ return (settings.ai && Array.isArray(settings.ai.profiles) ? settings.ai.profiles : []).find(profile => profile.id === profileId) || null;
177
+ }
178
+
179
+ function mergeAiProfiles(current, input) {
180
+ if (!input || typeof input !== 'object' || !Array.isArray(input.profiles)) {
181
+ throw new DomainError('INVALID_ARGUMENT', 'AI profiles must contain a profiles array.');
182
+ }
183
+ const existing = new Map((current && Array.isArray(current.profiles) ? current.profiles : []).map(profile => [profile.id, profile]));
184
+ const ids = new Set();
185
+ const profiles = input.profiles.map(raw => {
186
+ if (!raw || typeof raw !== 'object') throw new DomainError('INVALID_ARGUMENT', 'AI profile is invalid.');
187
+ const id = String(raw.id || '').trim();
188
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{1,127}$/.test(id) || ids.has(id)) throw new DomainError('INVALID_ARGUMENT', 'AI profile id is invalid or duplicated.');
189
+ ids.add(id);
190
+ const prior = existing.get(id) || {};
191
+ const update = raw.apiKeyUpdate && typeof raw.apiKeyUpdate === 'object' ? raw.apiKeyUpdate : {};
192
+ const mode = String(update.mode || raw.apiKeyMode || (Object.prototype.hasOwnProperty.call(raw, 'apiKey') ? 'replace' : 'preserve'));
193
+ if (!['preserve', 'replace', 'clear'].includes(mode)) throw new DomainError('INVALID_ARGUMENT', 'apiKeyUpdate.mode must be preserve, replace, or clear.');
194
+ const next = { ...raw, id };
195
+ delete next.apiKeyUpdate;
196
+ delete next.apiKeyMode;
197
+ delete next.hasApiKey;
198
+ delete next.apiKeyMasked;
199
+ delete next.authToken;
200
+ if (mode === 'preserve') {
201
+ if (prior.apiKey) next.apiKey = prior.apiKey;
202
+ else delete next.apiKey;
203
+ } else if (mode === 'clear') {
204
+ delete next.apiKey;
205
+ } else {
206
+ const replacement = String(update.value || raw.apiKey || '');
207
+ if (!replacement) throw new DomainError('INVALID_ARGUMENT', 'A replacement API key is required.');
208
+ next.apiKey = replacement;
209
+ }
210
+ return next;
211
+ });
212
+ const defaultProfileId = input.defaultProfileId == null ? current && current.defaultProfileId || null : input.defaultProfileId;
213
+ if (defaultProfileId && !ids.has(defaultProfileId)) throw new DomainError('INVALID_ARGUMENT', 'defaultProfileId must reference a configured profile.');
214
+ return { ...current, ...input, schema: input.schema || current && current.schema || 'ai-profiles/v1', profiles, defaultProfileId };
215
+ }
216
+
217
+ function projectPublicView(projectId, projectStore) {
218
+ const config = projectStore.readConfig(projectId);
219
+ const state = projectStore.readState(projectId);
220
+ return { projectId, config, state };
221
+ }
222
+
223
+ function listProjectViews(registryStore, projectStore) {
224
+ return registryStore.listIds().map(projectId => projectPublicView(projectId, projectStore));
225
+ }
226
+
227
+ async function inspectGitRepository(repoPath) {
228
+ const root = path.resolve(String(repoPath || ''));
229
+ if (!repoPath || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
230
+ return { repoStatus: 'missing', repoPath: root, headCommit: null, branch: '', dirty: false, error: { code: 'PROJECT_NOT_FOUND', message: 'Repository path is missing.' } };
231
+ }
232
+ const inside = await execGit(root, ['rev-parse', '--is-inside-work-tree']);
233
+ if (!inside.ok) return { repoStatus: 'not-git', repoPath: root, headCommit: null, branch: '', dirty: false, error: { code: 'INVALID_ARGUMENT', message: 'Path is not a Git work tree.' } };
234
+ const [top, head, branch, status, commonDir] = await Promise.all([
235
+ execGit(root, ['rev-parse', '--show-toplevel']),
236
+ execGit(root, ['rev-parse', '--verify', 'HEAD']),
237
+ execGit(root, ['branch', '--show-current']),
238
+ execGit(root, ['status', '--porcelain=v1', '--untracked-files=normal']),
239
+ execGit(root, ['rev-parse', '--git-common-dir']),
240
+ ]);
241
+ return {
242
+ repoStatus: head.ok ? 'ok' : 'empty',
243
+ repoPath: top.ok ? path.resolve(top.stdout.trim()) : root,
244
+ commonDir: commonDir.ok ? path.resolve(root, commonDir.stdout.trim()) : '',
245
+ headCommit: head.ok ? head.stdout.trim() : null,
246
+ branch: branch.ok ? branch.stdout.trim() : '',
247
+ dirty: status.ok && Boolean(status.stdout.trim()),
248
+ changes: status.ok ? status.stdout.split(/\r?\n/).filter(Boolean).slice(0, 200) : [],
249
+ error: status.ok ? null : { code: 'INVALID_ARGUMENT', message: 'Git status could not be read.' },
250
+ };
251
+ }
252
+
253
+ function listClaudeSessions(runtime, projectId = '') {
254
+ const projectIds = projectId ? [projectId] : runtime.registryStore.listIds();
255
+ const sessions = new Map();
256
+ for (const id of projectIds) {
257
+ for (const session of claudeCliRunner.listSessions({ projectSlug: id })) sessions.set(session.sessionId, session);
258
+ }
259
+ return [...sessions.values()].sort((left, right) => String(right.startedAt || '').localeCompare(String(left.startedAt || '')));
260
+ }
261
+
262
+ function isProjectBusy(projectId, projectStore, activeTasks) {
263
+ if ((activeTasks.get(projectId)?.size || 0) > 0) return true;
264
+ const state = projectStore.readState(projectId);
265
+ if (state.analysis.activeClaim) return true;
266
+ return claudeCliRunner.listSessions({ projectSlug: projectId }).some(session => ['spawning', 'running', 'pending-permission'].includes(session.state));
267
+ }
268
+
269
+ class RuntimeIndexAdapter {
270
+ constructor(options) {
271
+ this.layout = options.layout;
272
+ this.settingsStore = options.settingsStore;
273
+ this.runtime = null;
274
+ }
275
+
276
+ getRuntime() {
277
+ if (this.runtime) return this.runtime;
278
+ const settings = this.settingsStore.read();
279
+ const database = new KnowledgeDatabase({
280
+ dbPath: this.layout.getIndexPath(),
281
+ maintenancePath: this.layout.getRuntimePath('index-maintenance.json'),
282
+ });
283
+ const embedding = settings.embedding || {};
284
+ const embedder = this.createEmbedder(embedding);
285
+ this.runtime = { database, indexer: new MarkdownKnowledgeIndexer({ database, embedder }) };
286
+ return this.runtime;
287
+ }
288
+
289
+ createEmbedder(embedding) {
290
+ if (process.env.KB_EMBEDDING_FAKE === '1') {
291
+ const embed = async text => {
292
+ const vector = new Array(require('./knowledge-schema').EMBEDDING_DIMENSIONS).fill(0);
293
+ for (let index = 0; index < String(text).length; index += 1) vector[String(text).charCodeAt(index) % vector.length] += 1;
294
+ const norm = Math.sqrt(vector.reduce((sum, value) => sum + value * value, 0)) || 1;
295
+ return vector.map(value => value / norm);
296
+ };
297
+ return { embedPassage: embed, embedQuery: embed };
298
+ }
299
+ return new LocalEmbeddingService({
300
+ modelId: embedding.modelId,
301
+ cacheDir: this.layout.getCachePath('models'),
302
+ remoteHost: embedding.remoteHost,
303
+ localModelPath: embedding.localModelPath,
304
+ localFilesOnly: embedding.localFilesOnly === true,
305
+ });
306
+ }
307
+
308
+ async indexProject(input) {
309
+ const { indexer } = this.getRuntime();
310
+ return indexer.indexDirectory({
311
+ kbPath: input.knowledgePath,
312
+ spaceId: `project:${input.projectId}`,
313
+ sourceProjectId: input.projectId,
314
+ sourceCommit: input.sinceCommit || '',
315
+ });
316
+ }
317
+
318
+ createIndexer(targetPath) {
319
+ const settings = this.settingsStore.read();
320
+ const embedding = settings.embedding || {};
321
+ const database = new KnowledgeDatabase({
322
+ dbPath: targetPath,
323
+ maintenancePath: `${targetPath}.maintenance.json`,
324
+ });
325
+ const embedder = this.createEmbedder(embedding);
326
+ return { database, indexer: new MarkdownKnowledgeIndexer({ database, embedder }) };
327
+ }
328
+
329
+ async buildFull({ targetPath, projects }) {
330
+ const built = this.createIndexer(targetPath);
331
+ try {
332
+ for (const { projectId, config } of projects) {
333
+ await built.indexer.indexDirectory({
334
+ kbPath: config.knowledgePath,
335
+ spaceId: `project:${projectId}`,
336
+ sourceProjectId: projectId,
337
+ deferMaintenance: true,
338
+ });
339
+ }
340
+ return { ok: true, projects: projects.length, rows: await built.database.count() };
341
+ } finally {
342
+ await built.database.close();
343
+ try { fs.rmSync(`${targetPath}.maintenance.json`, { force: true }); } catch {
344
+ // Maintenance metadata is non-authoritative and is replaced on the next rebuild.
345
+ }
346
+ }
347
+ }
348
+
349
+ async validateIndex(targetPath) {
350
+ const database = new KnowledgeDatabase({ dbPath: targetPath, maintenancePath: `${targetPath}.maintenance.json` });
351
+ try {
352
+ await database.open();
353
+ return { ok: true, rows: await database.count() };
354
+ } catch (error) {
355
+ return { ok: false, error: { code: error.code || 'DATA_CORRUPT', message: error.message } };
356
+ } finally {
357
+ await database.close();
358
+ }
359
+ }
360
+
361
+ async close() {
362
+ if (this.runtime) await this.runtime.database.close();
363
+ this.runtime = null;
364
+ }
365
+ }
366
+
367
+ class RuntimeKnowledgeAnalyzer {
368
+ constructor(options) {
369
+ this.settingsStore = options.settingsStore;
370
+ this.logger = options.logger;
371
+ }
372
+
373
+ fakeResult(input) {
374
+ const relativePath = `changes/${input.claim.commitSha.slice(0, 12)}.md`;
375
+ const outputPath = path.join(input.stagingPath, 'files', ...relativePath.split('/'));
376
+ const content = `# Commit ${input.claim.commitSha.slice(0, 12)}\n\nThis knowledge entry was generated by the isolated automation test adapter from commit evidence ${input.claim.commitSha}.\n`;
377
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
378
+ fs.writeFileSync(outputPath, content, 'utf8');
379
+ fs.writeFileSync(input.manifestPath, `${JSON.stringify({
380
+ schema: 'knowledge-staging-manifest/v1',
381
+ projectId: input.projectId,
382
+ runId: input.claim.runId,
383
+ commitSha: input.claim.commitSha,
384
+ operations: [{
385
+ path: relativePath,
386
+ operation: 'create',
387
+ sha256: hashBuffer(Buffer.from(content)),
388
+ reason: 'Commit evidence was accepted by the isolated test analyzer.',
389
+ evidenceReferences: [
390
+ `commit:${input.claim.commitSha}`,
391
+ `patch:${input.claim.patchHash}`,
392
+ input.claim.conversationSnapshotHash && `conversation:${input.claim.conversationSnapshotHash}`,
393
+ input.claim.retrievalManifestHash && `retrieval:${input.claim.retrievalManifestHash}`,
394
+ ].filter(Boolean),
395
+ }],
396
+ }, null, 2)}\n`, 'utf8');
397
+ return { ok: true, fake: true };
398
+ }
399
+
400
+ async runClaim(input) {
401
+ if (process.env.KB_AUTOMATION_FAKE_CLAUDE === '1') return this.fakeResult(input);
402
+ const settings = this.settingsStore.read();
403
+ const resolved = resolveEffectiveAiProfile(settings, input.config);
404
+ const profile = resolved.profile;
405
+ const contract = `\n\nOUTPUT CONTRACT\nRead exact Git evidence only beneath ${input.evidenceRoot}. Write only beneath ${input.stagingPath}. Put Markdown under files/<allowed-path> and 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, sha256, reason, and evidenceReferences containing commit:${input.claim.commitSha}, patch:${input.claim.patchHash}, conversation:${input.claim.conversationSnapshotHash}, and retrieval:${input.claim.retrievalManifestHash}. Do not read or modify source files, final knowledge, other runs, or indexes.`;
406
+ const started = claudeCliRunner.startAutomationSession({
407
+ slug: input.projectId,
408
+ projectPath: input.config.repoPath,
409
+ kbPath: input.stagingPath,
410
+ userPrompt: `${input.prompt}${contract}`,
411
+ aiProfile: profile,
412
+ permissionMode: 'acceptEdits',
413
+ allowedTools: input.safetyPolicy.allowedTools,
414
+ safetyPolicy: input.safetyPolicy,
415
+ metadata: { source: 'git-hook', automationRunId: input.claim.runId, commitHash: input.claim.commitSha, projectId: input.projectId },
416
+ });
417
+ const current = claudeCliRunner.getSession(started.sessionId);
418
+ if (current && claudeCliRunner.TERMINAL_STATES.has(current.state)) {
419
+ if (current.state !== 'idle') throw new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer did not complete successfully.', { status: 500, retryable: true });
420
+ return { ok: true, sessionId: started.sessionId };
421
+ }
422
+ const timeoutMs = Math.max(10_000, Number(process.env.KB_AUTOMATION_TIMEOUT_MS || 15 * 60 * 1000));
423
+ return new Promise((resolve, reject) => {
424
+ let timer;
425
+ const unsubscribe = claudeCliRunner.onSessionEnded(session => {
426
+ if (session.sessionId !== started.sessionId) return;
427
+ clearTimeout(timer);
428
+ unsubscribe();
429
+ if (session.state === 'idle') resolve({ ok: true, sessionId: session.sessionId });
430
+ else reject(new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer did not complete successfully.', { status: 500, retryable: true }));
431
+ });
432
+ timer = setTimeout(() => {
433
+ unsubscribe();
434
+ try { claudeCliRunner.abort(started.sessionId); } catch {
435
+ // Timeout remains the public failure when the child already exited.
436
+ }
437
+ reject(new DomainError('INVALID_ARGUMENT', 'Knowledge analyzer timed out.', { status: 504, retryable: true }));
438
+ }, timeoutMs);
439
+ timer.unref?.();
440
+ });
441
+ }
442
+ }
443
+
444
+ function createRuntime(options = {}) {
445
+ const rootDir = path.resolve(options.rootDir || path.resolve(__dirname, '..', '..'));
446
+ const dataPath = path.resolve(options.dataDir || dataDir.getDataDir());
447
+ if (options.analysisOwner === 'engine') initializeProductData(dataPath);
448
+ const layout = options.layout || new StorageLayout({ dataDir: dataPath });
449
+ const settingsStore = options.settingsStore || new SettingsStore({ layout });
450
+ const registryStore = options.registryStore || new ProjectRegistryStore({ layout });
451
+ const projectStore = options.projectStore || new ProjectStore({ layout });
452
+ const secretsProvider = () => configuredSecrets(settingsStore, [
453
+ String(options.authToken || process.env.KB_SITE_AUTH_TOKEN || ''),
454
+ String(process.env.ANTHROPIC_API_KEY || ''),
455
+ String(process.env.ANTHROPIC_AUTH_TOKEN || ''),
456
+ ]);
457
+ const logger = options.logger || new Logger({
458
+ layout,
459
+ settingsProvider: () => {
460
+ try { return settingsStore.read({ allowMissing: true }).logging; }
461
+ catch { return {}; }
462
+ },
463
+ secretsProvider,
464
+ });
465
+ claudeCliRunner.setLogger(logger);
466
+ const logRepository = options.logRepository || new LogRepository({
467
+ layout,
468
+ settingsProvider: () => settingsStore.read({ allowMissing: true }).logging,
469
+ secretsProvider,
470
+ });
471
+ const activeTasks = new Map();
472
+ const bridgeAdapter = options.bridgeAdapter || new BridgeAdapter({ dataDir: dataPath, ...(options.analysisOwner === 'engine' ? { bridgeHomeDir: process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(dataPath, 'events') } : {}) });
473
+ const conversationStore = options.conversationStore || new ConversationStore({ layout, projectStore, logger });
474
+ const bridgeConsumerService = options.bridgeConsumerService || new BridgeConsumerService({
475
+ bridgeAdapter,
476
+ conversationStore,
477
+ registryStore,
478
+ projectStore,
479
+ logger,
480
+ });
481
+ const conversationQuery = options.conversationQuery || new ConversationQueryService({
482
+ layout, registryStore, projectStore, conversationStore, logger,
483
+ });
484
+ const indexAdapter = options.indexAdapter || new RuntimeIndexAdapter({ layout, settingsStore });
485
+ const indexService = options.indexService || new IndexService({ layout, registryStore, projectStore, adapter: indexAdapter, logger });
486
+ const analyzer = options.analyzer || new RuntimeKnowledgeAnalyzer({ settingsStore, logger });
487
+ const promotionService = options.promotionService || new KnowledgePromotionService({ layout, projectStore, analyzer, indexService, logger });
488
+ const requirementRecorder = options.requirementRecorder || new RequirementRecorder({ layout, registryStore, projectStore, conversationStore, logger });
489
+ const knowledgeRuntime = options.knowledgeRuntime || new KnowledgeToolRuntime({ layout, settingsStore, registryStore, projectStore, requirementRecorder, logger });
490
+ const reconciler = options.reconciler || new CommitReconciler({
491
+ layout,
492
+ registryStore,
493
+ projectStore,
494
+ conversationStore,
495
+ retrievalService: knowledgeRuntime.retrievalService,
496
+ claimProcessor: promotionService,
497
+ settingsStore,
498
+ logger,
499
+ });
500
+ const lifecycleService = options.lifecycleService || new ProjectLifecycleService({
501
+ layout,
502
+ settingsStore,
503
+ registryStore,
504
+ projectStore,
505
+ hookManager,
506
+ triggerScriptPath: TRIGGER_SCRIPT_PATH,
507
+ logger,
508
+ isProjectBusy: projectId => isProjectBusy(projectId, projectStore, activeTasks),
509
+ bridgeAdapter,
510
+ });
511
+ const moduleBridge = options.moduleBridge || new ModuleBridge({ registryStore, projectStore, settingsStore, logger, dataDir: dataPath });
512
+ return {
513
+ analysisOwner: options.analysisOwner || 'legacy', ownership: { owner: options.analysisOwner || 'legacy', ready: false },
514
+ rootDir, dataPath, layout, settingsStore, registryStore, projectStore, logger, logRepository,
515
+ activeTasks, bridgeAdapter, bridgeConsumerService, conversationStore, conversationQuery, indexAdapter, indexService, promotionService, reconciler, lifecycleService, requirementRecorder, knowledgeRuntime, moduleBridge,
516
+ registryEvents: new (require('./registry-events').RegistryEvents)(),
517
+ };
518
+ }
519
+
520
+ async function migrateManagedHooks(runtime) {
521
+ const results = [];
522
+ for (const projectId of runtime.registryStore.listIds()) {
523
+ const config = runtime.projectStore.readConfig(projectId);
524
+ const state = runtime.projectStore.readState(projectId);
525
+ if (!fs.existsSync(config.repoPath)) continue;
526
+ if (runtime.analysisOwner !== 'legacy') {
527
+ const status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
528
+ if (status.managedVersion === 2) {
529
+ const result = hookManager.installHook({ repoPath: config.repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH, ...runtime.bridgeAdapter.hookBinding() });
530
+ results.push({ projectId, ...result }); continue;
531
+ }
532
+ }
533
+ if (state.hook.migrationVersion >= 2) {
534
+ results.push({ projectId, ok: true, status: 'verified', reason: 'already-migrated', migrationVersion: state.hook.migrationVersion });
535
+ continue;
536
+ }
537
+ let outcome;
538
+ try {
539
+ outcome = await reconcileLegacyHookMigration(runtime, projectId, config, state);
540
+ } catch (error) {
541
+ await runtime.logger.warn('hook.migration_failed', 'Managed Hook migration could not be completed.', { projectId, error });
542
+ outcome = { ok: false, status: 'failed', reason: error.code || 'HOOK_INVALID', error: { code: error.code || 'HOOK_INVALID', message: error.message } };
543
+ }
544
+ if (outcome.status === 'verified') {
545
+ await runtime.projectStore.updateState(projectId, draft => {
546
+ draft.hook.migrationVersion = 2;
547
+ draft.hook.managedVersion = Number(outcome.managedVersion || 2);
548
+ draft.hook.lastVerifiedAt = new Date().toISOString();
549
+ if (outcome.conflict) draft.hook.lastConflict = outcome.conflict;
550
+ else delete draft.hook.lastConflict;
551
+ });
552
+ results.push({ projectId, ok: true, status: 'verified', managedVersion: outcome.managedVersion, reason: outcome.reason });
553
+ } else if (outcome.status === 'conflict') {
554
+ await runtime.projectStore.updateState(projectId, draft => {
555
+ if (outcome.conflict) draft.hook.lastConflict = outcome.conflict;
556
+ });
557
+ results.push({ projectId, ok: false, status: 'conflict', reason: outcome.reason, conflict: outcome.conflict });
558
+ } else {
559
+ // 'failed' or 'pending' — leave migrationVersion at its prior value so the
560
+ // next startup can re-attempt. Persist the failure reason so the UI can
561
+ // surface it (T04 will read this).
562
+ await runtime.projectStore.updateState(projectId, draft => {
563
+ draft.hook.lastConflict = outcome.conflict || { code: outcome.reason || 'HOOK_INVALID', message: outcome.error && outcome.error.message || 'Hook migration did not verify.', retryable: true };
564
+ });
565
+ results.push({ projectId, ok: false, status: outcome.status || 'failed', reason: outcome.reason, error: outcome.error });
566
+ }
567
+ }
568
+ return results;
569
+ }
570
+
571
+ // One-pass state machine for a single project's legacy hook migration.
572
+ // Returns an outcome object — the caller (migrateManagedHooks) is responsible
573
+ // for persisting the resulting state. The outcome shape:
574
+ // { status: 'verified', managedVersion, reason } — hook is installed + verified
575
+ // { status: 'conflict', reason, conflict } — third-party hook present, no overwrite
576
+ // { status: 'failed', reason, error } — write/verify failed; no migration
577
+ // { status: 'pending', reason } — unknown state; leave migrationVersion alone
578
+ async function reconcileLegacyHookMigration(runtime, projectId, config, state) {
579
+ const repoPath = config.repoPath;
580
+ // Use the public hook-manager surface; we never write to the repo ourselves.
581
+ const status = hookManager.readHookStatus({ repoPath, projectId });
582
+ // Case 1: already current v2 + verified.
583
+ if (status.kbManaged && status.managedVersion === 2 && status.installed) {
584
+ return { status: 'verified', managedVersion: status.managedVersion, reason: 'current' };
585
+ }
586
+ // Case 4: third-party hook. Do not overwrite.
587
+ if (!status.kbManaged && status.reason && /third-party/i.test(status.reason)) {
588
+ return {
589
+ status: 'conflict',
590
+ reason: 'third-party',
591
+ conflict: { code: 'HOOK_CONFLICT', message: 'A third-party post-commit hook is installed; Project-Knowledge will not overwrite it.', reason: status.reason },
592
+ };
593
+ }
594
+ // Case 5: managed hook exists but its marker belongs to another project.
595
+ if (status.kbManaged && status.managedVersion === 2 && !status.installed) {
596
+ return {
597
+ status: 'conflict',
598
+ reason: 'wrong-project',
599
+ conflict: { code: 'HOOK_CONFLICT', message: 'A managed hook belonging to another project is installed.', projectId: status.projectId },
600
+ };
601
+ }
602
+ // Case 2 + 3: delegate to migrateManagedHook which handles v1 replacement
603
+ // and reports reason=missing for the missing case. Then install + verify
604
+ // for the missing path.
605
+ let migrateOutcome;
606
+ try {
607
+ migrateOutcome = hookManager.migrateManagedHook({
608
+ repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
609
+ ...runtime.bridgeAdapter.hookBinding(),
610
+ });
611
+ } catch (error) {
612
+ if (error && error.code === 'HOOK_CONFLICT') {
613
+ return {
614
+ status: 'conflict',
615
+ reason: 'third-party',
616
+ conflict: { code: 'HOOK_CONFLICT', message: error.message, reason: error.details && error.details.reason },
617
+ };
618
+ }
619
+ throw error;
620
+ }
621
+ if (migrateOutcome.migrated) {
622
+ // Case 2: v1 -> v2 succeeded via the legacy replacement path.
623
+ const verify = hookManager.readHookStatus({ repoPath, projectId });
624
+ if (!verify.kbManaged || verify.managedVersion !== 2 || !verify.installed) {
625
+ throw new DomainError('HOOK_INVALID', 'Migrated v1 -> v2 hook did not verify.', { status: 500 });
626
+ }
627
+ return { status: 'verified', managedVersion: verify.managedVersion, reason: 'v1-migrated' };
628
+ }
629
+ if (migrateOutcome.reason === 'current') {
630
+ // migrateManagedHook returned reason=current but we already filtered that
631
+ // path above; this is a defensive fallback.
632
+ return { status: 'verified', managedVersion: migrateOutcome.managedVersion, reason: 'current' };
633
+ }
634
+ if (migrateOutcome.reason === 'missing') {
635
+ // Case 3: missing entirely. Install + verify.
636
+ const installed = hookManager.installHook({
637
+ repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
638
+ ...runtime.bridgeAdapter.hookBinding(),
639
+ });
640
+ if (!installed || installed.ok !== true) {
641
+ throw new DomainError('HOOK_INVALID', 'Newly installed hook failed to install.', { status: 500 });
642
+ }
643
+ const verify = hookManager.readHookStatus({ repoPath, projectId });
644
+ if (!verify.kbManaged || verify.managedVersion !== 2 || !verify.installed) {
645
+ throw new DomainError('HOOK_INVALID', 'Newly installed hook did not verify.', { status: 500 });
646
+ }
647
+ return { status: 'verified', managedVersion: verify.managedVersion, reason: 'installed' };
648
+ }
649
+ // Unknown — leave pending so the next startup re-attempts.
650
+ return { status: 'pending', reason: migrateOutcome.reason || 'unknown' };
651
+ }
652
+
653
+ async function initializeRuntime(runtime) {
654
+ await runtime.logger.info('server.startup_started', 'Server startup began with fresh product data.', { phase: 'initialization' });
655
+ const migration = { ok: true, migrated: false, reason: 'fresh-data-only' };
656
+ dataDir.ensureDataDir(runtime.dataPath);
657
+ await runtime.settingsStore.initialize();
658
+ await runtime.registryStore.initialize();
659
+ const recoveredPromotions = runtime.analysisOwner === 'legacy' ? await runtime.promotionService.recoverAll() : [];
660
+ // Promotion recovery runs first because it can legitimately finish and clear
661
+ // a claim. Any claim left after that belonged to the previous process. Make
662
+ // it terminal without invoking Claude or selecting another Git commit; only
663
+ // a future explicit Hook event may start fresh work.
664
+ const recoveredClaims = runtime.analysisOwner === 'legacy' ? await recoverOrphanedClaims(runtime) : [];
665
+ const recoveredIndexes = runtime.registryStore.listIds().filter(projectId => runtime.projectStore.readState(projectId).index.dirty);
666
+ const orphanedOperations = runtime.logRepository.findOrphanedOperations();
667
+ if (orphanedOperations.length) {
668
+ await runtime.logger.warn('server.orphaned_operations_detected', 'Incomplete operations from a previous process were detected.', {
669
+ phase: 'recovery',
670
+ context: { operationIds: orphanedOperations.map(entry => entry.operationId), count: orphanedOperations.length },
671
+ });
672
+ }
673
+ const hooks = await migrateManagedHooks(runtime);
674
+ await runtime.logger.info('server.startup_ready', 'Server stores and recovery services are ready.', {
675
+ phase: 'listen',
676
+ context: { migration, recoveredPromotions: recoveredPromotions.length, recoveredClaims: recoveredClaims.length, recoveredIndexes: recoveredIndexes.length, orphanedOperations: orphanedOperations.length, hooks: hooks.length },
677
+ });
678
+ return { migration, recoveredPromotions, recoveredClaims, recoveredIndexes, orphanedOperations, hooks };
679
+ }
680
+
681
+ function safeStaticFile(pathname) {
682
+ const decoded = decodeURIComponent(pathname === '/' ? '/index.html' : pathname);
683
+ const candidate = path.resolve(UI_ROOT, `.${decoded}`);
684
+ const relative = path.relative(UI_ROOT, candidate);
685
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
686
+ return candidate;
687
+ }
688
+
689
+ function streamSse(res, subscribe) {
690
+ res.statusCode = 200;
691
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
692
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
693
+ res.setHeader('Connection', 'keep-alive');
694
+ res.flushHeaders?.();
695
+ res.write(': connected\n\n');
696
+ const write = event => {
697
+ if (!res.writableEnded) {
698
+ const eventName = String(event && event.type || 'message').replace(/[^A-Za-z0-9._/-]/g, '') || 'message';
699
+ res.write(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`);
700
+ }
701
+ };
702
+ const unsubscribe = subscribe(write);
703
+ setImmediate(() => { if (!res.writableEnded) res.write(': ready\n\n'); });
704
+ const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 20_000);
705
+ heartbeat.unref?.();
706
+ res.on('close', () => { clearInterval(heartbeat); unsubscribe?.(); });
707
+ }
708
+
709
+ function logFiltersFromUrl(url) {
710
+ return {
711
+ from: url.searchParams.get('from') || '',
712
+ to: url.searchParams.get('to') || '',
713
+ levels: url.searchParams.get('levels') || '',
714
+ scope: url.searchParams.get('scope') || '',
715
+ projectId: url.searchParams.get('projectId') || '',
716
+ component: url.searchParams.get('component') || '',
717
+ event: url.searchParams.get('event') || '',
718
+ commitSha: url.searchParams.get('commitSha') || '',
719
+ operationId: url.searchParams.get('operationId') || '',
720
+ q: url.searchParams.get('q') || '',
721
+ };
722
+ }
723
+
724
+ function encodeLogStreamCursor(entry, snapshotAt = new Date().toISOString()) {
725
+ return Buffer.from(JSON.stringify({ v: 1, afterId: entry && entry.id || '', afterTs: entry && entry.ts || snapshotAt }), 'utf8').toString('base64url');
726
+ }
727
+
728
+ function decodeLogStreamCursor(value) {
729
+ if (!value) return { v: 1, afterId: '', afterTs: new Date().toISOString() };
730
+ try {
731
+ const decoded = JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8'));
732
+ if (!decoded || decoded.v !== 1 || typeof decoded.afterId !== 'string' || Number.isNaN(Date.parse(decoded.afterTs))) throw new Error('invalid');
733
+ return decoded;
734
+ } catch (error) {
735
+ throw new DomainError('LOG_CURSOR_EXPIRED', 'Log stream cursor is invalid; reload the snapshot.', { status: 409, retryable: true, cause: error });
736
+ }
737
+ }
738
+
739
+ function writeSseEvent(res, event) {
740
+ if (res.writableEnded) return;
741
+ const eventName = String(event && event.type || 'message').replace(/[^A-Za-z0-9._/-]/g, '') || 'message';
742
+ res.write(`event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`);
743
+ }
744
+
745
+ function streamLogsWithoutGap(res, runtime, filters, streamCursor) {
746
+ const watermark = decodeLogStreamCursor(streamCursor);
747
+ const buffered = [];
748
+ let replaying = true;
749
+ const unsubscribe = runtime.logger.subscribe(record => {
750
+ if (!runtime.logRepository.matches(record, filters)) return;
751
+ if (replaying) buffered.push(record);
752
+ else writeSseEvent(res, { type: 'logs/appended', record });
753
+ });
754
+ const catchup = [];
755
+ let cursor = '';
756
+ let found = !watermark.afterId;
757
+ try {
758
+ outer: do {
759
+ const page = runtime.logRepository.query({ ...filters, cursor, pageSize: 5000 });
760
+ for (const entry of page.entries) {
761
+ if (watermark.afterId && entry.id === watermark.afterId) { found = true; break outer; }
762
+ if (!watermark.afterId && String(entry.ts) <= watermark.afterTs) { found = true; break outer; }
763
+ catchup.push(entry);
764
+ }
765
+ cursor = page.nextCursor || '';
766
+ } while (cursor);
767
+ if (!found) throw new DomainError('LOG_CURSOR_EXPIRED', 'Log stream cursor source is no longer available; reload the snapshot.', { status: 409, retryable: true });
768
+ } catch (error) {
769
+ unsubscribe();
770
+ throw error;
771
+ }
772
+
773
+ res.statusCode = 200;
774
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
775
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
776
+ res.setHeader('Connection', 'keep-alive');
777
+ res.flushHeaders?.();
778
+ res.write(': connected\n\n');
779
+ const seen = new Set();
780
+ const latestCatchup = catchup[0] || null;
781
+ for (const record of catchup.reverse()) {
782
+ seen.add(record.id);
783
+ writeSseEvent(res, { type: 'logs/appended', record, replay: true });
784
+ }
785
+ replaying = false;
786
+ for (const record of buffered) {
787
+ if (seen.has(record.id)) continue;
788
+ seen.add(record.id);
789
+ writeSseEvent(res, { type: 'logs/appended', record });
790
+ }
791
+ writeSseEvent(res, { type: 'logs/ready', streamCursor: encodeLogStreamCursor(buffered.at(-1) || latestCatchup, watermark.afterTs) });
792
+ const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 20_000);
793
+ heartbeat.unref?.();
794
+ res.on('close', () => { clearInterval(heartbeat); unsubscribe(); });
795
+ }
796
+
797
+ function taskForProject(runtime, projectId, operationId, task, kind = 'background') {
798
+ let projectTasks = runtime.activeTasks.get(projectId);
799
+ if (!projectTasks) {
800
+ projectTasks = new Map();
801
+ runtime.activeTasks.set(projectId, projectTasks);
802
+ }
803
+ if (projectTasks.has(operationId)) throw new DomainError('INVALID_ARGUMENT', 'Background operationId is already registered.');
804
+ const startedAt = new Date().toISOString();
805
+ const work = Promise.resolve().then(task);
806
+ const promise = (async () => {
807
+ await runtime.logger.debug('background.operation_registered', 'Background operation registered.', {
808
+ projectId,
809
+ operationId,
810
+ phase: 'registered',
811
+ context: { kind, startedAt },
812
+ });
813
+ try {
814
+ const result = await work;
815
+ await runtime.logger.debug('background.operation_completed', 'Background operation completed.', {
816
+ projectId,
817
+ operationId,
818
+ phase: 'completed',
819
+ durationMs: Date.now() - Date.parse(startedAt),
820
+ context: { kind },
821
+ });
822
+ return result;
823
+ } catch (error) {
824
+ await runtime.logger.error('background.operation_failed', 'A background operation failed.', {
825
+ projectId,
826
+ operationId,
827
+ phase: 'failed',
828
+ durationMs: Date.now() - Date.parse(startedAt),
829
+ error,
830
+ context: { kind },
831
+ });
832
+ throw error;
833
+ } finally {
834
+ const current = runtime.activeTasks.get(projectId);
835
+ current?.delete(operationId);
836
+ if (current && current.size === 0) runtime.activeTasks.delete(projectId);
837
+ }
838
+ })();
839
+ projectTasks.set(operationId, { operationId, kind, startedAt, promise });
840
+ return promise;
841
+ }
842
+
843
+ function activeTaskPromises(activeTasks) {
844
+ const promises = [];
845
+ for (const projectTasks of activeTasks.values()) {
846
+ for (const entry of projectTasks.values()) promises.push(entry.promise);
847
+ }
848
+ return promises;
849
+ }
850
+
851
+ function createRequestHandler(runtime, options = {}) {
852
+ const port = parsePort(options.port || process.env.KB_SITE_PORT);
853
+ const host = String(options.host || process.env.KB_SITE_HOST || '127.0.0.1');
854
+ const security = {
855
+ port,
856
+ loopbackBind: isLoopback(host),
857
+ authToken: String(options.authToken || process.env.KB_SITE_AUTH_TOKEN || ''),
858
+ allowedOrigins: parseAllowedOrigins(options.allowedOrigins || process.env.KB_ALLOWED_ORIGINS),
859
+ };
860
+ if (!security.loopbackBind && !security.authToken) {
861
+ throw new DomainError('AUTH_REQUIRED', 'KB_SITE_AUTH_TOKEN is required for a non-loopback bind.', { status: 500 });
862
+ }
863
+
864
+ return async function handle(req, res) {
865
+ const requestOperationId = createId('op');
866
+ const requestStartedAt = Date.now();
867
+ const requestPathname = String(req.url || '').split('?')[0];
868
+ let requestFailed = false;
869
+ res.setHeader('X-Operation-Id', requestOperationId);
870
+ res.once('finish', () => {
871
+ if (requestFailed || res.statusCode >= 400) return;
872
+ if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(req.method)
873
+ && /^\/api\/(?:projects(?:\/|$)|domains(?:\/|$)|brain$|settings$|modules\/configure$)/.test(requestPathname)
874
+ && requestPathname !== '/api/projects/preflight-import') runtime.registryEvents?.publish();
875
+ Promise.resolve(runtime.logger.info('http.request_completed', 'HTTP request completed.', {
876
+ operationId: requestOperationId,
877
+ phase: 'completed',
878
+ durationMs: Date.now() - requestStartedAt,
879
+ context: { method: req.method, pathname: requestPathname, status: res.statusCode },
880
+ })).catch(() => {});
881
+ });
882
+ applyCors(req, res, security);
883
+ try {
884
+ await runtime.logger.debug('http.request_started', 'HTTP request started.', {
885
+ operationId: requestOperationId,
886
+ phase: 'started',
887
+ context: { method: req.method, pathname: requestPathname },
888
+ });
889
+ enforceSecurity(req, security);
890
+ if (req.method === 'OPTIONS') {
891
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS');
892
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Project-Knowledge-Token');
893
+ return send(res, 204, '');
894
+ }
895
+ const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
896
+ const method = req.method || 'GET';
897
+ const pathname = url.pathname;
898
+ if (method === 'GET' && pathname === '/api/registry/events') {
899
+ return streamSse(res, write => runtime.registryEvents.subscribe(write));
900
+ }
901
+
902
+ const moduleUi = pathname.match(/^\/modules\/(terminal|vectorhub|eventbridge|engine)\/(.*)$/);
903
+ if (moduleUi && method === 'GET') return runtime.moduleBridge.proxyModuleUi(req, res, moduleUi[1], moduleUi[2], url.search);
904
+ if (pathname.startsWith('/api/engine/') || pathname === '/api/engine') {
905
+ if (['/api/engine/runtime-config', '/api/engine/runtime-auth', '/api/engine/scopes', '/api/engine/control', '/api/engine/migrate'].includes(pathname)) throw new DomainError('FORBIDDEN', 'Runtime configuration is not exposed to browsers.', { status: 403 });
906
+ return runtime.moduleBridge.proxyEngine(req, res, pathname, url.search);
907
+ }
908
+
909
+ // ===== module bridge:壳 ↔ 模块(同源代理 + 聚合 + 登记/移除) =====
910
+ if (pathname.startsWith('/api/terminal/') || pathname === '/api/terminal') {
911
+ if (pathname.startsWith('/api/terminal/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
912
+ return runtime.moduleBridge.proxyTerminal(req, res, pathname, url.search);
913
+ }
914
+ if (pathname.startsWith('/api/vectorhub/') || pathname === '/api/vectorhub') {
915
+ if (pathname.startsWith('/api/vectorhub/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
916
+ return runtime.moduleBridge.proxyVectorHub(req, res, pathname, url.search);
917
+ }
918
+ if (pathname.startsWith('/api/eventbridge/') || pathname === '/api/eventbridge') {
919
+ if (pathname.startsWith('/api/eventbridge/internal/')) throw new DomainError('FORBIDDEN', 'Internal module endpoints are not exposed to browsers.', { status: 403 });
920
+ return runtime.moduleBridge.proxyEventBridge(req, res, pathname, url.search);
921
+ }
922
+ if (method === 'GET' && pathname === '/api/modules/health') {
923
+ return send(res, 200, { ...await runtime.moduleBridge.modulesHealth(), ownership: runtime.ownership });
924
+ }
925
+ if (method === 'GET' && pathname === '/api/system/version') {
926
+ return send(res, 200, coreVersionSnapshot());
927
+ }
928
+ if (method === 'POST' && pathname === '/api/modules/configure') {
929
+ await readJsonBody(req);
930
+ runtime.moduleBridge.resetRuntimeConfiguration();
931
+ runtime.projectConnections?.clear();
932
+ await connectEngine(runtime);
933
+ return send(res, 200, { ok: true, configuration: await runtime.moduleBridge.syncEngineConfiguration({ force: true }) });
934
+ }
935
+ if (method === 'GET' && pathname === '/api/brain') {
936
+ const settings = runtime.settingsStore.read();
937
+ return send(res, 200, { ok: true, brain: settings.brain, knowledgeRoot: settings.knowledge.rootPath, indexStoragePath: runtime.layout.getVectorRootPath(settings), configuration: runtime.moduleBridge.configStatus || null });
938
+ }
939
+ if (method === 'PATCH' && pathname === '/api/brain') {
940
+ const body = await readJsonBody(req);
941
+ if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new DomainError('INVALID_ARGUMENT', '请输入 1–100 个字符的智脑名称。');
942
+ const settings = await runtime.settingsStore.updatePatch({ brain: { name: body.name.trim() } });
943
+ const configuration = await runtime.moduleBridge.syncEngineConfiguration();
944
+ return send(res, 200, { ok: true, brain: settings.brain, configuration });
945
+ }
946
+ if (method === 'POST' && pathname === '/api/domains') {
947
+ const body = await readJsonBody(req);
948
+ if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 100) throw new DomainError('INVALID_ARGUMENT', '请输入 1–100 个字符的领域名称。');
949
+ const domain = { id: `domain-${crypto.randomUUID()}`, name: body.name.trim(), createdAt: new Date().toISOString() };
950
+ await runtime.settingsStore.addDomain(domain);
951
+ const configuration = await runtime.moduleBridge.syncEngineConfiguration();
952
+ return send(res, 201, { ok: true, domain, configuration });
953
+ }
954
+ if (method === 'GET' && pathname === '/api/projects/aggregated') {
955
+ return send(res, 200, { ok: true, ...(await runtime.moduleBridge.aggregatedProjects()) });
956
+ }
957
+ const moduleRegisterMatch = pathname.match(/^\/api\/projects\/([^/]+)\/module-register$/);
958
+ if (moduleRegisterMatch && method === 'POST') {
959
+ const projectId = validateProjectId(decodeURIComponent(moduleRegisterMatch[1]));
960
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
961
+ const config = runtime.projectStore.readConfig(projectId) || {};
962
+ const body = await readJsonBody(req).catch(() => ({}));
963
+ const result = await runtime.moduleBridge.registerProjectLinks({
964
+ projectId,
965
+ name: config.displayName || projectId,
966
+ workspacePath: String(body.workspacePath || config.repoPath || ''),
967
+ knowledgePath: String(body.knowledgePath || config.knowledgePath || ''),
968
+ });
969
+ return send(res, 200, { ok: true, projectId, modules: result });
970
+ }
971
+ const moduleRemoveMatch = pathname.match(/^\/api\/projects\/([^/]+)\/module-remove$/);
972
+ if (moduleRemoveMatch && method === 'POST') {
973
+ const projectId = validateProjectId(decodeURIComponent(moduleRemoveMatch[1]));
974
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
975
+ const result = await runtime.moduleBridge.removeProjectLinks({ projectId });
976
+ return send(res, 200, { ok: true, projectId, modules: result });
977
+ }
978
+
979
+ if (method === 'GET' && pathname === '/api/health') {
980
+ return send(res, 200, { ok: true, schema: 'server-health/v2', logger: runtime.logger.getHealth(), projects: runtime.registryStore.listIds().length });
981
+ }
982
+ if (method === 'GET' && pathname === '/api/runtime/identity') {
983
+ if (!isLoopback(req.socket?.remoteAddress?.replace(/^::ffff:/, ''))) throw new DomainError('ORIGIN_FORBIDDEN', 'Runtime identity is local-only.', { status: 403 });
984
+ return send(res, 200, runtime.instanceIdentity);
985
+ }
986
+ if (method === 'GET' && pathname === '/api/state') {
987
+ const settings = runtime.settingsStore.readPublicView();
988
+ return send(res, 200, { ok: true, schema: 'server-state/v2', settings, projects: listProjectViews(runtime.registryStore, runtime.projectStore), logger: runtime.logger.getHealth() });
989
+ }
990
+ if (method === 'GET' && pathname === '/api/settings') return send(res, 200, { ok: true, settings: runtime.settingsStore.readPublicView() });
991
+ if (method === 'PATCH' && pathname === '/api/settings') {
992
+ const body = await readJsonBody(req);
993
+ const patch = {};
994
+ if (body.knowledge) patch.knowledge = { rootPath: String(body.knowledge.rootPath || '') };
995
+ if (body.logging) throw new DomainError('IMMUTABLE_FIELD', 'Logging storage and capture policy are not user settings.', { status: 409 });
996
+ if (!Object.keys(patch).length) throw new DomainError('INVALID_ARGUMENT', 'Only the knowledge root is mutable on this route.');
997
+ const settings = await runtime.settingsStore.updatePatch(patch);
998
+ const configuration = await runtime.moduleBridge.syncEngineConfiguration();
999
+ return send(res, 200, { ok: true, settings: { ...settings, ai: publicAiProfilesConfig(settings.ai) }, configuration });
1000
+ }
1001
+
1002
+ if (method === 'GET' && pathname === '/api/projects/projection') return send(res, 200, { ok: true, projects: listProjectViews(runtime.registryStore, runtime.projectStore).map(p => ({ projectId: p.projectId, name: p.config.displayName, workspacePath: p.config.repoPath, knowledgePath: p.config.knowledgePath, brainId: p.config.brainId, domainId: p.config.domainId, modules: Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => { const module = p.state.modules?.[key] || {}; return [key, { projectId: module.projectId, projectName: module.projectName, status: module.status, registered: module.ok === true && module.status === 'registered' }]; })) })) });
1003
+ if (method === 'GET' && pathname === '/api/projects') return send(res, 200, { ok: true, projects: listProjectViews(runtime.registryStore, runtime.projectStore) });
1004
+ // T06: pure preflight endpoint — never mutates state. The UI calls
1005
+ // this on path pick / language change / profile change to keep the
1006
+ // "Import" button disabled until every prerequisite is satisfied.
1007
+ if (method === 'POST' && pathname === '/api/projects/preflight-import') {
1008
+ const body = await readJsonBody(req);
1009
+ const result = await runtime.lifecycleService.preflightImport(body);
1010
+ return send(res, 200, { ok: true, ...result });
1011
+ }
1012
+ if (method === 'POST' && pathname === '/api/projects/import') {
1013
+ const body = await readJsonBody(req);
1014
+ const result = await runtime.lifecycleService.importProject(body);
1015
+ await runtime.projectStore.updateState(result.projectId, state => {
1016
+ state.hook.managedVersion = Number(result.hook.managedVersion || 2);
1017
+ state.hook.migrationVersion = 2;
1018
+ state.hook.lastVerifiedAt = new Date().toISOString();
1019
+ });
1020
+ // T3.2 导入联动:双地址登记到两模块(尽力而为,模块未启动记 pending)
1021
+ const config = runtime.projectStore.readConfig(result.projectId) || {};
1022
+ const moduleResult = await runtime.moduleBridge.registerProjectLinks({
1023
+ projectId: result.projectId,
1024
+ name: config.displayName || result.projectId,
1025
+ workspacePath: String(config.repoPath || body.path || ''),
1026
+ knowledgePath: String(body.knowledgePath || config.knowledgePath || ''),
1027
+ });
1028
+ void connectEngine(runtime);
1029
+ return send(res, 201, { ...result, modules: moduleResult, project: projectPublicView(result.projectId, runtime.projectStore) });
1030
+ }
1031
+ const projectMatch = pathname.match(/^\/api\/projects\/([^/]+)$/);
1032
+ if (projectMatch && method === 'GET') {
1033
+ const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1034
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1035
+ return send(res, 200, { ok: true, project: projectPublicView(projectId, runtime.projectStore) });
1036
+ }
1037
+ if (projectMatch && method === 'PATCH') {
1038
+ const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1039
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1040
+ const patch = await readJsonBody(req);
1041
+ if (Object.prototype.hasOwnProperty.call(patch, 'displayName') && (typeof patch.displayName !== 'string' || !patch.displayName.trim() || patch.displayName.length > 200)) {
1042
+ throw new DomainError('INVALID_ARGUMENT', 'displayName must be a non-empty string of at most 200 characters.');
1043
+ }
1044
+ if (Object.prototype.hasOwnProperty.call(patch, 'enabled') && typeof patch.enabled !== 'boolean') {
1045
+ throw new DomainError('INVALID_ARGUMENT', 'enabled must be a boolean.');
1046
+ }
1047
+ if (Object.prototype.hasOwnProperty.call(patch, 'knowledgeLanguage') && !['zh-CN', 'en-US'].includes(patch.knowledgeLanguage)) {
1048
+ throw new DomainError('INVALID_ARGUMENT', 'knowledgeLanguage must be zh-CN or en-US.');
1049
+ }
1050
+ if (Object.prototype.hasOwnProperty.call(patch, 'relatedProjectIds')) {
1051
+ if (!Array.isArray(patch.relatedProjectIds)) throw new DomainError('INVALID_ARGUMENT', 'relatedProjectIds must be an array.');
1052
+ patch.relatedProjectIds = [...new Set(patch.relatedProjectIds.map(validateProjectId))];
1053
+ if (patch.relatedProjectIds.includes(projectId)) throw new DomainError('INVALID_ARGUMENT', 'A project cannot relate to itself.');
1054
+ for (const relatedId of patch.relatedProjectIds) {
1055
+ if (!runtime.registryStore.readDisplaySnapshot(relatedId)) throw new DomainError('PROJECT_NOT_FOUND', 'A related project was not found.', { status: 404, details: { projectId: relatedId } });
1056
+ }
1057
+ }
1058
+ if (patch.aiProfileId) {
1059
+ const profile = profileById(runtime.settingsStore.read(), patch.aiProfileId);
1060
+ if (!profile || profile.enabled === false) throw new DomainError('INVALID_ARGUMENT', 'aiProfileId must reference an enabled profile.');
1061
+ }
1062
+ if (patch.brainId && patch.brainId !== runtime.settingsStore.read().brain.id) throw new DomainError('INVALID_ARGUMENT', '智脑不存在。');
1063
+ if (patch.domainId && !runtime.settingsStore.read().brain.domains.some(d => d.id === patch.domainId)) throw new DomainError('INVALID_ARGUMENT', '领域不存在。');
1064
+ const config = await runtime.projectStore.updateConfig(projectId, patch);
1065
+ let modules;
1066
+ if (['domainId', 'knowledgeLanguage'].some(field => Object.prototype.hasOwnProperty.call(patch, field))) modules = await runtime.moduleBridge.registerProjectLinks({ projectId, name: config.displayName, workspacePath: config.repoPath, knowledgePath: config.knowledgePath });
1067
+ return send(res, 200, { ok: true, projectId, config, modules });
1068
+ }
1069
+ if (projectMatch && method === 'DELETE') {
1070
+ const projectId = validateProjectId(decodeURIComponent(projectMatch[1]));
1071
+ if (!runtime.registryStore.readDisplaySnapshot(projectId) && !runtime.lifecycleService.hasPendingDeletion(projectId)) {
1072
+ throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1073
+ }
1074
+ const body = await readJsonBody(req);
1075
+ const remove = () => runtime.lifecycleService.deleteProject(projectId, {
1076
+ deleteKnowledge: body.deleteKnowledge === true,
1077
+ confirmationToken: body.confirmationToken || '',
1078
+ });
1079
+ let result;
1080
+ if (runtime.registryStore.readDisplaySnapshot(projectId)) await runtime.moduleBridge.removeProjectLinks({ projectId, requireEngine: runtime.analysisOwner !== 'legacy', onRemoved: async () => { result = await remove(); } });
1081
+ else result = await remove();
1082
+ return send(res, 200, result);
1083
+ }
1084
+ const gitStatusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/git-status$/);
1085
+ if (gitStatusMatch && method === 'GET') {
1086
+ const projectId = validateProjectId(decodeURIComponent(gitStatusMatch[1]));
1087
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1088
+ const config = runtime.projectStore.readConfig(projectId);
1089
+ return send(res, 200, { ok: true, projectId, ...(await inspectGitRepository(config.repoPath)) });
1090
+ }
1091
+ // T04: per-project hook health snapshot. Lets the UI explain exactly why
1092
+ // commit auto-update is unavailable (missing / conflict / wrong-project /
1093
+ // broken-managed) without forcing the user to inspect .git/hooks manually.
1094
+ const hookStatusMatch = pathname.match(/^\/api\/projects\/([^/]+)\/hook-status$/);
1095
+ if (hookStatusMatch && method === 'GET') {
1096
+ const projectId = validateProjectId(decodeURIComponent(hookStatusMatch[1]));
1097
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1098
+ const config = runtime.projectStore.readConfig(projectId);
1099
+ const state = runtime.projectStore.readState(projectId);
1100
+ let status;
1101
+ try {
1102
+ status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1103
+ } catch (error) {
1104
+ status = { ok: true, installed: false, repoPath: config.repoPath, reason: error.message };
1105
+ }
1106
+ const repairable = fs.existsSync(config.repoPath)
1107
+ && !(status.installed && status.kbManaged && status.managedVersion === 2)
1108
+ && !(!status.kbManaged && status.reason && /third-party/i.test(status.reason || ''))
1109
+ && !(status.kbManaged && !status.installed);
1110
+ return send(res, 200, {
1111
+ ok: true,
1112
+ projectId,
1113
+ hook: {
1114
+ repoPath: status.repoPath,
1115
+ hookPath: status.hookPath || null,
1116
+ installed: Boolean(status.installed),
1117
+ managed: Boolean(status.kbManaged),
1118
+ managedVersion: status.managedVersion || 0,
1119
+ runtimeTarget: 'node', // T02 contract: hook body uses ELECTRON_RUN_AS_NODE=1 prefix
1120
+ lastVerifiedAt: state.hook && state.hook.lastVerifiedAt || null,
1121
+ migrationVersion: state.hook && state.hook.migrationVersion || 0,
1122
+ conflict: state.hook && state.hook.lastConflict || null,
1123
+ reason: status.reason || null,
1124
+ repairAvailable: Boolean(repairable),
1125
+ },
1126
+ });
1127
+ }
1128
+ // T04: explicit repair action. Only repairs missing/broken managed hooks;
1129
+ // never overwrites a third-party hook (that requires a separate UX decision).
1130
+ const hookRepairMatch = pathname.match(/^\/api\/projects\/([^/]+)\/hook-repair$/);
1131
+ if (hookRepairMatch && method === 'POST') {
1132
+ const projectId = validateProjectId(decodeURIComponent(hookRepairMatch[1]));
1133
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1134
+ const config = runtime.projectStore.readConfig(projectId);
1135
+ if (!fs.existsSync(config.repoPath)) {
1136
+ throw new DomainError('PROJECT_NOT_FOUND', 'Project repository path is missing on disk.', { status: 404 });
1137
+ }
1138
+ const status = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1139
+ if (!status.kbManaged && status.reason && /third-party/i.test(status.reason || '')) {
1140
+ throw new DomainError('HOOK_CONFLICT', 'A third-party post-commit hook is installed; Project-Knowledge will not overwrite it.', {
1141
+ status: 409,
1142
+ details: { reason: status.reason },
1143
+ });
1144
+ }
1145
+ const installResult = hookManager.installHook({
1146
+ repoPath: config.repoPath, projectId, triggerScriptPath: TRIGGER_SCRIPT_PATH,
1147
+ ...runtime.bridgeAdapter.hookBinding(),
1148
+ });
1149
+ const verify = hookManager.readHookStatus({ repoPath: config.repoPath, projectId });
1150
+ await runtime.projectStore.updateState(projectId, draft => {
1151
+ draft.hook.migrationVersion = 2;
1152
+ draft.hook.managedVersion = verify.managedVersion || 2;
1153
+ draft.hook.lastVerifiedAt = new Date().toISOString();
1154
+ delete draft.hook.lastConflict;
1155
+ });
1156
+ await runtime.logger.info('hook.repair_completed', 'Hook repair succeeded.', { projectId, phase: 'verify', managedVersion: verify.managedVersion });
1157
+ return send(res, 200, {
1158
+ ok: true,
1159
+ projectId,
1160
+ installed: verify.kbManaged && verify.installed,
1161
+ managedVersion: verify.managedVersion,
1162
+ hookPath: verify.hookPath,
1163
+ });
1164
+ }
1165
+ // T13: Project Goal editor — read / write GOAL.md atomically.
1166
+ // GET /api/projects/:projectId/goal -> { content, exists }
1167
+ // PUT /api/projects/:projectId/goal -> { ok } body: { content }
1168
+ // Atomic write guarantees the file is never partially written, and
1169
+ // only the project's own knowledgePath is touched — no cross-project
1170
+ // side effects. Other knowledge files in the same project are
1171
+ // untouched; the goal file is a single dedicated file.
1172
+ const goalMatch = pathname.match(/^\/api\/projects\/([^/]+)\/goal$/);
1173
+ if (goalMatch) {
1174
+ const projectId = validateProjectId(decodeURIComponent(goalMatch[1]));
1175
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1176
+ const config = runtime.projectStore.readConfig(projectId);
1177
+ const goalPath = path.join(config.knowledgePath, 'GOAL.md');
1178
+ if (method === 'GET') {
1179
+ if (!fs.existsSync(goalPath)) {
1180
+ return send(res, 200, { ok: true, projectId, exists: false, content: '' });
1181
+ }
1182
+ return send(res, 200, { ok: true, projectId, exists: true, content: fs.readFileSync(goalPath, 'utf8') });
1183
+ }
1184
+ if (method === 'PUT') {
1185
+ if (runtime.analysisOwner !== 'legacy') throw new DomainError('KNOWLEDGE_WRITE_MOVED', '知识写入由引擎维护,外壳不再直接修改知识文件。', { status: 409 });
1186
+ const body = await readJsonBody(req);
1187
+ if (typeof body.content !== 'string') throw new DomainError('INVALID_ARGUMENT', 'content must be a string.');
1188
+ if (body.content.length > 256 * 1024) throw new DomainError('INVALID_ARGUMENT', 'content exceeds 256KB limit.', { status: 413 });
1189
+ const AtomicFile = require('./atomic-file');
1190
+ AtomicFile.writeFileAtomic(goalPath, body.content, { mode: 0o644 });
1191
+ await runtime.logger.info('goal.updated', 'Project GOAL.md updated.', { projectId, phase: 'goal', context: { bytes: body.content.length } });
1192
+ return send(res, 200, { ok: true, projectId, exists: true, path: goalPath });
1193
+ }
1194
+ }
1195
+ const contextPackMatch = pathname.match(/^\/api\/projects\/([^/]+)\/context-pack$/);
1196
+ if (contextPackMatch && method === 'POST') {
1197
+ const projectId = validateProjectId(decodeURIComponent(contextPackMatch[1]));
1198
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1199
+ const body = await readJsonBody(req);
1200
+ const config = runtime.projectStore.readConfig(projectId);
1201
+ const trigger = body.trigger === 'commits' ? 'commits' : 'initial';
1202
+ const commits = Array.isArray(body.commits) ? body.commits.slice(0, 200).map(commit => ({
1203
+ hash: String(commit.hash || ''), short: String(commit.short || ''), subject: String(commit.subject || ''),
1204
+ date: String(commit.date || ''), author: String(commit.author || ''),
1205
+ })).filter(commit => /^[a-f0-9]{7,64}$/i.test(commit.hash)) : [];
1206
+ const pack = await buildContextPack({
1207
+ project: {
1208
+ slug: projectId, gitPath: config.repoPath, kbPath: config.knowledgePath,
1209
+ currentBranch: body.currentBranch || '', defaultBranch: body.defaultBranch || '', remoteUrl: body.remoteUrl || '',
1210
+ },
1211
+ runId: body.runId,
1212
+ trigger,
1213
+ commits,
1214
+ options: { maxFiles: body.maxFiles, outputDir: runtime.layout.getCachePath('context-packs', projectId) },
1215
+ });
1216
+ return send(res, 200, { ok: true, projectId, runId: pack.runId, entryCount: pack.entries.length, contextPack: pack });
1217
+ }
1218
+
1219
+ if (method === 'POST' && (pathname === '/api/knowledge/search' || pathname === '/api/knowledge/ask')) {
1220
+ if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, pathname.replace('/api/knowledge/', '/api/engine/'), url.search);
1221
+ const body = await readJsonBody(req);
1222
+ const input = { projectId: body.projectId, projectSlug: body.projectSlug, repoPath: body.repoPath, query: body.query, limit: body.limit };
1223
+ const result = pathname.endsWith('/ask') ? await runtime.knowledgeRuntime.ask(input) : await runtime.knowledgeRuntime.search(input);
1224
+ return send(res, 200, result);
1225
+ }
1226
+ if (method === 'GET' && pathname === '/api/knowledge/entry') {
1227
+ if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/get', url.search);
1228
+ const result = await runtime.knowledgeRuntime.get({
1229
+ projectId: url.searchParams.get('projectId') || '',
1230
+ projectSlug: url.searchParams.get('projectSlug') || '',
1231
+ repoPath: url.searchParams.get('repoPath') || '',
1232
+ entryId: url.searchParams.get('entryId') || '',
1233
+ });
1234
+ return send(res, 200, result);
1235
+ }
1236
+ if (method === 'GET' && pathname === '/api/knowledge/history') {
1237
+ if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/history', url.search);
1238
+ const result = await runtime.knowledgeRuntime.history({
1239
+ projectId: url.searchParams.get('projectId') || '',
1240
+ projectSlug: url.searchParams.get('projectSlug') || '',
1241
+ repoPath: url.searchParams.get('repoPath') || '',
1242
+ limit: url.searchParams.get('limit') || undefined,
1243
+ });
1244
+ return send(res, 200, result);
1245
+ }
1246
+ if (method === 'GET' && pathname === '/api/knowledge/maintenance') {
1247
+ if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/health', url.search);
1248
+ const projects = runtime.registryStore.listIds().map(projectId => ({ projectId, index: runtime.projectStore.readState(projectId).index }));
1249
+ return send(res, 200, { ok: true, schema: 'index-maintenance/v2', indexPath: runtime.layout.getIndexPath(), projects, active: runtime.indexService.pending.size > 0 });
1250
+ }
1251
+ if (method === 'POST' && pathname === '/api/knowledge/maintenance/rebuild') {
1252
+ if (runtime.analysisOwner !== 'legacy') throw new DomainError('INVALID_ARGUMENT', '索引由向量模块管理,请在系统状态中修复。', { status: 409 });
1253
+ const operationId = createId('op');
1254
+ await runtime.knowledgeRuntime.close();
1255
+ await runtime.indexAdapter.close();
1256
+ const result = await runtime.indexService.fullRebuild({ operationId });
1257
+ for (const projectId of runtime.registryStore.listIds()) {
1258
+ await runtime.projectStore.updateState(projectId, state => {
1259
+ state.index.dirty = false;
1260
+ state.index.sinceCommit = null;
1261
+ state.index.lastError = null;
1262
+ state.index.lastIndexedAt = new Date().toISOString();
1263
+ });
1264
+ }
1265
+ await runtime.logger.info('index.full_rebuild_completed', 'Derived knowledge index was rebuilt.', { operationId, phase: 'index.applied', context: { validation: result.validation } });
1266
+ return send(res, 200, result);
1267
+ }
1268
+
1269
+ if (method === 'POST' && pathname === '/api/hooks/post-commit') {
1270
+ const event = await readJsonBody(req, 256 * 1024);
1271
+ if (event.schema !== SCHEMAS.hookEvent) throw new DomainError('SCHEMA_UNSUPPORTED', 'Hook event must use hook-event/v2.', { status: 409 });
1272
+ const projectId = validateProjectId(event.projectId);
1273
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Hook project was not found.', { status: 404 });
1274
+ if (typeof event.repoRoot !== 'string' || !event.repoRoot.trim()) throw new DomainError('INVALID_ARGUMENT', 'Hook event repoRoot is required.');
1275
+ const operationId = /^op-[A-Za-z0-9-]{8,120}$/.test(String(event.operationId || ''))
1276
+ ? event.operationId
1277
+ : requestOperationId;
1278
+ if (runtime.analysisOwner !== 'legacy') {
1279
+ const journal = await forwardCommitToBridge(runtime, event);
1280
+ return send(res, 202, { ok: true, accepted: true, projectId, operationId, ...journal });
1281
+ }
1282
+ taskForProject(runtime, projectId, operationId, () => handlePostCommitEvent(event, {
1283
+ layout: runtime.layout,
1284
+ registryStore: runtime.registryStore,
1285
+ projectStore: runtime.projectStore,
1286
+ reconciler: runtime.reconciler,
1287
+ claimProcessor: runtime.promotionService,
1288
+ logger: runtime.logger,
1289
+ operationId,
1290
+ conversationStore: runtime.conversationStore,
1291
+ bridgeConsumerService: runtime.bridgeConsumerService,
1292
+ }), 'git-hook').catch(() => {
1293
+ // Failure is already recorded as background.operation_failed by
1294
+ // taskForProject; the 202 stands and one project's failure must not
1295
+ // take down every project's server via unhandledRejection.
1296
+ });
1297
+ await runtime.logger.info('hook.event_accepted', 'Hook event accepted for background reconciliation.', { projectId, operationId, phase: 'accepted' });
1298
+ return send(res, 202, { ok: true, accepted: true, projectId, operationId });
1299
+ }
1300
+
1301
+ // Bridge consumer wake-up (I-16): the notification body carries no
1302
+ // conversation truth — the durable journal is drained regardless.
1303
+ if (method === 'POST' && pathname === '/api/bridge/notify') {
1304
+ if (runtime.analysisOwner !== 'legacy') return runtime.moduleBridge.proxyEngine(req, res, '/api/engine/bridge/notify', url.search);
1305
+ if (runtime.bridgeConsumerService) {
1306
+ runtime.bridgeConsumerService.handleNotification().catch(() => {
1307
+ // Drain failures are logged by the service; wake-up stays 204.
1308
+ });
1309
+ }
1310
+ return send(res, 204, {});
1311
+ }
1312
+ if (method === 'GET' && pathname === '/api/bridge/status') {
1313
+ if (!runtime.bridgeConsumerService) return send(res, 200, { ok: true, available: false });
1314
+ const status = await runtime.bridgeConsumerService.status();
1315
+ return send(res, 200, { ok: true, available: true, status });
1316
+ }
1317
+
1318
+ // Unified Integration Setup surface: one action manages Knowledge
1319
+ // Integration and Development Capture per client, with separate status.
1320
+ if (method === 'GET' && pathname === '/api/integrations/status') {
1321
+ runtime.integrationStatusScan ||= new (require('./integration-status-scan').IntegrationStatusScan)();
1322
+ const intentStore = require('./integration-intent');
1323
+ const managerOptions = { ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir };
1324
+ const projectId = url.searchParams.get('projectId');
1325
+ if (projectId && !runtime.registryStore.listIds().includes(projectId)) throw new DomainError('PROJECT_NOT_FOUND', '当前项目尚未登记。', { status: 404 });
1326
+ const cwd = projectId ? runtime.projectStore.readConfig(projectId).repoPath : undefined;
1327
+ const requested = url.searchParams.get('clients');
1328
+ const clients = requested ? requested.split(',').filter(Boolean).map(require('./integration-installer').normalizeClient) : undefined;
1329
+ const summary = await runtime.integrationStatusScan.read(managerOptions, { captureOnly: url.searchParams.get('captureOnly') === '1', cwd, clients }, { force: url.searchParams.get('refresh') === '1' });
1330
+ const repair = runtime.integrationAutoRepair?.status() || null;
1331
+ const intent = intentStore.bootstrapIntent(runtime.dataPath, summary.clients);
1332
+ summary.clients = summary.clients.map(entry => ({
1333
+ ...entry,
1334
+ intent: intent.clients[entry.client] || 'disconnected',
1335
+ summary: intentStore.summarizeClient(entry, repair),
1336
+ repair: repair?.[entry.client] || null,
1337
+ }));
1338
+ return send(res, 200, { ok: true, ...summary });
1339
+ }
1340
+ if (method === 'POST' && pathname === '/api/integrations/install') {
1341
+ runtime.integrationStatusScan?.invalidate();
1342
+ const body = await readJsonBody(req);
1343
+ await runtime.integrationAutoRepair?.close();
1344
+ const manager = new IntegrationManager({ ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir });
1345
+ const clients = Array.isArray(body.clients) && body.clients.length
1346
+ ? body.clients.map(client => String(client))
1347
+ : undefined;
1348
+ const summary = await manager.installAll({
1349
+ clients,
1350
+ knowledgeOnly: body.knowledgeOnly === true,
1351
+ captureOnly: body.captureOnly === true,
1352
+ scope: body.scope || 'user',
1353
+ });
1354
+ const complete = summary.bridge.ok && summary.clients.every(entry => (!entry.knowledgeIntegration || entry.knowledgeIntegration.ok) && (!entry.developmentCapture || entry.developmentCapture.ok));
1355
+ if (complete) {
1356
+ const intentStore = require('./integration-intent');
1357
+ for (const entry of summary.clients) {
1358
+ if (entry.developmentCapture?.ok || entry.knowledgeIntegration?.ok) intentStore.markIntent(runtime.dataPath, entry.client, 'connected');
1359
+ }
1360
+ runtime.integrationAutoRepair?.markSucceeded?.(summary.clients.map(entry => entry.client));
1361
+ }
1362
+ await runtime.logger[complete ? 'info' : 'error'](`integrations.install_${complete ? 'completed' : 'failed'}`, complete ? 'Agent 接入完成。' : 'Agent 接入未完成,请查看各工具的失败原因。', { operationId: requestOperationId, context: { clients: summary.clients.map(entry => entry.client) } });
1363
+ return send(res, 200, { ok: true, complete, operationId: requestOperationId, ...summary });
1364
+ }
1365
+ if (method === 'POST' && pathname === '/api/integrations/uninstall') {
1366
+ const body = await readJsonBody(req);
1367
+ await runtime.integrationAutoRepair?.close();
1368
+ const manager = new IntegrationManager({ ...options.integrationOptions, rootDir: runtime.rootDir, logger: runtime.logger, operationId: requestOperationId, bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir });
1369
+ if (body.disableAllCapture === true) {
1370
+ const summary = await manager.disableAllCapture({});
1371
+ return send(res, 200, { ok: true, ...summary });
1372
+ }
1373
+ const clients = Array.isArray(body.clients) && body.clients.length
1374
+ ? body.clients.map(client => String(client))
1375
+ : undefined;
1376
+ const knowledgeResults = body.captureOnly === true ? [] : await manager.operate('uninstall', clients && clients.length ? clients : manager.detectClients(), { scope: body.scope || 'user' });
1377
+ const captureResults = body.knowledgeOnly === true ? [] : await manager.operateCapture('uninstall', clients && clients.length ? clients : manager.detectClients(), {});
1378
+ const complete = [...knowledgeResults, ...captureResults].every(entry => entry.ok);
1379
+ if (complete) {
1380
+ const intentStore = require('./integration-intent');
1381
+ const removed = new Set([...knowledgeResults, ...captureResults].map(entry => entry.client));
1382
+ for (const client of removed) intentStore.markIntent(runtime.dataPath, client, 'disconnected');
1383
+ }
1384
+ return send(res, 200, { ok: true, complete, operationId: requestOperationId, clients: knowledgeResults, capture: captureResults });
1385
+ }
1386
+
1387
+ if (method === 'GET' && pathname === '/api/ai-profiles') {
1388
+ const settings = runtime.settingsStore.read();
1389
+ return send(res, 200, { ok: true, config: publicAiProfilesConfig(settings.ai) });
1390
+ }
1391
+ if (method === 'PUT' && pathname === '/api/ai-profiles') {
1392
+ if (runtime.analysisOwner !== 'legacy') throw new DomainError('MODEL_CONFIG_MOVED', '请在工作台的模型设置中管理配置,外壳不再保存凭据。', { status: 409 });
1393
+ const body = await readJsonBody(req);
1394
+ const current = runtime.settingsStore.read();
1395
+ const ai = mergeAiProfiles(current.ai, body);
1396
+ await runtime.settingsStore.updatePatch({ ai });
1397
+ return send(res, 200, { ok: true, config: publicAiProfilesConfig(ai) });
1398
+ }
1399
+
1400
+ if (method === 'GET' && pathname === '/api/conversations/projects') {
1401
+ return send(res, 200, { ok: true, projects: runtime.conversationQuery.listProjects() });
1402
+ }
1403
+ if (method === 'GET' && pathname === '/api/conversations/turns') {
1404
+ const result = await runtime.conversationQuery.turns({
1405
+ projectId: url.searchParams.get('projectId') || '',
1406
+ date: url.searchParams.get('date') || '',
1407
+ cursor: url.searchParams.get('cursor') || '',
1408
+ limit: url.searchParams.get('limit') || undefined,
1409
+ });
1410
+ return send(res, 200, result);
1411
+ }
1412
+
1413
+ if (method === 'GET' && pathname === '/api/logs') {
1414
+ const result = runtime.logRepository.query({
1415
+ ...logFiltersFromUrl(url),
1416
+ cursor: url.searchParams.get('cursor') || '',
1417
+ pageSize: Number(url.searchParams.get('pageSize') || 500),
1418
+ });
1419
+ return send(res, 200, { ok: true, ...result, streamCursor: encodeLogStreamCursor(result.entries[0]), health: runtime.logger.getHealth() });
1420
+ }
1421
+ if (method === 'GET' && pathname === '/api/logs/stream') {
1422
+ return streamLogsWithoutGap(res, runtime, logFiltersFromUrl(url), url.searchParams.get('streamCursor') || '');
1423
+ }
1424
+ if (method === 'GET' && pathname === '/api/logs/export') {
1425
+ const output = runtime.layout.getCachePath('exports', `${requestOperationId}.jsonl`);
1426
+ const result = runtime.logRepository.exportToFile(output, logFiltersFromUrl(url));
1427
+ const payload = fs.readFileSync(result.filePath);
1428
+ fs.rmSync(result.filePath, { force: true });
1429
+ res.setHeader('Content-Disposition', `attachment; filename="project-knowledge-logs-${new Date().toISOString().slice(0, 10)}.jsonl"`);
1430
+ return send(res, 200, payload, 'application/x-ndjson; charset=utf-8');
1431
+ }
1432
+
1433
+ if (method === 'POST' && pathname === '/api/claude/sessions') {
1434
+ const body = await readJsonBody(req);
1435
+ const projectId = validateProjectId(body.projectId);
1436
+ if (!runtime.registryStore.readDisplaySnapshot(projectId)) throw new DomainError('PROJECT_NOT_FOUND', 'Project was not found.', { status: 404 });
1437
+ const config = runtime.projectStore.readConfig(projectId);
1438
+ const settings = runtime.settingsStore.read();
1439
+ const resolved = resolveEffectiveAiProfile(settings, config);
1440
+ const profile = resolved.profile;
1441
+ const result = claudeCliRunner.startChatSession({ slug: projectId, projectPath: config.repoPath, kbPath: config.knowledgePath, aiProfile: profile, permissionMode: body.permissionMode });
1442
+ return send(res, 201, { ok: true, projectId, ...result });
1443
+ }
1444
+ if (method === 'GET' && pathname === '/api/claude/sessions') {
1445
+ const projectId = url.searchParams.get('projectId') || '';
1446
+ if (projectId) validateProjectId(projectId);
1447
+ return send(res, 200, { ok: true, sessions: listClaudeSessions(runtime, projectId) });
1448
+ }
1449
+ if (method === 'GET' && pathname === '/api/claude/sessions-stream') {
1450
+ return streamSse(res, write => {
1451
+ write({ type: 'claude/snapshot', sessions: listClaudeSessions(runtime) });
1452
+ return claudeCliRunner.subscribeList(event => write({ type: 'claude/sessions-changed', ...event }));
1453
+ });
1454
+ }
1455
+ const sessionMatch = pathname.match(/^\/api\/claude\/sessions\/([^/]+)(?:\/(events|input|permission|abort))?$/);
1456
+ if (sessionMatch) {
1457
+ const sessionId = decodeURIComponent(sessionMatch[1]);
1458
+ const action = sessionMatch[2] || '';
1459
+ const session = claudeCliRunner.getSession(sessionId);
1460
+ if (!session) throw new DomainError('PROJECT_NOT_FOUND', 'Claude session was not found.', { status: 404 });
1461
+ if (method === 'GET' && !action) return send(res, 200, { ok: true, session: claudeCliRunner.getState(sessionId) });
1462
+ if (method === 'GET' && action === 'events') return streamSse(res, write => claudeCliRunner.subscribe(sessionId, write));
1463
+ if (method === 'POST' && action === 'input') {
1464
+ const body = await readJsonBody(req);
1465
+ if (typeof body.text !== 'string' || !body.text.trim()) throw new DomainError('INVALID_ARGUMENT', 'text must be a non-empty string.');
1466
+ const config = runtime.projectStore.readConfig(session.projectSlug);
1467
+ const settings = runtime.settingsStore.read();
1468
+ const resolved = resolveEffectiveAiProfile(settings, config);
1469
+ const profile = resolved.profile;
1470
+ // Workbench chat is internal AI runtime (I-02): sending a message
1471
+ // must NOT auto-record requirements or append Development
1472
+ // Conversation events. External capture flows through the Bridge.
1473
+ const result = await claudeCliRunner.sendInput(sessionId, body.text, profile, { permissionMode: body.permissionMode });
1474
+ return send(res, 200, { ok: true, sessionId, ...result });
1475
+ }
1476
+ if (method === 'POST' && action === 'permission') {
1477
+ const body = await readJsonBody(req);
1478
+ const result = claudeCliRunner.resolvePermission(sessionId, body.requestId, body.decision);
1479
+ return send(res, 200, { ok: true, sessionId, ...result });
1480
+ }
1481
+ if (method === 'POST' && action === 'abort') {
1482
+ claudeCliRunner.abort(sessionId);
1483
+ return send(res, 200, { ok: true, sessionId });
1484
+ }
1485
+ }
1486
+
1487
+ if (!pathname.startsWith('/api/') && method === 'GET') {
1488
+ const filePath = safeStaticFile(pathname);
1489
+ if (filePath && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
1490
+ return send(res, 200, fs.readFileSync(filePath), CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream');
1491
+ }
1492
+ }
1493
+ return send(res, 404, { ok: false, error: { code: 'PROJECT_NOT_FOUND', message: 'Route not found.', operationId: requestOperationId, retryable: false, details: {} } });
1494
+ } catch (error) {
1495
+ requestFailed = true;
1496
+ const status = error instanceof DomainError ? error.status : 500;
1497
+ if (!error.operationId) error.operationId = requestOperationId;
1498
+ await runtime.logger.error('http.request_failed', 'HTTP request failed.', {
1499
+ operationId: requestOperationId,
1500
+ phase: 'request',
1501
+ error,
1502
+ context: { method: req.method, pathname: String(req.url || '').split('?')[0], status },
1503
+ });
1504
+ if (res.headersSent) {
1505
+ res.destroy(error);
1506
+ return;
1507
+ }
1508
+ return send(res, status, serializeErrorEnvelope(error, requestOperationId));
1509
+ }
1510
+ };
1511
+ }
1512
+
1513
+ async function startServer(options = {}) {
1514
+ const runtime = options.runtime || createRuntime({ ...options, analysisOwner: options.analysisOwner || 'engine' });
1515
+ const port = parsePort(options.port || process.env.KB_SITE_PORT);
1516
+ const host = String(options.host || process.env.KB_SITE_HOST || '127.0.0.1');
1517
+ let endpointClaimed = false;
1518
+ if (isLoopback(host)) {
1519
+ const claim = runtimeEndpoint.claimEndpoint(runtime.dataPath, { pid: process.pid, host, port, mode: process.env.KB_RUNTIME_MODE || 'cli' });
1520
+ const alreadyOwned = !claim.claimed && claim.endpoint && claim.endpoint.pid === process.pid
1521
+ && claim.endpoint.host === host && claim.endpoint.port === port;
1522
+ if (!claim.claimed && !alreadyOwned) throw new DomainError('PROJECT_BUSY', 'Another Project Knowledge server owns this data directory.', { status: 409, retryable: true });
1523
+ endpointClaimed = claim.claimed;
1524
+ }
1525
+ let startup;
1526
+ let server;
1527
+ let controlServer;
1528
+ try {
1529
+ startup = await initializeRuntime(runtime);
1530
+ const handler = createRequestHandler(runtime, { ...options, port, host });
1531
+ server = http.createServer(handler);
1532
+ runtime.liveGateway = require('./live-gateway').attachLiveGateway(server, runtime);
1533
+ runtime.liveAddress = `${host}:${port}`;
1534
+ await new Promise((resolve, reject) => {
1535
+ server.once('error', reject);
1536
+ server.listen(port, host, resolve);
1537
+ });
1538
+ } catch (error) {
1539
+ if (endpointClaimed) runtimeEndpoint.clearEndpoint(runtime.dataPath, { pid: process.pid });
1540
+ throw error;
1541
+ }
1542
+ await runtime.logger.info('server.listening', 'Project Knowledge server is listening.', { phase: 'running', context: { host, port } });
1543
+
1544
+ // Startup drains durable conversation/boundary facts. It deliberately does
1545
+ // not infer or submit Git commits for knowledge analysis.
1546
+ const bridgeReady = Promise.resolve(
1547
+ runtime.analysisOwner === 'legacy' && runtime.bridgeConsumerService ? runtime.bridgeConsumerService.start(`http://127.0.0.1:${port}/api/bridge/notify`) : null
1548
+ ).catch(error => runtime.logger.error('bridge_consumer.startup_failed', 'Bridge consumer startup drain failed.', { error }));
1549
+
1550
+ const startupPromise = Promise.all([
1551
+ runtime.analysisOwner === 'legacy' ? runtime.indexService.retryDirtyProjects() : Promise.resolve(),
1552
+ bridgeReady,
1553
+ ]).catch(error => runtime.logger.error('startup.recovery_failed', 'Startup recovery failed.', { error }));
1554
+
1555
+ const maintenanceTimer = setInterval(() => {
1556
+ if (runtime.analysisOwner === 'legacy') runtime.indexService.retryDirtyProjects().catch(error => runtime.logger.error('index.retry_failed', 'Index retry failed.', { error }));
1557
+ }, Number(process.env.KB_MAINTENANCE_INTERVAL_MS || 60 * 60 * 1000));
1558
+ maintenanceTimer.unref?.();
1559
+ const configurationTimer = setInterval(() => { connectEngine(runtime).catch(() => {}); }, 30000);
1560
+ configurationTimer.unref?.();
1561
+ let readyTimer;
1562
+ runtime.moduleBridge.onReady = () => { runtime.registryEvents?.publish(['health', 'projects']); clearTimeout(readyTimer); readyTimer = setTimeout(() => { runtime.projectConnections?.clear(); connectEngine(runtime).catch(() => {}); }, 100); };
1563
+ runtime.moduleBridge.onStateChange = () => runtime.registryEvents?.publish(['health']);
1564
+ connectEngine(runtime).catch(() => {});
1565
+
1566
+ // T3.5 进程编排:拉起包内自带的模块服务并守护(默认开启,KB_MODULES_AUTOSTART=0 关闭)
1567
+ try {
1568
+ runtime.moduleBridge.startSupervised().catch(error => {
1569
+ runtime.logger.warn('modules.supervisor_failed', 'Module supervisor failed to start.', { error });
1570
+ });
1571
+ } catch (error) {
1572
+ runtime.logger.warn('modules.supervisor_failed', 'Module supervisor failed to start.', { error });
1573
+ }
1574
+
1575
+ // Startup-event integration repair: one attempt per connected-but-broken
1576
+ // client, failures surfaced and never retried in this run. Native checks
1577
+ // run in an owned worker so they cannot block HTTP or launcher readiness.
1578
+ try {
1579
+ runtime.integrationAutoRepair = new (require('./startup-integration-repair').StartupIntegrationRepair)({
1580
+ dataDir: runtime.dataPath,
1581
+ logger: runtime.logger,
1582
+ managerOptions: {
1583
+ ...options.integrationOptions,
1584
+ rootDir: runtime.rootDir,
1585
+ bridgeHomeDir: runtime.bridgeAdapter.hookBinding().bridgeHomeDir,
1586
+ },
1587
+ });
1588
+ runtime.integrationAutoRepair.runOnce().catch(error => {
1589
+ runtime.logger.warn('integrations.auto_repair_skipped', '启动自动修复未完成。', { error });
1590
+ });
1591
+ } catch (error) {
1592
+ runtime.logger.warn('integrations.auto_repair_unavailable', '启动自动修复不可用。', { error });
1593
+ }
1594
+
1595
+ let stopping = null;
1596
+ const stop = async reason => {
1597
+ if (stopping) return stopping;
1598
+ stopping = (async () => {
1599
+ runtime.integrationStatusScan?.close();
1600
+ await runtime.integrationAutoRepair?.close();
1601
+ clearInterval(maintenanceTimer);
1602
+ clearInterval(configurationTimer);
1603
+ clearTimeout(readyTimer); runtime.moduleBridge.onReady = null;
1604
+ runtime.moduleBridge.onStateChange = null;
1605
+ runtime.engineConnectionClosed = true;
1606
+ runtime.liveGateway?.close();
1607
+ await runtime.logger.info('server.shutdown_started', 'Server shutdown started.', { phase: 'shutdown', context: { reason } });
1608
+ const forceConnections = setTimeout(() => server.closeAllConnections?.(), Number(process.env.KB_SERVER_CLOSE_GRACE_MS || 2000));
1609
+ await new Promise(resolve => server.close(resolve));
1610
+ clearTimeout(forceConnections);
1611
+ const drainTimeoutMs = Number(process.env.KB_SHUTDOWN_DRAIN_TIMEOUT_MS || 30000);
1612
+ let drainTimer;
1613
+ const drained = await Promise.race([
1614
+ Promise.allSettled(activeTaskPromises(runtime.activeTasks).concat(IndexService.flush())).then(() => true),
1615
+ new Promise(resolve => { drainTimer = setTimeout(() => resolve(false), drainTimeoutMs); }),
1616
+ ]);
1617
+ clearTimeout(drainTimer);
1618
+ if (!drained) await runtime.logger.warn('server.shutdown_drain_timeout', 'Server shutdown timed out waiting for background work.', { phase: 'shutdown', context: { drainTimeoutMs } });
1619
+ await runtime.knowledgeRuntime.close();
1620
+ await runtime.indexAdapter.close();
1621
+ if (runtime.bridgeConsumerService) await runtime.bridgeConsumerService.stop().catch(() => {});
1622
+ await runtime.moduleBridge.stopSupervised();
1623
+ await runtime.logger.close();
1624
+ await controlServer?.close();
1625
+ runtimeEndpoint.clearEndpoint(runtime.dataPath, { pid: process.pid });
1626
+ })();
1627
+ return stopping;
1628
+ };
1629
+ runtime.instanceIdentity = runtimeControl.identity(runtime.dataPath, { host, port, version: require('../../package.json').version, startedAt: runtimeEndpoint.readEndpoint(runtime.dataPath)?.startedAt || new Date().toISOString() });
1630
+ try { controlServer = await runtimeControl.listen(runtime.dataPath, runtime.instanceIdentity, () => stop('cli-control')); }
1631
+ catch (error) { await stop('control-startup-failed'); throw error; }
1632
+ return { server, runtime, startup, startupPromise, stop, host, port };
1633
+ }
1634
+
1635
+ function installProcessHandlers(instancePromise) {
1636
+ let terminating = false;
1637
+ const terminate = async (reason, error, exitCode) => {
1638
+ if (terminating) return;
1639
+ terminating = true;
1640
+ try {
1641
+ const instance = await instancePromise;
1642
+ if (error) await instance.runtime.logger.fatal('server.process_failure', 'A process-level failure stopped the server.', { phase: 'shutdown', error });
1643
+ await instance.stop(reason);
1644
+ } catch (shutdownError) {
1645
+ try { process.stderr.write(`[project-knowledge] shutdown failed: ${String(shutdownError && shutdownError.message || shutdownError)}\n`); } catch {
1646
+ // stderr is the final fallback after the structured logger could not shut down.
1647
+ }
1648
+ }
1649
+ process.exitCode = exitCode;
1650
+ };
1651
+ process.once('SIGINT', () => terminate('SIGINT', null, 0));
1652
+ process.once('SIGTERM', () => terminate('SIGTERM', null, 0));
1653
+ process.once('uncaughtException', error => terminate('uncaughtException', error, 1));
1654
+ process.once('unhandledRejection', error => terminate('unhandledRejection', error instanceof Error ? error : new Error(String(error)), 1));
1655
+ }
1656
+
1657
+ module.exports = {
1658
+ RuntimeIndexAdapter,
1659
+ RuntimeKnowledgeAnalyzer,
1660
+ createRuntime,
1661
+ initializeRuntime,
1662
+ migrateManagedHooks,
1663
+ createRequestHandler,
1664
+ startServer,
1665
+ installProcessHandlers,
1666
+ mergeAiProfiles,
1667
+ requestOriginAllowed,
1668
+ isLoopback,
1669
+ isProjectBusy,
1670
+ taskForProject,
1671
+ activeTaskPromises,
1672
+ };