@sanqianx/project-knowledge 4.6.1 → 4.8.0

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