@sanqianx/project-knowledge 5.0.23 → 5.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +20 -20
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +1361 -1346
- package/LICENSE +201 -201
- package/NOTICE +5 -5
- package/README.md +349 -349
- package/_modules/knowledge-engine/package.json +1 -1
- package/_modules/knowledge-engine/src/coordinator.js +48 -3
- package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -140
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +238 -238
- package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -26
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +193 -193
- package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +394 -394
- package/_modules/knowledge-engine/src/runtime/contracts.js +282 -282
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +389 -389
- package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -228
- package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -62
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +505 -505
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +400 -400
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +171 -171
- package/_modules/knowledge-engine/src/runtime/scanner.js +298 -298
- package/_modules/knowledge-engine/ui/app.js +23 -2
- package/_site/README.md +30 -30
- package/_site/_test/agent-capture-mcp-ui-test.js +68 -68
- package/_site/_test/agent-capture-ui-test.js +101 -101
- package/_site/_test/ai-profile-resolver-test.js +137 -137
- package/_site/_test/ai-profile-test.js +75 -75
- package/_site/_test/ai-vendor-presets-test.js +91 -91
- package/_site/_test/automatic-context-test.js +70 -70
- package/_site/_test/automation-queue-test.js +14 -14
- package/_site/_test/automation-ui-test.js +40 -40
- package/_site/_test/background-task-registry-test.js +43 -43
- package/_site/_test/baseline-schema-test.js +91 -91
- package/_site/_test/bin-cli-test.js +230 -230
- package/_site/_test/bridge-adapter-test.js +95 -95
- package/_site/_test/bridge-consumer-service-test.js +310 -310
- package/_site/_test/candidate-bin-content-test.js +35 -35
- package/_site/_test/candidate-product-assets-test.js +13 -13
- package/_site/_test/candidate-release-contract-test.js +14 -14
- package/_site/_test/capture-hook-dedup-test.js +158 -158
- package/_site/_test/cdp-browser-failure-test.js +103 -103
- package/_site/_test/chat-claudecodeui-match-test.js +92 -92
- package/_site/_test/claude-executable-discovery-test.js +90 -90
- package/_site/_test/claude-session-lifecycle-sweep-test.js +252 -252
- package/_site/_test/claude-workbench-test.js +174 -174
- package/_site/_test/cli-instance-management-test.js +62 -62
- package/_site/_test/client-capture-restart-e2e-test.js +189 -189
- package/_site/_test/codex-conversation-projection-test.js +60 -60
- package/_site/_test/codex-marketplace-scoped-path-test.js +186 -186
- package/_site/_test/codex-notify-capture-e2e-test.js +101 -101
- package/_site/_test/codex-notify-fanout-test.js +141 -134
- package/_site/_test/codex-notify-upgrade-test.js +253 -253
- package/_site/_test/commit-boundary-freeze-test.js +147 -147
- package/_site/_test/commit-conversation-binding-test.js +99 -99
- package/_site/_test/commit-evidence-test.js +110 -110
- package/_site/_test/configuration-generation-test.js +22 -22
- package/_site/_test/context-pack-test.js +200 -200
- package/_site/_test/conversation-api-test.js +119 -119
- package/_site/_test/conversation-origin-test.js +15 -15
- package/_site/_test/conversation-store-test.js +138 -138
- package/_site/_test/cross-repo-isolation-e2e-test.js +187 -187
- package/_site/_test/desktop-browser-compat-test.js +28 -28
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
- package/_site/_test/embedding-config-test.js +32 -32
- package/_site/_test/explicit-commit-processor-test.js +37 -37
- package/_site/_test/fixtures/fake-browser-exit.js +10 -10
- package/_site/_test/fixtures/fake-browser-hang.js +10 -10
- package/_site/_test/fixtures/integration-busy-worker.cjs +6 -6
- package/_site/_test/fixtures/make-git-repos.js +92 -92
- package/_site/_test/fixtures/retrieval-gold-queries.json +32 -32
- package/_site/_test/folder-picker-output-test.js +19 -19
- package/_site/_test/fresh-hook-reimport-test.js +53 -53
- package/_site/_test/fresh-product-data-test.js +56 -56
- package/_site/_test/full-integration-e2e-test.js +167 -167
- package/_site/_test/git-path-compatibility-test.js +80 -80
- package/_site/_test/git-validation-test.js +81 -81
- package/_site/_test/glm-ui-refresh-review-test.js +313 -404
- package/_site/_test/harness-isolation-test.js +138 -0
- package/_site/_test/helpers/cdp-browser.js +383 -0
- package/_site/_test/helpers/cdp-network-capture.js +66 -0
- package/_site/_test/helpers/find-chrome.js +34 -0
- package/_site/_test/helpers/free-port.js +8 -0
- package/_site/_test/helpers/logging-ui-fixture.js +131 -0
- package/_site/_test/helpers/module-fixture.js +282 -0
- package/_site/_test/helpers/parallel-engine-advanced-engine-child.js +45 -0
- package/_site/_test/helpers/parallel-engine-advanced-fault-writer.js +171 -0
- package/_site/_test/helpers/parallel-engine-advanced-harness.js +245 -0
- package/_site/_test/helpers/parallel-engine-advanced-standin-analyzer.js +80 -0
- package/_site/_test/helpers/parallel-engine-advanced-ui-host.js +177 -0
- package/_site/_test/helpers/parallel-engine-final-harness.js +314 -0
- package/_site/_test/helpers/parallel-engine-final-standin-analyzer.js +159 -0
- package/_site/_test/helpers/parallel-knowledge-fixture.js +255 -0
- package/_site/_test/helpers/parallel-logging-acceptance-fixture.js +111 -0
- package/_site/_test/helpers/parallel-logging-acceptance-proxy.js +106 -0
- package/_site/_test/helpers/parallel-logging-fix-r2-proxy.js +205 -0
- package/_site/_test/helpers/parallel-logging-integration-5025-env.js +432 -0
- package/_site/_test/helpers/parallel-memory-analyzer.js +81 -0
- package/_site/_test/helpers/parallel-memory-event-bridge.js +128 -0
- package/_site/_test/helpers/parallel-memory-shell.js +470 -0
- package/_site/_test/helpers/parallel-nav-isolation-env.js +389 -0
- package/_site/_test/helpers/parallel-nav-module-ui.js +186 -0
- package/_site/_test/helpers/parallel-nav-proxy.js +148 -0
- package/_site/_test/helpers/parallel-nav-shell.js +361 -0
- package/_site/_test/helpers/parallel-qa-engine-endpoint-guard.js +206 -0
- package/_site/_test/helpers/parallel-qa-engine-endpoint-isolate.js +257 -0
- package/_site/_test/helpers/parallel-retrieval-followup-corpus.js +293 -0
- package/_site/_test/helpers/parallel-retrieval-followup-next-session.js +70 -0
- package/_site/_test/helpers/parallel-retrieval-followup-r2-shell.js +737 -0
- package/_site/_test/helpers/parallel-retrieval-followup-r2-wb-host.js +82 -0
- package/_site/_test/helpers/parallel-retrieval-followup-shell.js +279 -0
- package/_site/_test/helpers/parallel-workbench-fixture.js +371 -0
- package/_site/_test/helpers/parallel-workbench-host.js +80 -0
- package/_site/_test/helpers/parallel-workbench-integration-5025-fixture.js +408 -0
- package/_site/_test/helpers/parallel-workbench-integration-5025-harness.js +593 -0
- package/_site/_test/helpers/parallel-workbench-integration-5025-host.js +70 -0
- package/_site/_test/helpers/parallel-workbench-integration-5025-launcher.js +240 -0
- package/_site/_test/helpers/parallel-workbench-integration-5025-runtime-harness.js +630 -0
- package/_site/_test/helpers/parallel-workbench-source-map.js +196 -0
- package/_site/_test/helpers/spawn-server.js +106 -0
- package/_site/_test/helpers/version-visibility-fixture.js +251 -0
- package/_site/_test/hook-runtime-endpoint-test.js +88 -88
- package/_site/_test/hook-status-repair-api-test.js +152 -152
- package/_site/_test/hook-trigger-test.js +85 -85
- package/_site/_test/hook-worktree-test.js +42 -42
- package/_site/_test/import-preflight-api-test.js +234 -234
- package/_site/_test/index-builder-test.js +41 -41
- package/_site/_test/index-error-display-contract-test.js +10 -10
- package/_site/_test/index-writer-concurrency-test.js +135 -135
- package/_site/_test/integration-adapters-test.js +262 -262
- package/_site/_test/integration-auto-repair-test.js +113 -113
- package/_site/_test/integration-capture-setup-test.js +118 -118
- package/_site/_test/integration-intent-test.js +81 -81
- package/_site/_test/integration-runtime-binding-a2-test.js +566 -566
- package/_site/_test/integration-runtime-binding-a3-test.js +227 -227
- package/_site/_test/integration-runtime-binding-test.js +473 -473
- package/_site/_test/integration-status-scan-test.js +29 -29
- package/_site/_test/integration-surface-coverage-test.js +164 -164
- package/_site/_test/kb-framework-test.js +57 -57
- package/_site/_test/kbpath-follow-test.js +63 -63
- package/_site/_test/knowledge-db-test.js +144 -144
- package/_site/_test/knowledge-engine-client-test.js +120 -109
- package/_site/_test/knowledge-language-control-test.js +170 -170
- package/_site/_test/knowledge-maintenance-test.js +40 -40
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
- package/_site/_test/knowledge-query-test.js +54 -54
- package/_site/_test/knowledge-retrieval-service-test.js +77 -77
- package/_site/_test/knowledge-scopes-test.js +37 -37
- package/_site/_test/knowledge-storage-location-test.js +28 -28
- package/_site/_test/knowledge-storage-startup-test.js +52 -52
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
- package/_site/_test/launcher-readiness-test.js +80 -80
- package/_site/_test/legacy-claude-session-alias-test.js +106 -106
- package/_site/_test/legacy-routes-removed-test.js +47 -47
- package/_site/_test/legacy-workbench-exclusion-test.js +92 -92
- package/_site/_test/live-gateway-test.js +48 -48
- package/_site/_test/local-marketplace-test.js +16 -16
- package/_site/_test/log-redaction-test.js +58 -58
- package/_site/_test/logging-api-test.js +89 -89
- package/_site/_test/logging-sse-no-gap-test.js +88 -88
- package/_site/_test/logging-ui-test.js +107 -107
- package/_site/_test/markdown-delta-overlay-test.js +85 -85
- package/_site/_test/markdown-maintenance-api-test.js +75 -75
- package/_site/_test/markdown-maintenance-test.js +11 -11
- package/_site/_test/mcp-server-test.js +149 -149
- package/_site/_test/model-context-windows-test.js +89 -89
- package/_site/_test/module-agent-start-proxy-test.js +65 -65
- package/_site/_test/module-artifact-integrity-test.js +27 -27
- package/_site/_test/module-boundary-test.js +30 -30
- package/_site/_test/module-bridge-eventbridge-test.js +202 -202
- package/_site/_test/module-dynamic-port-test.js +118 -118
- package/_site/_test/module-index-proxy-test.js +33 -33
- package/_site/_test/module-model-configuration-test.js +44 -44
- package/_site/_test/module-process-lifecycle-test.js +35 -35
- package/_site/_test/module-stream-proxy-test.js +34 -34
- package/_site/_test/module-terminal-restart-test.js +63 -63
- package/_site/_test/module-wiring-unbind-race-test.js +59 -59
- package/_site/_test/native-context-integrations-test.js +86 -86
- package/_site/_test/non-release-ci-test.js +34 -34
- package/_site/_test/offline-boundary-isolation-test.js +32 -32
- package/_site/_test/orphaned-claim-recovery-test.js +80 -80
- package/_site/_test/package-boundary-test.js +43 -43
- package/_site/_test/package-startup-test.js +56 -56
- package/_site/_test/packaged-ui-smoke-test.js +88 -88
- package/_site/_test/parallel-ci-r9-posix-socket-budget-test.js +156 -0
- package/_site/_test/parallel-engine-advanced-eng005-test.js +136 -0
- package/_site/_test/parallel-engine-advanced-eng006-test.js +171 -0
- package/_site/_test/parallel-engine-advanced-eng007-reconcile-test.js +231 -0
- package/_site/_test/parallel-engine-advanced-eng009-test.js +208 -0
- package/_site/_test/parallel-engine-advanced-eng010-test.js +213 -0
- package/_site/_test/parallel-engine-advanced-eng011-ui-probe.js +459 -0
- package/_site/_test/parallel-engine-final-eng002-test.js +255 -0
- package/_site/_test/parallel-engine-final-eng007-test.js +269 -0
- package/_site/_test/parallel-engine-final-eng011-test.js +197 -0
- package/_site/_test/parallel-engine-final-eng012-test.js +354 -0
- package/_site/_test/parallel-knowledge-client-error-code-test.js +184 -0
- package/_site/_test/parallel-knowledge-engine-test.js +353 -0
- package/_site/_test/parallel-knowledge-retrieval-test.js +292 -0
- package/_site/_test/parallel-knowledge-vector-test.js +168 -0
- package/_site/_test/parallel-linux-ci-r7-log007-day-test.js +110 -0
- package/_site/_test/parallel-logging-acceptance-log003-test.js +224 -0
- package/_site/_test/parallel-logging-acceptance-log004-test.js +231 -0
- package/_site/_test/parallel-logging-acceptance-log005-test.js +208 -0
- package/_site/_test/parallel-logging-acceptance-log006-test.js +214 -0
- package/_site/_test/parallel-logging-acceptance-log007-test.js +223 -0
- package/_site/_test/parallel-logging-acceptance-log008-test.js +180 -0
- package/_site/_test/parallel-logging-fix-r2-crash-probe.js +271 -0
- package/_site/_test/parallel-logging-fix-r2-gap-test.js +294 -0
- package/_site/_test/parallel-logging-fix-r2-normalhook-test.js +330 -0
- package/_site/_test/parallel-logging-integration-5025-portability-test.js +554 -0
- package/_site/_test/parallel-memory-candidate-test.js +301 -0
- package/_site/_test/parallel-memory-manual-test.js +197 -0
- package/_site/_test/parallel-memory-scope-test.js +228 -0
- package/_site/_test/parallel-memory-source-test.js +227 -0
- package/_site/_test/parallel-memory-sync-test.js +238 -0
- package/_site/_test/parallel-memory-view-r8-reload-gate-test.js +123 -0
- package/_site/_test/parallel-nav-failure-test.js +119 -0
- package/_site/_test/parallel-nav-isolation-test.js +308 -0
- package/_site/_test/parallel-nav-isolation-vector-path-test.js +90 -0
- package/_site/_test/parallel-nav-offline-test.js +272 -0
- package/_site/_test/parallel-nav-operability-test.js +257 -0
- package/_site/_test/parallel-nav-switch-test.js +247 -0
- package/_site/_test/parallel-nav-theme-test.js +130 -0
- package/_site/_test/parallel-nav-viewport-test.js +310 -0
- package/_site/_test/parallel-qa-engine-endpoint-guard-test.js +273 -0
- package/_site/_test/parallel-retrieval-followup-citation-test.js +219 -0
- package/_site/_test/parallel-retrieval-followup-corpus-test.js +287 -0
- package/_site/_test/parallel-workbench-independent-host.js +262 -0
- package/_site/_test/parallel-workbench-integration-5025-abort-probe.js +340 -0
- package/_site/_test/parallel-workbench-integration-5025-drafts-probe.js +588 -0
- package/_site/_test/parallel-workbench-integration-5025-env-check.js +317 -0
- package/_site/_test/parallel-workbench-integration-5025-model-probe.js +342 -0
- package/_site/_test/parallel-workbench-integration-5025-ordering-probe.js +326 -0
- package/_site/_test/parallel-workbench-model-probe.js +336 -0
- package/_site/_test/parallel-workbench-offline-test.js +169 -0
- package/_site/_test/parallel-workbench-provenance-trace.js +134 -0
- package/_site/_test/parallel-workbench-source-match.js +206 -0
- package/_site/_test/parallel-workbench-standalone-turn.js +349 -0
- package/_site/_test/path-consistency-test.js +151 -151
- package/_site/_test/pending-sweep-test.js +9 -9
- package/_site/_test/post-commit-automation-test.js +87 -87
- package/_site/_test/pr-consumer-contract-test.js +55 -55
- package/_site/_test/product-brand-contract-test.js +25 -25
- package/_site/_test/product-diagnostics-ui-test.js +93 -93
- package/_site/_test/product-empty-state-test.js +148 -0
- package/_site/_test/product-import-ui-test.js +219 -104
- package/_site/_test/product-navigation-test.js +152 -0
- package/_site/_test/product-progress-status-test.js +84 -84
- package/_site/_test/project-delete-recovery-test.js +64 -64
- package/_site/_test/project-domain-create-test.js +91 -0
- package/_site/_test/project-domain-scope-retrieval-test.js +214 -0
- package/_site/_test/project-git-init-confirm-test.js +115 -0
- package/_site/_test/project-goal-editor-test.js +144 -144
- package/_site/_test/project-import-duplicate-alias-test.js +134 -0
- package/_site/_test/project-import-unicode-restart-test.js +140 -0
- package/_site/_test/project-lifecycle-transaction-test.js +106 -106
- package/_site/_test/project-module-partial-recovery-test.js +222 -0
- package/_site/_test/project-remove-running-guard-test.js +79 -79
- package/_site/_test/project-store-test.js +90 -90
- package/_site/_test/project-unbind-late-read-ui-test.js +250 -0
- package/_site/_test/project-unbind-writer-guard-test.js +231 -0
- package/_site/_test/prompt-settings-test.js +115 -115
- package/_site/_test/protected-architecture-gate-test.js +122 -122
- package/_site/_test/refactor-characterization-test.js +36 -36
- package/_site/_test/registry-events-test.js +38 -38
- package/_site/_test/release-version-sync-test.js +115 -115
- package/_site/_test/requirement-binding-test.js +113 -113
- package/_site/_test/requirement-recorder-test.js +173 -173
- package/_site/_test/run-all-tests.js +173 -161
- package/_site/_test/runtime-endpoint-test.js +64 -64
- package/_site/_test/scanner-test.js +83 -83
- package/_site/_test/server-security-test.js +96 -96
- package/_site/_test/sessions-stream-test.js +93 -93
- package/_site/_test/shared-contracts-test.js +58 -58
- package/_site/_test/shared-scope-root-binding-test.js +74 -67
- package/_site/_test/simple-import-test.js +98 -98
- package/_site/_test/startup-analysis-disabled-test.js +16 -16
- package/_site/_test/storage-foundation-test.js +78 -78
- package/_site/_test/structured-logger-test.js +101 -101
- package/_site/_test/t07b-cln-005-coverage-test.js +212 -0
- package/_site/_test/team-knowledge-store-sparse-checkout-test.js +196 -196
- package/_site/_test/team-knowledge-store-test.js +132 -132
- package/_site/_test/test-server-cleanup-test.js +35 -35
- package/_site/_test/test-server-module-isolation-test.js +37 -37
- package/_site/_test/tracking-start-test.js +144 -144
- package/_site/_test/vector-storage-root-test.js +75 -75
- package/_site/_test/version-visibility-browser-probe.js +249 -0
- package/_site/_test/version-visibility-test.js +92 -0
- package/_site/_test/workbench-context-receipts-test.js +22 -22
- package/_site/_test/workbench-permission-test.js +117 -117
- package/_site/_test/zcode-capture-integration-test.js +112 -112
- package/_site/lib/ai-profile-resolver.js +78 -78
- package/_site/lib/ai-vendor-presets.js +83 -83
- package/_site/lib/ai-workspace.js +104 -104
- package/_site/lib/atomic-file.js +204 -204
- package/_site/lib/automation-config.js +140 -140
- package/_site/lib/bridge-adapter.js +260 -260
- package/_site/lib/bridge-consumer-service.js +402 -402
- package/_site/lib/capture-hook-config.js +108 -108
- package/_site/lib/claude-cli-runner.js +1599 -1599
- package/_site/lib/cli-instances.js +43 -43
- package/_site/lib/codex-context-status.js +17 -17
- package/_site/lib/codex-notify-config.js +193 -193
- package/_site/lib/commit-conversation-binder.js +247 -247
- package/_site/lib/commit-processing-ledger.js +27 -27
- package/_site/lib/commit-prompt.js +202 -202
- package/_site/lib/commit-reconciler.js +444 -444
- package/_site/lib/context-pack-builder.js +299 -299
- package/_site/lib/contracts.js +291 -291
- package/_site/lib/conversation-exclusions.js +204 -204
- package/_site/lib/conversation-query-service.js +161 -161
- package/_site/lib/conversation-store.js +387 -387
- package/_site/lib/data-dir.js +13 -13
- package/_site/lib/embedding-service.js +85 -85
- package/_site/lib/engine-connection.js +93 -93
- package/_site/lib/engine-knowledge-client.js +76 -72
- package/_site/lib/evidence-bundle.js +228 -228
- package/_site/lib/folder-picker-output.js +24 -24
- package/_site/lib/git-path.js +14 -14
- package/_site/lib/git-runner.js +62 -62
- package/_site/lib/github-team-store.js +1175 -1175
- package/_site/lib/hook-manager.js +203 -203
- package/_site/lib/index-builder.js +157 -157
- package/_site/lib/index-service.js +173 -173
- package/_site/lib/integration-auto-repair.js +77 -77
- package/_site/lib/integration-installer.js +1240 -1240
- package/_site/lib/integration-intent.js +88 -88
- package/_site/lib/integration-status-scan.js +99 -99
- package/_site/lib/integration-status-worker.js +14 -14
- package/_site/lib/kb-framework.js +191 -191
- package/_site/lib/kb-validator.js +124 -124
- package/_site/lib/knowledge-db.js +363 -363
- package/_site/lib/knowledge-promotion.js +435 -435
- package/_site/lib/knowledge-retrieval-service.js +401 -401
- package/_site/lib/knowledge-schema.js +121 -121
- package/_site/lib/knowledge-tool-runtime.js +366 -366
- package/_site/lib/live-gateway.js +78 -78
- package/_site/lib/llm-client.js +173 -173
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/model-context-windows.js +82 -82
- package/_site/lib/module-bridge.js +874 -874
- package/_site/lib/module-port-resolver.js +57 -57
- package/_site/lib/post-commit-automation.js +141 -141
- package/_site/lib/product-data.js +35 -35
- package/_site/lib/project-lifecycle-service.js +554 -554
- package/_site/lib/project-registry-store.js +129 -129
- package/_site/lib/project-store.js +268 -268
- package/_site/lib/prompt-registry.js +148 -148
- package/_site/lib/public-version-info.js +37 -0
- package/_site/lib/registry-events.js +16 -16
- package/_site/lib/requirement-adapters.js +10 -10
- package/_site/lib/requirement-binder.js +112 -112
- package/_site/lib/requirement-recorder.js +278 -278
- package/_site/lib/runtime-control.js +89 -89
- package/_site/lib/runtime-endpoint.js +160 -160
- package/_site/lib/scanner.js +298 -298
- package/_site/lib/server-app.js +1672 -1664
- package/_site/lib/settings-store.js +153 -153
- package/_site/lib/storage-layout.js +166 -166
- package/_site/lib/structured-logger.js +574 -574
- package/_site/lib/zcode-context-integration.js +60 -60
- package/_site/scripts/codex-notify-fanout.cjs +95 -95
- package/_site/scripts/folder-picker.ps1 +156 -156
- package/_site/scripts/hook-trigger.js +158 -158
- package/_site/scripts/install-module-candidates.js +69 -69
- package/_site/scripts/pack-module-candidates.js +89 -89
- package/_site/scripts/sync-product-content.js +28 -28
- package/_site/scripts/sync-release-version.js +164 -164
- package/_site/scripts/vendor-modules.js +49 -49
- package/_site/scripts/verify-module-runtime.js +50 -50
- package/_site/scripts/verify-product-runtime.js +36 -36
- package/_site/scripts/verify-retrieval-gold.js +83 -83
- package/_site/server.js +15 -15
- package/_site/start.bat +26 -26
- package/_site/stop.bat +11 -11
- package/bin/project-knowledge-integrations.js +254 -254
- package/bin/project-knowledge-kb.js +67 -67
- package/bin/project-knowledge-mcp.js +201 -201
- package/bin/project-knowledge.js +269 -170
- package/claude-prompts.json +21 -21
- package/docs/README.zh-CN.md +207 -205
- package/docs/assets/architecture.svg +181 -181
- package/docs/assets/logo.svg +49 -49
- package/docs/fresh-v5-start.md +44 -44
- package/docs/leader-glm-workbench-playbook.md +122 -122
- package/docs/project-registry-schema.md +33 -33
- package/docs/release-5.0.16-acceptance.md +176 -176
- package/docs/release-5.0.17-acceptance.md +117 -117
- package/docs/release-5.0.19-acceptance.md +39 -39
- package/docs/team-knowledge-mode-a-plan.md +73 -73
- package/docs/testing-strategy.md +33 -33
- package/module-runtime-manifest.json +70 -67
- package/package.json +113 -110
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/templates/change.md +30 -30
- package/templates/project-readme.md +52 -52
- package/ui/index.html +3 -2
- package/ui/version-info.css +44 -0
- package/ui/version-info.js +24 -0
- package/vendor-manifest.json +55 -55
|
@@ -1,874 +1,874 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
/**
|
|
3
|
-
* Module bridge: connectivity from project-knowledge to the embedded modules
|
|
4
|
-
* (claude-ai-workbench Agent Terminal, vector-hub console, ai-coding-event-bridge
|
|
5
|
-
* conversation console).
|
|
6
|
-
*
|
|
7
|
-
* The shell owns no features — it only connects and projects. This module is
|
|
8
|
-
* that connection on the server side:
|
|
9
|
-
* - reverse proxy for browser calls (keeps the UI same-origin; no CORS on modules)
|
|
10
|
-
* - registration fan-out on project import (the "wiring table" lives in project state)
|
|
11
|
-
* - aggregated project projection for the shell sidebar
|
|
12
|
-
* - module removal with keepSessions semantics
|
|
13
|
-
*
|
|
14
|
-
* Configuration (env, loopback defaults):
|
|
15
|
-
* KB_TERMINAL_URL default http://127.0.0.1:5760 (managed: dynamic when busy)
|
|
16
|
-
* KB_VECTORHUB_URL default http://127.0.0.1:8787 (managed: dynamic when busy)
|
|
17
|
-
* KB_EVENTBRIDGE_URL default http://127.0.0.1:8790 (managed: dynamic when busy)
|
|
18
|
-
* KB_KNOWLEDGE_ENGINE_URL default http://127.0.0.1:5790 (managed: dynamic when busy)
|
|
19
|
-
* An explicitly injected URL is treated as an external service and never
|
|
20
|
-
* rewritten. Managed modules resolve a final URL before spawn: healthy
|
|
21
|
-
* identity match reuses the port, otherwise the next free port from
|
|
22
|
-
* preferred +0..+19 is taken; all busy fails loudly (NO_FREE_MODULE_PORT).
|
|
23
|
-
* KB_MODULES_AUTOSTART '0' to stop spawning the vendored module services
|
|
24
|
-
* (they start with the shell by default)
|
|
25
|
-
* KB_TERMINAL_COMMAND explicit node command line for the terminal service
|
|
26
|
-
* KB_VECTORHUB_COMMAND explicit node command line for the vector-hub service
|
|
27
|
-
* KB_EVENTBRIDGE_COMMAND explicit node command line for the event-bridge console
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
const { spawn, spawnSync } = require('child_process');
|
|
31
|
-
const net = require('net');
|
|
32
|
-
const path = require('path');
|
|
33
|
-
const { StorageLayout } = require('./storage-layout');
|
|
34
|
-
const { resolveModuleUrl } = require('./module-port-resolver');
|
|
35
|
-
const fs = require('fs');
|
|
36
|
-
const crypto = require('crypto');
|
|
37
|
-
const { once } = require('events');
|
|
38
|
-
const { DomainError } = require('./contracts');
|
|
39
|
-
|
|
40
|
-
const PROXY_TIMEOUT_MS = 10000;
|
|
41
|
-
// Native client startup includes knowledge retrieval and a local server/CLI
|
|
42
|
-
// handshake. It has a bounded setup budget, separate from ordinary reads/SSE.
|
|
43
|
-
const AGENT_START_TIMEOUT_MS = 90000;
|
|
44
|
-
// Registration drives a full embedding pass in vector-hub (remote API); a
|
|
45
|
-
// fresh knowledge folder can easily exceed the interactive proxy budget.
|
|
46
|
-
const REGISTER_TIMEOUT_MS = 60000;
|
|
47
|
-
// Match knowledge-engine's index request budget for synchronous embedding.
|
|
48
|
-
const INDEX_TIMEOUT_MS = 120000;
|
|
49
|
-
// Native folder dialogs wait for the user without a bound.
|
|
50
|
-
const PICKER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
51
|
-
|
|
52
|
-
// Vendored module runtimes shipped inside the npm package. Sibling-checkout
|
|
53
|
-
// spawning is gone: the services are bundled, spawned from here, and resolved
|
|
54
|
-
// through normal node_modules resolution
|
|
55
|
-
// node_modules.
|
|
56
|
-
// Module entry scripts resolve through normal node_modules dependencies
|
|
57
|
-
// (claude-ai-workbench, @sanqianx/vector-hub, @sanqianx/ai-coding-event-bridge-
|
|
58
|
-
// console) — the npm packages are the single source of these runtimes.
|
|
59
|
-
const MODULE_ENTRIES = {
|
|
60
|
-
terminal: 'claude-ai-workbench/packages/server/bin/agent-terminal-server.js',
|
|
61
|
-
'vector-hub': '@sanqianx/vector-hub/bin.js',
|
|
62
|
-
'event-bridge': '@sanqianx/ai-coding-event-bridge-console/src/bin.js',
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
function resolveModuleEntry(name) {
|
|
66
|
-
if (name === 'knowledge-engine') {
|
|
67
|
-
const runtime = path.resolve(__dirname, '..', '..', '_modules', 'knowledge-engine', 'src', 'bin.js');
|
|
68
|
-
return fs.existsSync(runtime) ? runtime : null;
|
|
69
|
-
}
|
|
70
|
-
try { return require.resolve(MODULE_ENTRIES[name]); } catch { return null; }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function urlPort(baseUrl, fallback) {
|
|
74
|
-
try { return new URL(baseUrl).port || String(fallback); } catch { return String(fallback); }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function urlHost(baseUrl) {
|
|
78
|
-
try { return new URL(baseUrl).hostname || '127.0.0.1'; } catch { return '127.0.0.1'; }
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function defaultTerminalUrl() {
|
|
82
|
-
return String(process.env.KB_TERMINAL_URL || 'http://127.0.0.1:5760').replace(/\/+$/, '');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function defaultVectorHubUrl() {
|
|
86
|
-
return String(process.env.KB_VECTORHUB_URL || 'http://127.0.0.1:8787').replace(/\/+$/, '');
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function defaultEventBridgeUrl() {
|
|
90
|
-
return String(process.env.KB_EVENTBRIDGE_URL || 'http://127.0.0.1:8790').replace(/\/+$/, '');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function defaultEngineUrl() { return String(process.env.KB_KNOWLEDGE_ENGINE_URL || 'http://127.0.0.1:5790').replace(/\/+$/, ''); }
|
|
94
|
-
|
|
95
|
-
// Health identity markers: an HTTP 200 alone can come from any foreign
|
|
96
|
-
// service that grabbed the port. Only a body that matches the product
|
|
97
|
-
// counts as "our module already running" (reuse); anything else is
|
|
98
|
-
// foreign and the resolver moves to another port instead of crash-looping.
|
|
99
|
-
const MODULE_IDENTITY = {
|
|
100
|
-
terminal: body => body && typeof body === 'object' && body.status === 'ok' && typeof body.apiVersion === 'string',
|
|
101
|
-
vectorHub: body => body && typeof body === 'object' && Array.isArray(body.projects),
|
|
102
|
-
eventBridge: body => body && typeof body === 'object' && typeof body.registeredProjects === 'number',
|
|
103
|
-
engine: body => body && typeof body === 'object' && body.name === 'knowledge-engine',
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// kind -> bridge URL property, explicit env override, spawn spec name
|
|
107
|
-
const MODULE_URL_TARGETS = [
|
|
108
|
-
{ kind: 'terminal', prop: 'terminalUrl', env: 'KB_TERMINAL_URL', specName: 'terminal' },
|
|
109
|
-
{ kind: 'vectorHub', prop: 'vectorHubUrl', env: 'KB_VECTORHUB_URL', specName: 'vector-hub' },
|
|
110
|
-
{ kind: 'eventBridge', prop: 'eventBridgeUrl', env: 'KB_EVENTBRIDGE_URL', specName: 'event-bridge' },
|
|
111
|
-
{ kind: 'engine', prop: 'engineUrl', env: 'KB_KNOWLEDGE_ENGINE_URL', specName: 'knowledge-engine' },
|
|
112
|
-
];
|
|
113
|
-
|
|
114
|
-
function specNameToKind(specName) {
|
|
115
|
-
return MODULE_URL_TARGETS.find(target => target.specName === specName)?.kind || null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function tcpPortFree(host, port) {
|
|
119
|
-
return new Promise(resolve => {
|
|
120
|
-
const tester = net.createServer();
|
|
121
|
-
let done = false;
|
|
122
|
-
const finish = free => { if (done) return; done = true; tester.close(() => resolve(free)); };
|
|
123
|
-
tester.once('error', () => finish(false));
|
|
124
|
-
tester.once('listening', () => finish(true));
|
|
125
|
-
tester.listen(port, host);
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
class ModuleBridge {
|
|
130
|
-
constructor(options = {}) {
|
|
131
|
-
this.logger = options.logger || { info: () => {}, warn: () => {}, error: () => {} };
|
|
132
|
-
this.terminalUrl = options.terminalUrl || defaultTerminalUrl();
|
|
133
|
-
this.vectorHubUrl = options.vectorHubUrl || defaultVectorHubUrl();
|
|
134
|
-
this.eventBridgeUrl = options.eventBridgeUrl || defaultEventBridgeUrl();
|
|
135
|
-
this.engineUrl = options.engineUrl || defaultEngineUrl();
|
|
136
|
-
// Explicitly injected URLs (option or env) mean an external service;
|
|
137
|
-
// only managed defaults participate in dynamic port resolution.
|
|
138
|
-
this.explicitModuleUrls = {
|
|
139
|
-
terminal: Boolean(options.terminalUrl || process.env.KB_TERMINAL_URL),
|
|
140
|
-
vectorHub: Boolean(options.vectorHubUrl || process.env.KB_VECTORHUB_URL),
|
|
141
|
-
eventBridge: Boolean(options.eventBridgeUrl || process.env.KB_EVENTBRIDGE_URL),
|
|
142
|
-
engine: Boolean(options.engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL),
|
|
143
|
-
};
|
|
144
|
-
this.moduleUrlSources = { terminal: 'pending', vectorHub: 'pending', eventBridge: 'pending', engine: 'pending' };
|
|
145
|
-
this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
|
|
146
|
-
this.registryStore = options.registryStore || null;
|
|
147
|
-
this.projectStore = options.projectStore || null;
|
|
148
|
-
this.projectWiringQueue = new Map();
|
|
149
|
-
this.settingsStore = options.settingsStore || null;
|
|
150
|
-
this.dataDir = options.dataDir || null;
|
|
151
|
-
this.supervised = [];
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
_terminalApi(pathname) {
|
|
155
|
-
// /api/terminal/<rest> -> <terminal>/api/claude-workbench/v1/<rest>
|
|
156
|
-
const rest = pathname.replace(/^\/api\/terminal\/?/, '');
|
|
157
|
-
return `${this.terminalUrl}/api/claude-workbench/v1/${rest}`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
_vectorHubApi(pathname) {
|
|
161
|
-
// /api/vectorhub/<rest> -> <vectorhub>/api/<rest>
|
|
162
|
-
const rest = pathname.replace(/^\/api\/vectorhub\/?/, '');
|
|
163
|
-
return `${this.vectorHubUrl}/api/${rest}`;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
_eventBridgeApi(pathname) {
|
|
167
|
-
// /api/eventbridge/<rest> -> <event-bridge console>/api/<rest>
|
|
168
|
-
const rest = pathname.replace(/^\/api\/eventbridge\/?/, '');
|
|
169
|
-
return `${this.eventBridgeUrl}/api/${rest}`;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async _fetch(url, init = {}) {
|
|
173
|
-
const controller = new AbortController();
|
|
174
|
-
const timer = setTimeout(() => controller.abort(), init.timeoutMs || PROXY_TIMEOUT_MS);
|
|
175
|
-
try {
|
|
176
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
177
|
-
} finally {
|
|
178
|
-
clearTimeout(timer);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Same-origin proxy. SSE has a headers deadline, never a whole-turn deadline. */
|
|
183
|
-
async proxy(req, res, target) {
|
|
184
|
-
const chunks = [];
|
|
185
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
186
|
-
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
|
187
|
-
// The native folder pickers hold the request open until the user closes
|
|
188
|
-
// the dialog — they need a human-scale budget, not the API budget.
|
|
189
|
-
const isPicker = /\/(system\/)?pick-folder/.test(target);
|
|
190
|
-
const targetUrl = new URL(target);
|
|
191
|
-
const pathname = targetUrl.pathname;
|
|
192
|
-
const isAgentStart = req.method === 'POST' && /^\/api\/claude-workbench\/v1\/sessions(?:\/[^/]+\/input)?\/?$/.test(pathname);
|
|
193
|
-
const isIndex = req.method === 'POST' && pathname === '/api/sync' && targetUrl.origin === new URL(this.vectorHubUrl).origin;
|
|
194
|
-
const timeoutMs = isPicker ? PICKER_TIMEOUT_MS : isAgentStart ? AGENT_START_TIMEOUT_MS : isIndex ? INDEX_TIMEOUT_MS : PROXY_TIMEOUT_MS;
|
|
195
|
-
const startedAt = Date.now();
|
|
196
|
-
let upstream;
|
|
197
|
-
const controller = new AbortController();
|
|
198
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
199
|
-
const disconnected = () => { if (!res.writableEnded) controller.abort(); };
|
|
200
|
-
res.once?.('close', disconnected);
|
|
201
|
-
try {
|
|
202
|
-
upstream = await fetch(target, {
|
|
203
|
-
method: req.method,
|
|
204
|
-
signal: controller.signal,
|
|
205
|
-
headers: { ...(body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {}), ...(req.headers['last-event-id'] ? { 'Last-Event-ID': req.headers['last-event-id'] } : {}) },
|
|
206
|
-
body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
|
|
207
|
-
});
|
|
208
|
-
} catch (error) {
|
|
209
|
-
clearTimeout(timer);
|
|
210
|
-
res.off?.('close', disconnected);
|
|
211
|
-
if (res.destroyed) return;
|
|
212
|
-
const details = { failureKind: controller.signal.aborted ? 'UPSTREAM_TIMEOUT' : 'NETWORK_ERROR', timeoutMs };
|
|
213
|
-
this.logger.warn('module.proxy.failed', '模块请求未完成,请检查连接或客户端启动。', {
|
|
214
|
-
component: 'module-bridge', phase: 'proxy', durationMs: Date.now() - startedAt,
|
|
215
|
-
context: { ...details, method: req.method, path: pathname },
|
|
216
|
-
});
|
|
217
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
218
|
-
res.end(JSON.stringify({ ok: false, error: 'MODULE_UNREACHABLE', target: targetUrl.origin + pathname, details }));
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
|
|
222
|
-
if (contentType.includes('text/event-stream')) {
|
|
223
|
-
clearTimeout(timer);
|
|
224
|
-
res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
225
|
-
res.flushHeaders?.();
|
|
226
|
-
try {
|
|
227
|
-
for await (const chunk of upstream.body) {
|
|
228
|
-
if (res.destroyed) break;
|
|
229
|
-
if (!res.write(Buffer.from(chunk))) await once(res, 'drain', { signal: controller.signal });
|
|
230
|
-
}
|
|
231
|
-
if (!res.destroyed) res.end();
|
|
232
|
-
} catch { if (!res.destroyed) res.destroy(); }
|
|
233
|
-
finally { controller.abort(); res.off?.('close', disconnected); }
|
|
234
|
-
} else {
|
|
235
|
-
try {
|
|
236
|
-
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
237
|
-
res.writeHead(upstream.status, { 'Content-Type': contentType });
|
|
238
|
-
res.end(buf);
|
|
239
|
-
} finally { clearTimeout(timer); res.off?.('close', disconnected); }
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
proxyTerminal(req, res, pathname, search) {
|
|
244
|
-
return this.proxy(req, res, this._terminalApi(pathname) + (search || ''));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
proxyVectorHub(req, res, pathname, search) {
|
|
248
|
-
return this.proxy(req, res, this._vectorHubApi(pathname) + (search || ''));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
proxyEventBridge(req, res, pathname, search) {
|
|
252
|
-
return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
proxyEngine(req, res, pathname, search) {
|
|
256
|
-
if (this.engineCompatible === false) throw new DomainError('ENGINE_CONNECTION_PENDING', '知识引擎连接尚未确认,请查看系统状态;不会读取旧服务中的知识。', { status: 503 });
|
|
257
|
-
return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
proxyModuleUi(req, res, module, rest, search) {
|
|
261
|
-
const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
|
|
262
|
-
let decoded;
|
|
263
|
-
try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
|
|
264
|
-
// Reject encoded separators/dot segments before URL normalization. Static
|
|
265
|
-
// mounts must never become a second route to a module's private API.
|
|
266
|
-
if (!base || req.method !== 'GET' || decoded === null || /[\\\x00-\x1f]/.test(decoded) || decoded.split('/').some(part => ['.', '..', 'api', 'internal'].includes(part.toLowerCase())) || /%[0-9a-f]{2}/i.test(decoded)) { res.statusCode = 404; res.end('Not found'); return; }
|
|
267
|
-
const target = new URL(`${base}/${rest}${search || ''}`);
|
|
268
|
-
if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
|
|
269
|
-
return this.proxy(req, res, target.href);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
async _listTerminalProjects({ requireAvailable = false } = {}) {
|
|
273
|
-
try {
|
|
274
|
-
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, { timeoutMs: 2500 });
|
|
275
|
-
if (!response.ok) throw new Error('TERMINAL_REGISTRY_UNAVAILABLE');
|
|
276
|
-
const payload = await response.json();
|
|
277
|
-
const list = Array.isArray(payload) ? payload : payload.projects;
|
|
278
|
-
if (!Array.isArray(list)) throw new Error('TERMINAL_REGISTRY_INVALID');
|
|
279
|
-
return list;
|
|
280
|
-
} catch { return requireAvailable ? null : []; }
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
async _listVectorHubProjects() {
|
|
284
|
-
try {
|
|
285
|
-
const response = await this._fetch(`${this.vectorHubUrl}/api/projects`, { timeoutMs: 2500 });
|
|
286
|
-
if (!response.ok) return [];
|
|
287
|
-
const payload = await response.json();
|
|
288
|
-
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
289
|
-
} catch { return []; }
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async _listEventBridgeProjects() {
|
|
293
|
-
try {
|
|
294
|
-
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, { timeoutMs: 2500 });
|
|
295
|
-
if (!response.ok) return [];
|
|
296
|
-
const payload = await response.json();
|
|
297
|
-
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
298
|
-
} catch { return []; }
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async _listEngineProjects() {
|
|
302
|
-
if (this.engineCompatible === false) return [];
|
|
303
|
-
try {
|
|
304
|
-
const response = await this._fetch(`${this.engineUrl}/api/projects`, { timeoutMs: 2500 });
|
|
305
|
-
if (!response.ok) return [];
|
|
306
|
-
const payload = await response.json();
|
|
307
|
-
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
308
|
-
} catch { return []; }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Aggregated projection for the shell sidebar: pkb registry is the spine. */
|
|
312
|
-
async aggregatedProjects() {
|
|
313
|
-
const healthJob = this.modulesHealth();
|
|
314
|
-
const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
|
|
315
|
-
this._listTerminalProjects(),
|
|
316
|
-
this._listVectorHubProjects(),
|
|
317
|
-
this._listEventBridgeProjects(),
|
|
318
|
-
this._listEngineProjects(),
|
|
319
|
-
]);
|
|
320
|
-
const ids = this.registryStore ? this.registryStore.listIds() : [];
|
|
321
|
-
const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
|
|
322
|
-
const byName = new Map(vectorHubProjects.map(p => [String(p.name), p]));
|
|
323
|
-
const ebByPath = new Map(eventBridgeProjects.filter(p => p.path).map(p => [String(p.path).toLowerCase(), p]));
|
|
324
|
-
const projects = ids.map(projectId => {
|
|
325
|
-
const config = this.projectStore ? (this.projectStore.readConfig(projectId) || {}) : {};
|
|
326
|
-
const workspacePath = String(config.repoPath || '');
|
|
327
|
-
const knowledgePath = String(config.knowledgePath || '');
|
|
328
|
-
const state = this.projectStore ? this.projectStore.readState(projectId) : {};
|
|
329
|
-
const wiring = state.modules || {};
|
|
330
|
-
const terminal = (wiring.terminal && wiring.terminal.projectId ? terminalProjects.find(p => p.id === wiring.terminal.projectId) : null)
|
|
331
|
-
|| byWorkspace.get(workspacePath.toLowerCase())
|
|
332
|
-
|| null;
|
|
333
|
-
const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
|
|
334
|
-
const vectorHub = byName.get(vectorHubName) || null;
|
|
335
|
-
const eventBridge = ebByPath.get(workspacePath.toLowerCase())
|
|
336
|
-
|| (wiring.eventBridge && wiring.eventBridge.projectId ? eventBridgeProjects.find(p => p.id === wiring.eventBridge.projectId) : null)
|
|
337
|
-
|| null;
|
|
338
|
-
return {
|
|
339
|
-
projectId,
|
|
340
|
-
name: config.displayName || projectId,
|
|
341
|
-
workspacePath,
|
|
342
|
-
knowledgePath,
|
|
343
|
-
brainId: config.brainId || 'brain-default',
|
|
344
|
-
domainId: config.domainId || null,
|
|
345
|
-
modules: {
|
|
346
|
-
terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
|
|
347
|
-
vectorHub: vectorHub ? { registered: true, projectName: vectorHub.name, docCount: vectorHub.docCount, chunkCount: vectorHub.chunkCount, watching: !!vectorHub.watching } : { registered: false, status: wiring.vectorHub && wiring.vectorHub.status },
|
|
348
|
-
eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
|
|
349
|
-
engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
|
|
350
|
-
},
|
|
351
|
-
};
|
|
352
|
-
});
|
|
353
|
-
const health = (await healthJob).modules;
|
|
354
|
-
return {
|
|
355
|
-
projects,
|
|
356
|
-
terminalAvailable: health.terminal.up, vectorHubAvailable: health.vectorHub.up, eventBridgeAvailable: health.eventBridge.up, engineAvailable: health.engine.up,
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Classify what is listening on a module URL.
|
|
362
|
-
* 'module' HTTP 2xx + product identity marker -> safe to reuse
|
|
363
|
-
* 'foreign' HTTP responded but identity mismatch -> never reuse
|
|
364
|
-
* 'unreachable' no HTTP answer (free port or non-HTTP listener)
|
|
365
|
-
*/
|
|
366
|
-
async _probeClass(base, kind) {
|
|
367
|
-
const route = kind === 'terminal' ? '/api/claude-workbench/v1/health' : '/api/health';
|
|
368
|
-
const marker = MODULE_IDENTITY[kind];
|
|
369
|
-
if (!marker) return 'unreachable';
|
|
370
|
-
try {
|
|
371
|
-
const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 });
|
|
372
|
-
if (!response.ok) return 'unreachable';
|
|
373
|
-
let body;
|
|
374
|
-
try { body = await response.json(); } catch { return 'foreign'; }
|
|
375
|
-
return marker(body) ? 'module' : 'foreign';
|
|
376
|
-
} catch { return 'unreachable'; }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
async _probe(base, kind) {
|
|
380
|
-
const resolvedKind = kind || (base === this.terminalUrl ? 'terminal' : null);
|
|
381
|
-
if (!resolvedKind) return false;
|
|
382
|
-
return (await this._probeClass(base, resolvedKind)) === 'module';
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async modulesHealth() {
|
|
386
|
-
const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
|
|
387
|
-
this._probe(this.terminalUrl, 'terminal'),
|
|
388
|
-
this._probe(this.vectorHubUrl, 'vectorHub'),
|
|
389
|
-
this._probe(this.eventBridgeUrl, 'eventBridge'),
|
|
390
|
-
this._probe(this.engineUrl, 'engine'),
|
|
391
|
-
]);
|
|
392
|
-
return { ok: true, modules: {
|
|
393
|
-
terminal: { url: this.terminalUrl, up: terminal, source: this.moduleUrlSources.terminal },
|
|
394
|
-
vectorHub: { url: this.vectorHubUrl, up: vectorHub, source: this.moduleUrlSources.vectorHub },
|
|
395
|
-
eventBridge: { url: this.eventBridgeUrl, up: eventBridge, source: this.moduleUrlSources.eventBridge },
|
|
396
|
-
engine: { url: this.engineUrl, up: engine, source: this.moduleUrlSources.engine },
|
|
397
|
-
} };
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
syncEngineConfiguration({ force = false } = {}) {
|
|
401
|
-
const signature = () => { try { return crypto.createHash('sha256').update(JSON.stringify(this.settingsStore?.read() || {})).digest('hex'); } catch { return ''; } };
|
|
402
|
-
if (this.configSyncPromise) {
|
|
403
|
-
if (force || signature() !== this.configSyncSettings) this.configSyncDirty = true;
|
|
404
|
-
return this.configSyncPromise;
|
|
405
|
-
}
|
|
406
|
-
const pending = (async () => {
|
|
407
|
-
let result;
|
|
408
|
-
do { this.configSyncDirty = false; this.configSyncSettings = signature(); result = await this._syncEngineConfiguration(); } while (this.configSyncDirty && !this.stopped);
|
|
409
|
-
return result;
|
|
410
|
-
})().finally(() => { if (this.configSyncPromise === pending) this.configSyncPromise = null; });
|
|
411
|
-
this.configSyncPromise = pending;
|
|
412
|
-
return pending;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
resetRuntimeConfiguration() {
|
|
416
|
-
this.configFingerprints = {};
|
|
417
|
-
this.configGeneration = (this.configGeneration || 0) + 1;
|
|
418
|
-
if (this.configSyncPromise) this.configSyncDirty = true;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
async _syncEngineConfiguration() {
|
|
422
|
-
const applyConfiguration = async (key, url, body) => {
|
|
423
|
-
this.configFingerprints ||= {};
|
|
424
|
-
const generation = this.configGeneration;
|
|
425
|
-
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
|
426
|
-
if (this.configFingerprints[key]?.fingerprint === fingerprint) return this.configFingerprints[key].result;
|
|
427
|
-
const response = await this._fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` }, body: JSON.stringify(body) });
|
|
428
|
-
const result = { ok: response.ok, data: response.ok ? await response.json().catch(() => ({})) : null };
|
|
429
|
-
if (result.ok && this.configGeneration === generation) this.configFingerprints[key] = { fingerprint, result };
|
|
430
|
-
else delete this.configFingerprints[key];
|
|
431
|
-
return result;
|
|
432
|
-
};
|
|
433
|
-
const dataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
434
|
-
let indexPath = null;
|
|
435
|
-
try { indexPath = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read()); }
|
|
436
|
-
catch { /* Corrupt settings must not switch storage or prevent credential revocation. */ }
|
|
437
|
-
const status = { scopes: false, scopeStatus: { status: 'pending' }, model: false, embedding: false, indexStorage: { expectedPath: indexPath, actualPath: null, status: 'pending' } };
|
|
438
|
-
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
|
|
439
|
-
try {
|
|
440
|
-
if (this.settingsStore) {
|
|
441
|
-
const settings = this.settingsStore.read();
|
|
442
|
-
if (settings.knowledge.rootPath) {
|
|
443
|
-
const brain = settings.brain;
|
|
444
|
-
const root = path.join(settings.knowledge.rootPath, '_shared');
|
|
445
|
-
const response = await this._fetch(`${this.engineUrl}/api/snapshot?summary=1&limit=1`);
|
|
446
|
-
if (!response.ok) throw new DomainError('ENGINE_UNAVAILABLE', '共享知识接线暂时不可用。');
|
|
447
|
-
const prior = (await response.json()).sharedScopes || [];
|
|
448
|
-
// Root settings govern new directories and derived indexes, never move
|
|
449
|
-
// existing knowledge. Retain engine-acknowledged bindings in the spine.
|
|
450
|
-
const binding = (item, kind) => {
|
|
451
|
-
const existing = prior.find(scope => scope.id === item.id && scope.kind === kind && scope.brainId === brain.id);
|
|
452
|
-
if (existing && !path.isAbsolute(existing.knowledgePath || '')) throw new DomainError('DATA_CORRUPT', '共享目录绑定损坏。');
|
|
453
|
-
return item.knowledgePath || existing?.knowledgePath || path.join(root, item.id);
|
|
454
|
-
};
|
|
455
|
-
const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: binding(brain, 'brain') }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: binding(d, 'domain') }))];
|
|
456
|
-
const applied = await applyConfiguration('scopes', `${this.engineUrl}/api/scopes`, { scopes });
|
|
457
|
-
if (!applied.ok) throw new DomainError('SHARED_SCOPE_APPLY_FAILED', '共享目录接线未完成。');
|
|
458
|
-
await this.settingsStore.bindSharedKnowledgePaths?.(scopes);
|
|
459
|
-
status.scopes = true; status.scopeStatus = { status: 'ready' };
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
} catch (error) {
|
|
463
|
-
status.scopeStatus = { status: 'pending', code: ['DATA_CORRUPT', 'IMMUTABLE_FIELD', 'ENGINE_UNAVAILABLE', 'SHARED_SCOPE_APPLY_FAILED'].includes(error.code) ? error.code : 'ENGINE_UNAVAILABLE' };
|
|
464
|
-
this.logger.warn('modules.scopes.pending', 'Shared knowledge wiring is pending; existing data is retained.', { context: { code: status.scopeStatus.code } });
|
|
465
|
-
// A scope failure must not prevent revoking a stale model key.
|
|
466
|
-
}
|
|
467
|
-
try {
|
|
468
|
-
let profile = null;
|
|
469
|
-
try {
|
|
470
|
-
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
|
|
471
|
-
if (response.ok) profile = await response.json();
|
|
472
|
-
} catch { /* Missing configuration must clear a previously resolved key. */ }
|
|
473
|
-
const response = await applyConfiguration('model', `${this.engineUrl}/api/runtime-config`, { projectId: 'default', profile });
|
|
474
|
-
status.model = response.ok && !!profile;
|
|
475
|
-
} catch { /* Status projection only: never log resolved profiles or raw errors. */ }
|
|
476
|
-
try {
|
|
477
|
-
let settings = null;
|
|
478
|
-
try {
|
|
479
|
-
const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
|
|
480
|
-
if (resolved.ok) settings = await resolved.json();
|
|
481
|
-
} catch { /* Revocation wins over a previously cached runtime key. */ }
|
|
482
|
-
const applied = await applyConfiguration('embedding', `${this.vectorHubUrl}/api/internal/runtime-config`, { settings, ...(indexPath ? { storageRoot: indexPath } : {}) });
|
|
483
|
-
status.embedding = applied.ok && !!settings;
|
|
484
|
-
if (applied.ok) {
|
|
485
|
-
const result = applied.data;
|
|
486
|
-
status.indexStorage.actualPath = result.rootPath || null;
|
|
487
|
-
status.indexStorage.status = result.rootPath && path.resolve(result.rootPath) === indexPath ? 'ready' : 'pending';
|
|
488
|
-
}
|
|
489
|
-
} catch { /* Public status contains no resolved configuration. */ }
|
|
490
|
-
this.configStatus = status;
|
|
491
|
-
return status;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Register one pkb project into both modules and persist the wiring table.
|
|
496
|
-
* Best-effort per module: a module that is down is recorded as 'pending'
|
|
497
|
-
* (retry on the next register call or health-check pass), never a hard error.
|
|
498
|
-
* KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
|
|
499
|
-
*/
|
|
500
|
-
_withProjectWiring(projectId, action) {
|
|
501
|
-
const prior = this.projectWiringQueue.get(projectId) || Promise.resolve();
|
|
502
|
-
const operation = prior.catch(() => {}).then(action);
|
|
503
|
-
const settled = operation.finally(() => { if (this.projectWiringQueue.get(projectId) === settled) this.projectWiringQueue.delete(projectId); });
|
|
504
|
-
this.projectWiringQueue.set(projectId, settled);
|
|
505
|
-
return settled;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
registerProjectLinks(input) {
|
|
509
|
-
return this._withProjectWiring(input.projectId, () => {
|
|
510
|
-
// A supervisor may have captured this project before a concurrent unbind.
|
|
511
|
-
// Recheck the spine after acquiring the same lease as the removal path.
|
|
512
|
-
if (this.registryStore && !this.registryStore.readDisplaySnapshot(input.projectId)) return Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { ok: true, status: 'removed', skipped: 'project-not-registered' }]));
|
|
513
|
-
return this._registerProjectLinks(input);
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
async _registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
|
|
518
|
-
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
519
|
-
if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
|
|
520
|
-
result.terminal = { ok: false, status: 'disabled' };
|
|
521
|
-
result.vectorHub = { ok: false, status: 'disabled' };
|
|
522
|
-
result.eventBridge = { ok: false, status: 'disabled' };
|
|
523
|
-
result.engine = { ok: false, status: 'disabled' };
|
|
524
|
-
return result;
|
|
525
|
-
}
|
|
526
|
-
// 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
|
|
527
|
-
const terminalProjects = await this._listTerminalProjects({ requireAvailable: true });
|
|
528
|
-
const existingTerminal = terminalProjects?.find(p => String(p.rootPath || '').toLowerCase() === String(workspacePath || '').toLowerCase());
|
|
529
|
-
if (terminalProjects === null) {
|
|
530
|
-
// Startup/reconnect failures do not prove the workspace is absent. Keep
|
|
531
|
-
// its saved context so a retry cannot strand existing sessions/drafts.
|
|
532
|
-
result.terminal = { ok: false, status: 'pending' };
|
|
533
|
-
} else if (existingTerminal) {
|
|
534
|
-
result.terminal = { ok: true, projectId: existingTerminal.id, status: 'registered', reused: true };
|
|
535
|
-
} else {
|
|
536
|
-
try {
|
|
537
|
-
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, {
|
|
538
|
-
method: 'POST',
|
|
539
|
-
headers: { 'Content-Type': 'application/json' },
|
|
540
|
-
body: JSON.stringify({ name: name || projectId, rootPath: workspacePath }),
|
|
541
|
-
});
|
|
542
|
-
const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
|
|
543
|
-
result.terminal = { ok: response.ok, projectId: payload.id || payload.projectId || null, status: response.ok ? 'registered' : 'error' };
|
|
544
|
-
} catch {
|
|
545
|
-
result.terminal = { ok: false, status: 'pending' };
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
|
|
549
|
-
if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
|
|
550
|
-
else try {
|
|
551
|
-
const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
|
|
552
|
-
method: 'POST',
|
|
553
|
-
headers: { 'Content-Type': 'application/json' },
|
|
554
|
-
timeoutMs: REGISTER_TIMEOUT_MS,
|
|
555
|
-
body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
|
|
556
|
-
});
|
|
557
|
-
result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
|
|
558
|
-
} catch {
|
|
559
|
-
result.vectorHub = { ok: false, status: 'pending' };
|
|
560
|
-
}
|
|
561
|
-
// 幂等登记:会话控制台按 path(Git work tree)复用已有登记。store 遵循
|
|
562
|
-
// event-bridge 的推荐布局 <知识库>/dev-conversations;无知识库地址时交给
|
|
563
|
-
// 控制台的默认 store(全局 journal),登记本身不受阻。
|
|
564
|
-
const existingEventBridge = (await this._listEventBridgeProjects())
|
|
565
|
-
.find(p => !p.auto && String(p.path || '').toLowerCase() === String(workspacePath || '').toLowerCase());
|
|
566
|
-
if (existingEventBridge) {
|
|
567
|
-
result.eventBridge = { ok: true, projectId: existingEventBridge.id, status: 'registered', reused: true };
|
|
568
|
-
} else {
|
|
569
|
-
try {
|
|
570
|
-
const body = { path: workspacePath };
|
|
571
|
-
if (knowledgePath) body.store = path.join(knowledgePath, 'dev-conversations');
|
|
572
|
-
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, {
|
|
573
|
-
method: 'POST',
|
|
574
|
-
headers: { 'Content-Type': 'application/json' },
|
|
575
|
-
body: JSON.stringify(body),
|
|
576
|
-
});
|
|
577
|
-
const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
|
|
578
|
-
const registeredProject = payload.project || {};
|
|
579
|
-
result.eventBridge = { ok: response.ok, projectId: registeredProject.id || null, status: response.ok ? 'registered' : 'error', errorCode: (!response.ok && payload.error && payload.error.code) || undefined };
|
|
580
|
-
} catch {
|
|
581
|
-
result.eventBridge = { ok: false, status: 'pending' };
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
|
|
585
|
-
const state = this.projectStore ? this.projectStore.readState(projectId) : {};
|
|
586
|
-
try {
|
|
587
|
-
const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
|
|
588
|
-
const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
|
|
589
|
-
if (journalId && baselines[journalId] === undefined) {
|
|
590
|
-
const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
|
|
591
|
-
const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
|
|
592
|
-
if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
|
|
593
|
-
baselines[journalId] = current.sequence;
|
|
594
|
-
if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
|
|
595
|
-
}
|
|
596
|
-
const response = await this._fetch(`${this.engineUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, name: name || projectId, repoPath: workspacePath, knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, journalId: journalId || null, vectorProjectId: projectId, trackingStartCommit: state.trackingStartCommit || state.lastAnalyzedCommit || null, conversationBaselineCursor: state.conversationBaselineCursor ?? 0, conversationBaselineCursors: baselines, profileId: config.aiProfileId || null }) });
|
|
597
|
-
result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
|
|
598
|
-
} catch { result.engine = { ok: false, status: 'pending' }; }
|
|
599
|
-
await this._writeWiring(projectId, wiring => {
|
|
600
|
-
wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
|
|
601
|
-
wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
|
|
602
|
-
wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
|
|
603
|
-
wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
|
|
604
|
-
});
|
|
605
|
-
this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
|
|
606
|
-
return result;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** Remove module registrations. Local data is preserved by contract: vector-hub
|
|
610
|
-
* only drops its derived index (source untouched); terminal removes the project
|
|
611
|
-
* with keepSessions=1 so session records survive; the event-bridge console only
|
|
612
|
-
* unregisters the project — journals and sealed commit documents stay on disk. */
|
|
613
|
-
removeProjectLinks(input) {
|
|
614
|
-
return this._withProjectWiring(input.projectId, async () => {
|
|
615
|
-
const result = await this._removeProjectLinks(input);
|
|
616
|
-
// The caller owns registry/lifecycle changes. Hold the wiring lease until
|
|
617
|
-
// its transaction settles, so delayed reconfiguration cannot resurrect it.
|
|
618
|
-
await input.onRemoved?.(result);
|
|
619
|
-
return result;
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
async _removeProjectLinks({ projectId, requireEngine = true }) {
|
|
624
|
-
const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
|
|
625
|
-
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
626
|
-
// Stop the writer first, before removing hooks/index registrations.
|
|
627
|
-
try {
|
|
628
|
-
if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
|
|
629
|
-
else {
|
|
630
|
-
const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
|
|
631
|
-
if (response.status === 409) throw new DomainError('PROJECT_BUSY', '此项目正在执行知识维护,请等待写入结束后再解绑。未开始的排队任务会随解绑取消。', { status: 409, retryable: true });
|
|
632
|
-
result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
|
|
633
|
-
}
|
|
634
|
-
} catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
|
|
635
|
-
if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
|
|
636
|
-
let terminalId = wiring.terminal && wiring.terminal.projectId;
|
|
637
|
-
if (!terminalId) {
|
|
638
|
-
const workspacePath = wiring.terminal && wiring.terminal.rootPath;
|
|
639
|
-
const match = (await this._listTerminalProjects())
|
|
640
|
-
.find(p => (workspacePath && String(p.rootPath || '').toLowerCase() === String(workspacePath).toLowerCase()) || p.name === projectId);
|
|
641
|
-
terminalId = match && match.id;
|
|
642
|
-
}
|
|
643
|
-
if (terminalId) {
|
|
644
|
-
try {
|
|
645
|
-
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects/${encodeURIComponent(terminalId)}?keepSessions=1`, { method: 'DELETE' });
|
|
646
|
-
result.terminal = { ok: response.ok || response.status === 204 };
|
|
647
|
-
} catch { result.terminal = { ok: false, status: 'pending' }; }
|
|
648
|
-
} else {
|
|
649
|
-
result.terminal = { ok: true, skipped: 'not-registered' };
|
|
650
|
-
}
|
|
651
|
-
const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
|
|
652
|
-
try {
|
|
653
|
-
const response = await this._fetch(`${this.vectorHubUrl}/api/project?name=${encodeURIComponent(vectorHubName)}`, { method: 'DELETE' });
|
|
654
|
-
result.vectorHub = { ok: response.ok || response.status === 204 || response.status === 404 };
|
|
655
|
-
} catch { result.vectorHub = { ok: false, status: 'pending' }; }
|
|
656
|
-
let eventBridgeId = wiring.eventBridge && wiring.eventBridge.projectId;
|
|
657
|
-
if (!eventBridgeId) {
|
|
658
|
-
const workspacePath = wiring.eventBridge && wiring.eventBridge.path;
|
|
659
|
-
const match = (await this._listEventBridgeProjects())
|
|
660
|
-
.find(p => !p.auto && workspacePath && String(p.path || '').toLowerCase() === String(workspacePath).toLowerCase());
|
|
661
|
-
eventBridgeId = match && match.id;
|
|
662
|
-
}
|
|
663
|
-
if (eventBridgeId) {
|
|
664
|
-
try {
|
|
665
|
-
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects?id=${encodeURIComponent(eventBridgeId)}`, { method: 'DELETE' });
|
|
666
|
-
// 404 means already unregistered (or auto entry) — removal goal reached.
|
|
667
|
-
result.eventBridge = { ok: response.ok || response.status === 204 || response.status === 404 };
|
|
668
|
-
} catch { result.eventBridge = { ok: false, status: 'pending' }; }
|
|
669
|
-
} else {
|
|
670
|
-
result.eventBridge = { ok: true, skipped: 'not-registered' };
|
|
671
|
-
}
|
|
672
|
-
await this._writeWiring(projectId, wiring => {
|
|
673
|
-
if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
674
|
-
if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
675
|
-
if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
676
|
-
if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
677
|
-
});
|
|
678
|
-
this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
|
|
679
|
-
return result;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
async _writeWiring(projectId, mutator) {
|
|
683
|
-
if (!this.projectStore) return;
|
|
684
|
-
await this.projectStore.updateState(projectId, state => {
|
|
685
|
-
state.modules = state.modules || {};
|
|
686
|
-
mutator(state.modules);
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* T3.5 process orchestration: spawn the vendored module services as
|
|
692
|
-
* supervised child processes. ON by default now that the services ship in
|
|
693
|
-
* the package — opt out with KB_MODULES_AUTOSTART=0. A service that is
|
|
694
|
-
* already listening on its URL is never spawned twice.
|
|
695
|
-
*/
|
|
696
|
-
async startSupervised() {
|
|
697
|
-
if (String(process.env.KB_MODULES_AUTOSTART || '') === '0') return;
|
|
698
|
-
await this._resolveManagedUrls();
|
|
699
|
-
await Promise.all(this._supervisedSpecs().map(async spec => {
|
|
700
|
-
const kind = specNameToKind(spec.name);
|
|
701
|
-
if (kind && await this._probeClass(spec.url, kind) === 'module') {
|
|
702
|
-
this.logger.info('modules.supervisor', 'Module service already up; not spawning.', { context: { name: spec.name, url: spec.url } });
|
|
703
|
-
this.onReady?.(spec.name); return;
|
|
704
|
-
}
|
|
705
|
-
this._spawnSupervised(spec);
|
|
706
|
-
}));
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* Resolve final managed URLs BEFORE any spec is built or a process is
|
|
711
|
-
* spawned, so inter-module env wiring (engine URL, bridge URL, ...) uses
|
|
712
|
-
* the final dynamic ports. Sequential on purpose: parallel scans could
|
|
713
|
-
* race two kinds into the same overlapping candidate range
|
|
714
|
-
* (8787..8806 vs 8790..8809).
|
|
715
|
-
*/
|
|
716
|
-
async _resolveManagedUrls() {
|
|
717
|
-
for (const target of MODULE_URL_TARGETS) {
|
|
718
|
-
const preferredUrl = this[target.prop];
|
|
719
|
-
const resolved = await resolveModuleUrl({
|
|
720
|
-
kind: target.kind,
|
|
721
|
-
preferredUrl,
|
|
722
|
-
explicit: this.explicitModuleUrls[target.kind],
|
|
723
|
-
portFree: port => tcpPortFree(urlHost(preferredUrl), port),
|
|
724
|
-
probe: url => this._probeClass(url, target.kind),
|
|
725
|
-
});
|
|
726
|
-
this[target.prop] = resolved.url;
|
|
727
|
-
this.moduleUrlSources[target.kind] = resolved.source;
|
|
728
|
-
if (resolved.source !== 'external') {
|
|
729
|
-
this.logger.info('modules.port_resolution', 'Module URL resolved.', { context: { kind: target.kind, source: resolved.source, url: resolved.url, port: resolved.port, offset: resolved.offset } });
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
_supervisedSpecs() {
|
|
735
|
-
const specs = [];
|
|
736
|
-
// C2 minimal wiring: the terminal service receives the non-secret product
|
|
737
|
-
// knowledge root via KB_KNOWLEDGE_DATA_DIR. Its own AGENT_TERMINAL_DATA_DIR
|
|
738
|
-
// stays the dedicated workbench subtree and is never widened.
|
|
739
|
-
const knowledgeDataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
740
|
-
if (process.env.KB_TERMINAL_COMMAND) {
|
|
741
|
-
specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
|
|
742
|
-
} else {
|
|
743
|
-
const terminalEntry = resolveModuleEntry('terminal');
|
|
744
|
-
if (terminalEntry) {
|
|
745
|
-
specs.push({
|
|
746
|
-
name: 'terminal',
|
|
747
|
-
command: `node ${JSON.stringify(terminalEntry)}`,
|
|
748
|
-
url: this.terminalUrl,
|
|
749
|
-
env: { AGENT_TERMINAL_DATA_DIR: path.join(knowledgeDataDir, 'workbench'), KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl), KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl },
|
|
750
|
-
});
|
|
751
|
-
} else {
|
|
752
|
-
this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
// vector-hub declares engines >= 22; spawning it on older runtimes would
|
|
756
|
-
// crash-loop the supervisor for nothing.
|
|
757
|
-
if (process.env.KB_VECTORHUB_COMMAND) {
|
|
758
|
-
specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: { KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken } });
|
|
759
|
-
} else if (parseInt(process.versions.node, 10) < 22) {
|
|
760
|
-
this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
|
|
761
|
-
} else {
|
|
762
|
-
const vectorHubEntry = resolveModuleEntry('vector-hub');
|
|
763
|
-
if (!vectorHubEntry) {
|
|
764
|
-
this.logger.warn('modules.supervisor', '@sanqianx/vector-hub runtime not installed; expecting an external service.', {});
|
|
765
|
-
} else {
|
|
766
|
-
const dataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
767
|
-
const configRoot = path.join(dataDir, 'vectorhub');
|
|
768
|
-
const root = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read());
|
|
769
|
-
specs.push({
|
|
770
|
-
name: 'vector-hub',
|
|
771
|
-
command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
|
|
772
|
-
url: this.vectorHubUrl,
|
|
773
|
-
env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(configRoot, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(configRoot, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
// ai-coding-event-bridge console: plain-JS CommonJS on Node >= 18 — no
|
|
778
|
-
// build gate, no runtime floor beyond the shell's own. The home flag keeps
|
|
779
|
-
// the console on the same bridge home the in-process BridgeAdapter uses,
|
|
780
|
-
// so conversations captured by PK show up in the embedded explorer. The
|
|
781
|
-
// folder-picker warm-up is an interactive-only feature the shell never
|
|
782
|
-
// routes here (import uses the terminal/vector-hub pickers) — skip it.
|
|
783
|
-
if (process.env.KB_EVENTBRIDGE_COMMAND) {
|
|
784
|
-
specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
|
|
785
|
-
} else {
|
|
786
|
-
const eventBridgeEntry = resolveModuleEntry('event-bridge');
|
|
787
|
-
if (!eventBridgeEntry) {
|
|
788
|
-
this.logger.warn('modules.supervisor', '@sanqianx/ai-coding-event-bridge-console runtime not installed; expecting an external service.', {});
|
|
789
|
-
} else {
|
|
790
|
-
const homeArgs = ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(this.dataDir || require('./data-dir').getDataDir(), 'events'))}`;
|
|
791
|
-
specs.push({
|
|
792
|
-
name: 'event-bridge',
|
|
793
|
-
command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
|
|
794
|
-
url: this.eventBridgeUrl,
|
|
795
|
-
env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
|
|
796
|
-
});
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
const engineEntry = resolveModuleEntry('knowledge-engine');
|
|
800
|
-
if (process.env.KB_KNOWLEDGE_ENGINE_COMMAND || engineEntry) specs.push({ name: 'knowledge-engine', command: process.env.KB_KNOWLEDGE_ENGINE_COMMAND || `node ${JSON.stringify(engineEntry)} serve --port ${urlPort(this.engineUrl, 5790)} --host ${urlHost(this.engineUrl)}`, url: this.engineUrl, env: { KNOWLEDGE_ENGINE_DATA: path.join(this.dataDir || require('./data-dir').getDataDir(), 'knowledge-engine'), KB_ENGINE_MANAGED: '1', KNOWLEDGE_ENGINE_CONSUME: '0', KB_ENGINE_RUNTIME_TOKEN: this.runtimeToken, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl, KB_VECTOR_HUB_URL: this.vectorHubUrl, NODE_PATH: path.resolve(__dirname, '..', '..', 'node_modules') } });
|
|
801
|
-
return specs;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
_spawnSupervised(spec) {
|
|
805
|
-
if (this.stopped) return;
|
|
806
|
-
// Strip the quoting: spawn takes argv, not a shell line — a literal
|
|
807
|
-
// `"path"` argument makes node load a file whose name contains quotes.
|
|
808
|
-
const parts = (spec.command.match(/"[^"]+"|\S+/g) || [])
|
|
809
|
-
.map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
|
|
810
|
-
const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
|
|
811
|
-
const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
|
|
812
|
-
// Module banners (ports, data roots) are diagnostics — inherit stdout
|
|
813
|
-
// so they land on the console in --fg mode and in launcher.log when the
|
|
814
|
-
// shell itself runs detached.
|
|
815
|
-
stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
|
|
816
|
-
windowsHide: true,
|
|
817
|
-
env: { ...process.env, ...(spec.env || {}) },
|
|
818
|
-
});
|
|
819
|
-
child.on('message', message => {
|
|
820
|
-
if (message?.type !== 'kb:diagnostic' || !message.record || !['terminal', 'knowledge-engine'].includes(spec.name)) return;
|
|
821
|
-
const record = message.record;
|
|
822
|
-
if (!['debug', 'info', 'warn', 'error'].includes(record.level) || typeof record.event !== 'string' || typeof record.message !== 'string') return;
|
|
823
|
-
const component = spec.name === 'terminal' ? 'claude-ai-workbench' : 'knowledge-engine';
|
|
824
|
-
try { Promise.resolve(this.logger[record.level]?.(record.event, record.message, { component, projectId: record.projectId, runId: record.runId, operationId: record.operationId, commitSha: record.commitSha, phase: record.phase, error: record.error, context: record.context })).catch(() => {}); } catch {}
|
|
825
|
-
});
|
|
826
|
-
let attempts = spec._attempts || 0;
|
|
827
|
-
child.on('error', error => {
|
|
828
|
-
this.supervised = this.supervised.filter(existing => existing !== child);
|
|
829
|
-
this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
|
|
830
|
-
});
|
|
831
|
-
child.on('exit', code => {
|
|
832
|
-
this.onStateChange?.(spec.name);
|
|
833
|
-
this.supervised = this.supervised.filter(existing => existing !== child);
|
|
834
|
-
attempts += 1;
|
|
835
|
-
if (attempts > 5 || this.stopped) {
|
|
836
|
-
this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
const delay = Math.min(1000 * 2 ** attempts, 15000);
|
|
840
|
-
this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
|
|
841
|
-
const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
|
|
842
|
-
this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
|
|
843
|
-
});
|
|
844
|
-
this.supervised.push(child);
|
|
845
|
-
this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
|
|
846
|
-
let attemptsReady = 0;
|
|
847
|
-
const checkReady = async () => {
|
|
848
|
-
if (this.stopped || child.exitCode !== null || ++attemptsReady > 20) return;
|
|
849
|
-
const kind = specNameToKind(spec.name);
|
|
850
|
-
if (kind && await this._probeClass(spec.url, kind) === 'module') { this.resetRuntimeConfiguration(); this.onReady?.(spec.name); return; }
|
|
851
|
-
const timer = setTimeout(() => { this.restartTimers.delete(timer); checkReady(); }, 500);
|
|
852
|
-
this.restartTimers ||= new Set(); this.restartTimers.add(timer);
|
|
853
|
-
};
|
|
854
|
-
void checkReady();
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
async stopSupervised() {
|
|
858
|
-
this.stopped = true;
|
|
859
|
-
for (const timer of this.restartTimers || []) clearTimeout(timer);
|
|
860
|
-
await Promise.all(this.supervised.map(child => new Promise(resolve => {
|
|
861
|
-
if (child.exitCode !== null || !child.pid) return resolve();
|
|
862
|
-
const timer = setTimeout(() => {
|
|
863
|
-
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
864
|
-
else try { child.kill('SIGKILL'); } catch {}
|
|
865
|
-
resolve();
|
|
866
|
-
}, 5000);
|
|
867
|
-
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
868
|
-
try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
|
|
869
|
-
})));
|
|
870
|
-
this.supervised = [];
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl, defaultEngineUrl };
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Module bridge: connectivity from project-knowledge to the embedded modules
|
|
4
|
+
* (claude-ai-workbench Agent Terminal, vector-hub console, ai-coding-event-bridge
|
|
5
|
+
* conversation console).
|
|
6
|
+
*
|
|
7
|
+
* The shell owns no features — it only connects and projects. This module is
|
|
8
|
+
* that connection on the server side:
|
|
9
|
+
* - reverse proxy for browser calls (keeps the UI same-origin; no CORS on modules)
|
|
10
|
+
* - registration fan-out on project import (the "wiring table" lives in project state)
|
|
11
|
+
* - aggregated project projection for the shell sidebar
|
|
12
|
+
* - module removal with keepSessions semantics
|
|
13
|
+
*
|
|
14
|
+
* Configuration (env, loopback defaults):
|
|
15
|
+
* KB_TERMINAL_URL default http://127.0.0.1:5760 (managed: dynamic when busy)
|
|
16
|
+
* KB_VECTORHUB_URL default http://127.0.0.1:8787 (managed: dynamic when busy)
|
|
17
|
+
* KB_EVENTBRIDGE_URL default http://127.0.0.1:8790 (managed: dynamic when busy)
|
|
18
|
+
* KB_KNOWLEDGE_ENGINE_URL default http://127.0.0.1:5790 (managed: dynamic when busy)
|
|
19
|
+
* An explicitly injected URL is treated as an external service and never
|
|
20
|
+
* rewritten. Managed modules resolve a final URL before spawn: healthy
|
|
21
|
+
* identity match reuses the port, otherwise the next free port from
|
|
22
|
+
* preferred +0..+19 is taken; all busy fails loudly (NO_FREE_MODULE_PORT).
|
|
23
|
+
* KB_MODULES_AUTOSTART '0' to stop spawning the vendored module services
|
|
24
|
+
* (they start with the shell by default)
|
|
25
|
+
* KB_TERMINAL_COMMAND explicit node command line for the terminal service
|
|
26
|
+
* KB_VECTORHUB_COMMAND explicit node command line for the vector-hub service
|
|
27
|
+
* KB_EVENTBRIDGE_COMMAND explicit node command line for the event-bridge console
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { spawn, spawnSync } = require('child_process');
|
|
31
|
+
const net = require('net');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { StorageLayout } = require('./storage-layout');
|
|
34
|
+
const { resolveModuleUrl } = require('./module-port-resolver');
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const { once } = require('events');
|
|
38
|
+
const { DomainError } = require('./contracts');
|
|
39
|
+
|
|
40
|
+
const PROXY_TIMEOUT_MS = 10000;
|
|
41
|
+
// Native client startup includes knowledge retrieval and a local server/CLI
|
|
42
|
+
// handshake. It has a bounded setup budget, separate from ordinary reads/SSE.
|
|
43
|
+
const AGENT_START_TIMEOUT_MS = 90000;
|
|
44
|
+
// Registration drives a full embedding pass in vector-hub (remote API); a
|
|
45
|
+
// fresh knowledge folder can easily exceed the interactive proxy budget.
|
|
46
|
+
const REGISTER_TIMEOUT_MS = 60000;
|
|
47
|
+
// Match knowledge-engine's index request budget for synchronous embedding.
|
|
48
|
+
const INDEX_TIMEOUT_MS = 120000;
|
|
49
|
+
// Native folder dialogs wait for the user without a bound.
|
|
50
|
+
const PICKER_TIMEOUT_MS = 10 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// Vendored module runtimes shipped inside the npm package. Sibling-checkout
|
|
53
|
+
// spawning is gone: the services are bundled, spawned from here, and resolved
|
|
54
|
+
// through normal node_modules resolution
|
|
55
|
+
// node_modules.
|
|
56
|
+
// Module entry scripts resolve through normal node_modules dependencies
|
|
57
|
+
// (claude-ai-workbench, @sanqianx/vector-hub, @sanqianx/ai-coding-event-bridge-
|
|
58
|
+
// console) — the npm packages are the single source of these runtimes.
|
|
59
|
+
const MODULE_ENTRIES = {
|
|
60
|
+
terminal: 'claude-ai-workbench/packages/server/bin/agent-terminal-server.js',
|
|
61
|
+
'vector-hub': '@sanqianx/vector-hub/bin.js',
|
|
62
|
+
'event-bridge': '@sanqianx/ai-coding-event-bridge-console/src/bin.js',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function resolveModuleEntry(name) {
|
|
66
|
+
if (name === 'knowledge-engine') {
|
|
67
|
+
const runtime = path.resolve(__dirname, '..', '..', '_modules', 'knowledge-engine', 'src', 'bin.js');
|
|
68
|
+
return fs.existsSync(runtime) ? runtime : null;
|
|
69
|
+
}
|
|
70
|
+
try { return require.resolve(MODULE_ENTRIES[name]); } catch { return null; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function urlPort(baseUrl, fallback) {
|
|
74
|
+
try { return new URL(baseUrl).port || String(fallback); } catch { return String(fallback); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function urlHost(baseUrl) {
|
|
78
|
+
try { return new URL(baseUrl).hostname || '127.0.0.1'; } catch { return '127.0.0.1'; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function defaultTerminalUrl() {
|
|
82
|
+
return String(process.env.KB_TERMINAL_URL || 'http://127.0.0.1:5760').replace(/\/+$/, '');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function defaultVectorHubUrl() {
|
|
86
|
+
return String(process.env.KB_VECTORHUB_URL || 'http://127.0.0.1:8787').replace(/\/+$/, '');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function defaultEventBridgeUrl() {
|
|
90
|
+
return String(process.env.KB_EVENTBRIDGE_URL || 'http://127.0.0.1:8790').replace(/\/+$/, '');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function defaultEngineUrl() { return String(process.env.KB_KNOWLEDGE_ENGINE_URL || 'http://127.0.0.1:5790').replace(/\/+$/, ''); }
|
|
94
|
+
|
|
95
|
+
// Health identity markers: an HTTP 200 alone can come from any foreign
|
|
96
|
+
// service that grabbed the port. Only a body that matches the product
|
|
97
|
+
// counts as "our module already running" (reuse); anything else is
|
|
98
|
+
// foreign and the resolver moves to another port instead of crash-looping.
|
|
99
|
+
const MODULE_IDENTITY = {
|
|
100
|
+
terminal: body => body && typeof body === 'object' && body.status === 'ok' && typeof body.apiVersion === 'string',
|
|
101
|
+
vectorHub: body => body && typeof body === 'object' && Array.isArray(body.projects),
|
|
102
|
+
eventBridge: body => body && typeof body === 'object' && typeof body.registeredProjects === 'number',
|
|
103
|
+
engine: body => body && typeof body === 'object' && body.name === 'knowledge-engine',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// kind -> bridge URL property, explicit env override, spawn spec name
|
|
107
|
+
const MODULE_URL_TARGETS = [
|
|
108
|
+
{ kind: 'terminal', prop: 'terminalUrl', env: 'KB_TERMINAL_URL', specName: 'terminal' },
|
|
109
|
+
{ kind: 'vectorHub', prop: 'vectorHubUrl', env: 'KB_VECTORHUB_URL', specName: 'vector-hub' },
|
|
110
|
+
{ kind: 'eventBridge', prop: 'eventBridgeUrl', env: 'KB_EVENTBRIDGE_URL', specName: 'event-bridge' },
|
|
111
|
+
{ kind: 'engine', prop: 'engineUrl', env: 'KB_KNOWLEDGE_ENGINE_URL', specName: 'knowledge-engine' },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
function specNameToKind(specName) {
|
|
115
|
+
return MODULE_URL_TARGETS.find(target => target.specName === specName)?.kind || null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function tcpPortFree(host, port) {
|
|
119
|
+
return new Promise(resolve => {
|
|
120
|
+
const tester = net.createServer();
|
|
121
|
+
let done = false;
|
|
122
|
+
const finish = free => { if (done) return; done = true; tester.close(() => resolve(free)); };
|
|
123
|
+
tester.once('error', () => finish(false));
|
|
124
|
+
tester.once('listening', () => finish(true));
|
|
125
|
+
tester.listen(port, host);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class ModuleBridge {
|
|
130
|
+
constructor(options = {}) {
|
|
131
|
+
this.logger = options.logger || { info: () => {}, warn: () => {}, error: () => {} };
|
|
132
|
+
this.terminalUrl = options.terminalUrl || defaultTerminalUrl();
|
|
133
|
+
this.vectorHubUrl = options.vectorHubUrl || defaultVectorHubUrl();
|
|
134
|
+
this.eventBridgeUrl = options.eventBridgeUrl || defaultEventBridgeUrl();
|
|
135
|
+
this.engineUrl = options.engineUrl || defaultEngineUrl();
|
|
136
|
+
// Explicitly injected URLs (option or env) mean an external service;
|
|
137
|
+
// only managed defaults participate in dynamic port resolution.
|
|
138
|
+
this.explicitModuleUrls = {
|
|
139
|
+
terminal: Boolean(options.terminalUrl || process.env.KB_TERMINAL_URL),
|
|
140
|
+
vectorHub: Boolean(options.vectorHubUrl || process.env.KB_VECTORHUB_URL),
|
|
141
|
+
eventBridge: Boolean(options.eventBridgeUrl || process.env.KB_EVENTBRIDGE_URL),
|
|
142
|
+
engine: Boolean(options.engineUrl || process.env.KB_KNOWLEDGE_ENGINE_URL),
|
|
143
|
+
};
|
|
144
|
+
this.moduleUrlSources = { terminal: 'pending', vectorHub: 'pending', eventBridge: 'pending', engine: 'pending' };
|
|
145
|
+
this.runtimeToken = options.runtimeToken || crypto.randomBytes(32).toString('hex');
|
|
146
|
+
this.registryStore = options.registryStore || null;
|
|
147
|
+
this.projectStore = options.projectStore || null;
|
|
148
|
+
this.projectWiringQueue = new Map();
|
|
149
|
+
this.settingsStore = options.settingsStore || null;
|
|
150
|
+
this.dataDir = options.dataDir || null;
|
|
151
|
+
this.supervised = [];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_terminalApi(pathname) {
|
|
155
|
+
// /api/terminal/<rest> -> <terminal>/api/claude-workbench/v1/<rest>
|
|
156
|
+
const rest = pathname.replace(/^\/api\/terminal\/?/, '');
|
|
157
|
+
return `${this.terminalUrl}/api/claude-workbench/v1/${rest}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_vectorHubApi(pathname) {
|
|
161
|
+
// /api/vectorhub/<rest> -> <vectorhub>/api/<rest>
|
|
162
|
+
const rest = pathname.replace(/^\/api\/vectorhub\/?/, '');
|
|
163
|
+
return `${this.vectorHubUrl}/api/${rest}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
_eventBridgeApi(pathname) {
|
|
167
|
+
// /api/eventbridge/<rest> -> <event-bridge console>/api/<rest>
|
|
168
|
+
const rest = pathname.replace(/^\/api\/eventbridge\/?/, '');
|
|
169
|
+
return `${this.eventBridgeUrl}/api/${rest}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async _fetch(url, init = {}) {
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const timer = setTimeout(() => controller.abort(), init.timeoutMs || PROXY_TIMEOUT_MS);
|
|
175
|
+
try {
|
|
176
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Same-origin proxy. SSE has a headers deadline, never a whole-turn deadline. */
|
|
183
|
+
async proxy(req, res, target) {
|
|
184
|
+
const chunks = [];
|
|
185
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
186
|
+
const body = chunks.length ? Buffer.concat(chunks) : undefined;
|
|
187
|
+
// The native folder pickers hold the request open until the user closes
|
|
188
|
+
// the dialog — they need a human-scale budget, not the API budget.
|
|
189
|
+
const isPicker = /\/(system\/)?pick-folder/.test(target);
|
|
190
|
+
const targetUrl = new URL(target);
|
|
191
|
+
const pathname = targetUrl.pathname;
|
|
192
|
+
const isAgentStart = req.method === 'POST' && /^\/api\/claude-workbench\/v1\/sessions(?:\/[^/]+\/input)?\/?$/.test(pathname);
|
|
193
|
+
const isIndex = req.method === 'POST' && pathname === '/api/sync' && targetUrl.origin === new URL(this.vectorHubUrl).origin;
|
|
194
|
+
const timeoutMs = isPicker ? PICKER_TIMEOUT_MS : isAgentStart ? AGENT_START_TIMEOUT_MS : isIndex ? INDEX_TIMEOUT_MS : PROXY_TIMEOUT_MS;
|
|
195
|
+
const startedAt = Date.now();
|
|
196
|
+
let upstream;
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
199
|
+
const disconnected = () => { if (!res.writableEnded) controller.abort(); };
|
|
200
|
+
res.once?.('close', disconnected);
|
|
201
|
+
try {
|
|
202
|
+
upstream = await fetch(target, {
|
|
203
|
+
method: req.method,
|
|
204
|
+
signal: controller.signal,
|
|
205
|
+
headers: { ...(body ? { 'Content-Type': req.headers['content-type'] || 'application/json' } : {}), ...(req.headers['last-event-id'] ? { 'Last-Event-ID': req.headers['last-event-id'] } : {}) },
|
|
206
|
+
body: ['GET', 'HEAD'].includes(req.method) ? undefined : body,
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
res.off?.('close', disconnected);
|
|
211
|
+
if (res.destroyed) return;
|
|
212
|
+
const details = { failureKind: controller.signal.aborted ? 'UPSTREAM_TIMEOUT' : 'NETWORK_ERROR', timeoutMs };
|
|
213
|
+
this.logger.warn('module.proxy.failed', '模块请求未完成,请检查连接或客户端启动。', {
|
|
214
|
+
component: 'module-bridge', phase: 'proxy', durationMs: Date.now() - startedAt,
|
|
215
|
+
context: { ...details, method: req.method, path: pathname },
|
|
216
|
+
});
|
|
217
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
218
|
+
res.end(JSON.stringify({ ok: false, error: 'MODULE_UNREACHABLE', target: targetUrl.origin + pathname, details }));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const contentType = upstream.headers.get('content-type') || 'application/json; charset=utf-8';
|
|
222
|
+
if (contentType.includes('text/event-stream')) {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
|
|
225
|
+
res.flushHeaders?.();
|
|
226
|
+
try {
|
|
227
|
+
for await (const chunk of upstream.body) {
|
|
228
|
+
if (res.destroyed) break;
|
|
229
|
+
if (!res.write(Buffer.from(chunk))) await once(res, 'drain', { signal: controller.signal });
|
|
230
|
+
}
|
|
231
|
+
if (!res.destroyed) res.end();
|
|
232
|
+
} catch { if (!res.destroyed) res.destroy(); }
|
|
233
|
+
finally { controller.abort(); res.off?.('close', disconnected); }
|
|
234
|
+
} else {
|
|
235
|
+
try {
|
|
236
|
+
const buf = Buffer.from(await upstream.arrayBuffer());
|
|
237
|
+
res.writeHead(upstream.status, { 'Content-Type': contentType });
|
|
238
|
+
res.end(buf);
|
|
239
|
+
} finally { clearTimeout(timer); res.off?.('close', disconnected); }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
proxyTerminal(req, res, pathname, search) {
|
|
244
|
+
return this.proxy(req, res, this._terminalApi(pathname) + (search || ''));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
proxyVectorHub(req, res, pathname, search) {
|
|
248
|
+
return this.proxy(req, res, this._vectorHubApi(pathname) + (search || ''));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
proxyEventBridge(req, res, pathname, search) {
|
|
252
|
+
return this.proxy(req, res, this._eventBridgeApi(pathname) + (search || ''));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
proxyEngine(req, res, pathname, search) {
|
|
256
|
+
if (this.engineCompatible === false) throw new DomainError('ENGINE_CONNECTION_PENDING', '知识引擎连接尚未确认,请查看系统状态;不会读取旧服务中的知识。', { status: 503 });
|
|
257
|
+
return this.proxy(req, res, `${this.engineUrl}/api/${pathname.replace(/^\/api\/engine\/?/, '')}${search || ''}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
proxyModuleUi(req, res, module, rest, search) {
|
|
261
|
+
const base = { terminal: this.terminalUrl, vectorhub: this.vectorHubUrl, eventbridge: this.eventBridgeUrl, engine: this.engineUrl }[module];
|
|
262
|
+
let decoded;
|
|
263
|
+
try { decoded = decodeURIComponent(rest); } catch { decoded = null; }
|
|
264
|
+
// Reject encoded separators/dot segments before URL normalization. Static
|
|
265
|
+
// mounts must never become a second route to a module's private API.
|
|
266
|
+
if (!base || req.method !== 'GET' || decoded === null || /[\\\x00-\x1f]/.test(decoded) || decoded.split('/').some(part => ['.', '..', 'api', 'internal'].includes(part.toLowerCase())) || /%[0-9a-f]{2}/i.test(decoded)) { res.statusCode = 404; res.end('Not found'); return; }
|
|
267
|
+
const target = new URL(`${base}/${rest}${search || ''}`);
|
|
268
|
+
if (target.origin !== new URL(base).origin) { res.statusCode = 404; res.end('Not found'); return; }
|
|
269
|
+
return this.proxy(req, res, target.href);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async _listTerminalProjects({ requireAvailable = false } = {}) {
|
|
273
|
+
try {
|
|
274
|
+
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, { timeoutMs: 2500 });
|
|
275
|
+
if (!response.ok) throw new Error('TERMINAL_REGISTRY_UNAVAILABLE');
|
|
276
|
+
const payload = await response.json();
|
|
277
|
+
const list = Array.isArray(payload) ? payload : payload.projects;
|
|
278
|
+
if (!Array.isArray(list)) throw new Error('TERMINAL_REGISTRY_INVALID');
|
|
279
|
+
return list;
|
|
280
|
+
} catch { return requireAvailable ? null : []; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _listVectorHubProjects() {
|
|
284
|
+
try {
|
|
285
|
+
const response = await this._fetch(`${this.vectorHubUrl}/api/projects`, { timeoutMs: 2500 });
|
|
286
|
+
if (!response.ok) return [];
|
|
287
|
+
const payload = await response.json();
|
|
288
|
+
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
289
|
+
} catch { return []; }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async _listEventBridgeProjects() {
|
|
293
|
+
try {
|
|
294
|
+
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, { timeoutMs: 2500 });
|
|
295
|
+
if (!response.ok) return [];
|
|
296
|
+
const payload = await response.json();
|
|
297
|
+
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
298
|
+
} catch { return []; }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async _listEngineProjects() {
|
|
302
|
+
if (this.engineCompatible === false) return [];
|
|
303
|
+
try {
|
|
304
|
+
const response = await this._fetch(`${this.engineUrl}/api/projects`, { timeoutMs: 2500 });
|
|
305
|
+
if (!response.ok) return [];
|
|
306
|
+
const payload = await response.json();
|
|
307
|
+
return Array.isArray(payload.projects) ? payload.projects : [];
|
|
308
|
+
} catch { return []; }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Aggregated projection for the shell sidebar: pkb registry is the spine. */
|
|
312
|
+
async aggregatedProjects() {
|
|
313
|
+
const healthJob = this.modulesHealth();
|
|
314
|
+
const [terminalProjects, vectorHubProjects, eventBridgeProjects, engineProjects] = await Promise.all([
|
|
315
|
+
this._listTerminalProjects(),
|
|
316
|
+
this._listVectorHubProjects(),
|
|
317
|
+
this._listEventBridgeProjects(),
|
|
318
|
+
this._listEngineProjects(),
|
|
319
|
+
]);
|
|
320
|
+
const ids = this.registryStore ? this.registryStore.listIds() : [];
|
|
321
|
+
const byWorkspace = new Map(terminalProjects.map(p => [String(p.rootPath || '').toLowerCase(), p]));
|
|
322
|
+
const byName = new Map(vectorHubProjects.map(p => [String(p.name), p]));
|
|
323
|
+
const ebByPath = new Map(eventBridgeProjects.filter(p => p.path).map(p => [String(p.path).toLowerCase(), p]));
|
|
324
|
+
const projects = ids.map(projectId => {
|
|
325
|
+
const config = this.projectStore ? (this.projectStore.readConfig(projectId) || {}) : {};
|
|
326
|
+
const workspacePath = String(config.repoPath || '');
|
|
327
|
+
const knowledgePath = String(config.knowledgePath || '');
|
|
328
|
+
const state = this.projectStore ? this.projectStore.readState(projectId) : {};
|
|
329
|
+
const wiring = state.modules || {};
|
|
330
|
+
const terminal = (wiring.terminal && wiring.terminal.projectId ? terminalProjects.find(p => p.id === wiring.terminal.projectId) : null)
|
|
331
|
+
|| byWorkspace.get(workspacePath.toLowerCase())
|
|
332
|
+
|| null;
|
|
333
|
+
const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
|
|
334
|
+
const vectorHub = byName.get(vectorHubName) || null;
|
|
335
|
+
const eventBridge = ebByPath.get(workspacePath.toLowerCase())
|
|
336
|
+
|| (wiring.eventBridge && wiring.eventBridge.projectId ? eventBridgeProjects.find(p => p.id === wiring.eventBridge.projectId) : null)
|
|
337
|
+
|| null;
|
|
338
|
+
return {
|
|
339
|
+
projectId,
|
|
340
|
+
name: config.displayName || projectId,
|
|
341
|
+
workspacePath,
|
|
342
|
+
knowledgePath,
|
|
343
|
+
brainId: config.brainId || 'brain-default',
|
|
344
|
+
domainId: config.domainId || null,
|
|
345
|
+
modules: {
|
|
346
|
+
terminal: terminal ? { registered: true, projectId: terminal.id, name: terminal.name } : { registered: false, status: wiring.terminal && wiring.terminal.status },
|
|
347
|
+
vectorHub: vectorHub ? { registered: true, projectName: vectorHub.name, docCount: vectorHub.docCount, chunkCount: vectorHub.chunkCount, watching: !!vectorHub.watching } : { registered: false, status: wiring.vectorHub && wiring.vectorHub.status },
|
|
348
|
+
eventBridge: eventBridge ? { registered: true, projectId: eventBridge.id, turns: eventBridge.turns, auto: !!eventBridge.auto } : { registered: false, status: wiring.eventBridge && wiring.eventBridge.status },
|
|
349
|
+
engine: engineProjects.some(p => p.projectId === projectId) ? { registered: true, projectId } : { registered: false, status: wiring.engine && wiring.engine.status },
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
});
|
|
353
|
+
const health = (await healthJob).modules;
|
|
354
|
+
return {
|
|
355
|
+
projects,
|
|
356
|
+
terminalAvailable: health.terminal.up, vectorHubAvailable: health.vectorHub.up, eventBridgeAvailable: health.eventBridge.up, engineAvailable: health.engine.up,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Classify what is listening on a module URL.
|
|
362
|
+
* 'module' HTTP 2xx + product identity marker -> safe to reuse
|
|
363
|
+
* 'foreign' HTTP responded but identity mismatch -> never reuse
|
|
364
|
+
* 'unreachable' no HTTP answer (free port or non-HTTP listener)
|
|
365
|
+
*/
|
|
366
|
+
async _probeClass(base, kind) {
|
|
367
|
+
const route = kind === 'terminal' ? '/api/claude-workbench/v1/health' : '/api/health';
|
|
368
|
+
const marker = MODULE_IDENTITY[kind];
|
|
369
|
+
if (!marker) return 'unreachable';
|
|
370
|
+
try {
|
|
371
|
+
const response = await this._fetch(`${base}${route}`, { timeoutMs: 2000 });
|
|
372
|
+
if (!response.ok) return 'unreachable';
|
|
373
|
+
let body;
|
|
374
|
+
try { body = await response.json(); } catch { return 'foreign'; }
|
|
375
|
+
return marker(body) ? 'module' : 'foreign';
|
|
376
|
+
} catch { return 'unreachable'; }
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async _probe(base, kind) {
|
|
380
|
+
const resolvedKind = kind || (base === this.terminalUrl ? 'terminal' : null);
|
|
381
|
+
if (!resolvedKind) return false;
|
|
382
|
+
return (await this._probeClass(base, resolvedKind)) === 'module';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async modulesHealth() {
|
|
386
|
+
const [terminal, vectorHub, eventBridge, engine] = await Promise.all([
|
|
387
|
+
this._probe(this.terminalUrl, 'terminal'),
|
|
388
|
+
this._probe(this.vectorHubUrl, 'vectorHub'),
|
|
389
|
+
this._probe(this.eventBridgeUrl, 'eventBridge'),
|
|
390
|
+
this._probe(this.engineUrl, 'engine'),
|
|
391
|
+
]);
|
|
392
|
+
return { ok: true, modules: {
|
|
393
|
+
terminal: { url: this.terminalUrl, up: terminal, source: this.moduleUrlSources.terminal },
|
|
394
|
+
vectorHub: { url: this.vectorHubUrl, up: vectorHub, source: this.moduleUrlSources.vectorHub },
|
|
395
|
+
eventBridge: { url: this.eventBridgeUrl, up: eventBridge, source: this.moduleUrlSources.eventBridge },
|
|
396
|
+
engine: { url: this.engineUrl, up: engine, source: this.moduleUrlSources.engine },
|
|
397
|
+
} };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
syncEngineConfiguration({ force = false } = {}) {
|
|
401
|
+
const signature = () => { try { return crypto.createHash('sha256').update(JSON.stringify(this.settingsStore?.read() || {})).digest('hex'); } catch { return ''; } };
|
|
402
|
+
if (this.configSyncPromise) {
|
|
403
|
+
if (force || signature() !== this.configSyncSettings) this.configSyncDirty = true;
|
|
404
|
+
return this.configSyncPromise;
|
|
405
|
+
}
|
|
406
|
+
const pending = (async () => {
|
|
407
|
+
let result;
|
|
408
|
+
do { this.configSyncDirty = false; this.configSyncSettings = signature(); result = await this._syncEngineConfiguration(); } while (this.configSyncDirty && !this.stopped);
|
|
409
|
+
return result;
|
|
410
|
+
})().finally(() => { if (this.configSyncPromise === pending) this.configSyncPromise = null; });
|
|
411
|
+
this.configSyncPromise = pending;
|
|
412
|
+
return pending;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
resetRuntimeConfiguration() {
|
|
416
|
+
this.configFingerprints = {};
|
|
417
|
+
this.configGeneration = (this.configGeneration || 0) + 1;
|
|
418
|
+
if (this.configSyncPromise) this.configSyncDirty = true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async _syncEngineConfiguration() {
|
|
422
|
+
const applyConfiguration = async (key, url, body) => {
|
|
423
|
+
this.configFingerprints ||= {};
|
|
424
|
+
const generation = this.configGeneration;
|
|
425
|
+
const fingerprint = crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
|
|
426
|
+
if (this.configFingerprints[key]?.fingerprint === fingerprint) return this.configFingerprints[key].result;
|
|
427
|
+
const response = await this._fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` }, body: JSON.stringify(body) });
|
|
428
|
+
const result = { ok: response.ok, data: response.ok ? await response.json().catch(() => ({})) : null };
|
|
429
|
+
if (result.ok && this.configGeneration === generation) this.configFingerprints[key] = { fingerprint, result };
|
|
430
|
+
else delete this.configFingerprints[key];
|
|
431
|
+
return result;
|
|
432
|
+
};
|
|
433
|
+
const dataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
434
|
+
let indexPath = null;
|
|
435
|
+
try { indexPath = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read()); }
|
|
436
|
+
catch { /* Corrupt settings must not switch storage or prevent credential revocation. */ }
|
|
437
|
+
const status = { scopes: false, scopeStatus: { status: 'pending' }, model: false, embedding: false, indexStorage: { expectedPath: indexPath, actualPath: null, status: 'pending' } };
|
|
438
|
+
const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${this.runtimeToken}` };
|
|
439
|
+
try {
|
|
440
|
+
if (this.settingsStore) {
|
|
441
|
+
const settings = this.settingsStore.read();
|
|
442
|
+
if (settings.knowledge.rootPath) {
|
|
443
|
+
const brain = settings.brain;
|
|
444
|
+
const root = path.join(settings.knowledge.rootPath, '_shared');
|
|
445
|
+
const response = await this._fetch(`${this.engineUrl}/api/snapshot?summary=1&limit=1`);
|
|
446
|
+
if (!response.ok) throw new DomainError('ENGINE_UNAVAILABLE', '共享知识接线暂时不可用。');
|
|
447
|
+
const prior = (await response.json()).sharedScopes || [];
|
|
448
|
+
// Root settings govern new directories and derived indexes, never move
|
|
449
|
+
// existing knowledge. Retain engine-acknowledged bindings in the spine.
|
|
450
|
+
const binding = (item, kind) => {
|
|
451
|
+
const existing = prior.find(scope => scope.id === item.id && scope.kind === kind && scope.brainId === brain.id);
|
|
452
|
+
if (existing && !path.isAbsolute(existing.knowledgePath || '')) throw new DomainError('DATA_CORRUPT', '共享目录绑定损坏。');
|
|
453
|
+
return item.knowledgePath || existing?.knowledgePath || path.join(root, item.id);
|
|
454
|
+
};
|
|
455
|
+
const scopes = [{ id: brain.id, kind: 'brain', brainId: brain.id, name: brain.name, knowledgePath: binding(brain, 'brain') }, ...brain.domains.map(d => ({ id: d.id, kind: 'domain', brainId: brain.id, domainId: d.id, name: d.name, knowledgePath: binding(d, 'domain') }))];
|
|
456
|
+
const applied = await applyConfiguration('scopes', `${this.engineUrl}/api/scopes`, { scopes });
|
|
457
|
+
if (!applied.ok) throw new DomainError('SHARED_SCOPE_APPLY_FAILED', '共享目录接线未完成。');
|
|
458
|
+
await this.settingsStore.bindSharedKnowledgePaths?.(scopes);
|
|
459
|
+
status.scopes = true; status.scopeStatus = { status: 'ready' };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} catch (error) {
|
|
463
|
+
status.scopeStatus = { status: 'pending', code: ['DATA_CORRUPT', 'IMMUTABLE_FIELD', 'ENGINE_UNAVAILABLE', 'SHARED_SCOPE_APPLY_FAILED'].includes(error.code) ? error.code : 'ENGINE_UNAVAILABLE' };
|
|
464
|
+
this.logger.warn('modules.scopes.pending', 'Shared knowledge wiring is pending; existing data is retained.', { context: { code: status.scopeStatus.code } });
|
|
465
|
+
// A scope failure must not prevent revoking a stale model key.
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
let profile = null;
|
|
469
|
+
try {
|
|
470
|
+
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/maintenance-config`, { method: 'POST', headers, body: '{}' });
|
|
471
|
+
if (response.ok) profile = await response.json();
|
|
472
|
+
} catch { /* Missing configuration must clear a previously resolved key. */ }
|
|
473
|
+
const response = await applyConfiguration('model', `${this.engineUrl}/api/runtime-config`, { projectId: 'default', profile });
|
|
474
|
+
status.model = response.ok && !!profile;
|
|
475
|
+
} catch { /* Status projection only: never log resolved profiles or raw errors. */ }
|
|
476
|
+
try {
|
|
477
|
+
let settings = null;
|
|
478
|
+
try {
|
|
479
|
+
const resolved = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/internal/embedding-config`, { method: 'POST', headers, body: '{}' });
|
|
480
|
+
if (resolved.ok) settings = await resolved.json();
|
|
481
|
+
} catch { /* Revocation wins over a previously cached runtime key. */ }
|
|
482
|
+
const applied = await applyConfiguration('embedding', `${this.vectorHubUrl}/api/internal/runtime-config`, { settings, ...(indexPath ? { storageRoot: indexPath } : {}) });
|
|
483
|
+
status.embedding = applied.ok && !!settings;
|
|
484
|
+
if (applied.ok) {
|
|
485
|
+
const result = applied.data;
|
|
486
|
+
status.indexStorage.actualPath = result.rootPath || null;
|
|
487
|
+
status.indexStorage.status = result.rootPath && path.resolve(result.rootPath) === indexPath ? 'ready' : 'pending';
|
|
488
|
+
}
|
|
489
|
+
} catch { /* Public status contains no resolved configuration. */ }
|
|
490
|
+
this.configStatus = status;
|
|
491
|
+
return status;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Register one pkb project into both modules and persist the wiring table.
|
|
496
|
+
* Best-effort per module: a module that is down is recorded as 'pending'
|
|
497
|
+
* (retry on the next register call or health-check pass), never a hard error.
|
|
498
|
+
* KB_MODULES_REGISTER=0 disables fan-out entirely (test isolation).
|
|
499
|
+
*/
|
|
500
|
+
_withProjectWiring(projectId, action) {
|
|
501
|
+
const prior = this.projectWiringQueue.get(projectId) || Promise.resolve();
|
|
502
|
+
const operation = prior.catch(() => {}).then(action);
|
|
503
|
+
const settled = operation.finally(() => { if (this.projectWiringQueue.get(projectId) === settled) this.projectWiringQueue.delete(projectId); });
|
|
504
|
+
this.projectWiringQueue.set(projectId, settled);
|
|
505
|
+
return settled;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
registerProjectLinks(input) {
|
|
509
|
+
return this._withProjectWiring(input.projectId, () => {
|
|
510
|
+
// A supervisor may have captured this project before a concurrent unbind.
|
|
511
|
+
// Recheck the spine after acquiring the same lease as the removal path.
|
|
512
|
+
if (this.registryStore && !this.registryStore.readDisplaySnapshot(input.projectId)) return Object.fromEntries(['terminal', 'vectorHub', 'eventBridge', 'engine'].map(key => [key, { ok: true, status: 'removed', skipped: 'project-not-registered' }]));
|
|
513
|
+
return this._registerProjectLinks(input);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async _registerProjectLinks({ projectId, name, workspacePath, knowledgePath }) {
|
|
518
|
+
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
519
|
+
if (String(process.env.KB_MODULES_REGISTER || '') === '0') {
|
|
520
|
+
result.terminal = { ok: false, status: 'disabled' };
|
|
521
|
+
result.vectorHub = { ok: false, status: 'disabled' };
|
|
522
|
+
result.eventBridge = { ok: false, status: 'disabled' };
|
|
523
|
+
result.engine = { ok: false, status: 'disabled' };
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
// 幂等登记:终端已有同 rootPath 的项目时复用,绝不重复创建
|
|
527
|
+
const terminalProjects = await this._listTerminalProjects({ requireAvailable: true });
|
|
528
|
+
const existingTerminal = terminalProjects?.find(p => String(p.rootPath || '').toLowerCase() === String(workspacePath || '').toLowerCase());
|
|
529
|
+
if (terminalProjects === null) {
|
|
530
|
+
// Startup/reconnect failures do not prove the workspace is absent. Keep
|
|
531
|
+
// its saved context so a retry cannot strand existing sessions/drafts.
|
|
532
|
+
result.terminal = { ok: false, status: 'pending' };
|
|
533
|
+
} else if (existingTerminal) {
|
|
534
|
+
result.terminal = { ok: true, projectId: existingTerminal.id, status: 'registered', reused: true };
|
|
535
|
+
} else {
|
|
536
|
+
try {
|
|
537
|
+
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects`, {
|
|
538
|
+
method: 'POST',
|
|
539
|
+
headers: { 'Content-Type': 'application/json' },
|
|
540
|
+
body: JSON.stringify({ name: name || projectId, rootPath: workspacePath }),
|
|
541
|
+
});
|
|
542
|
+
const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
|
|
543
|
+
result.terminal = { ok: response.ok, projectId: payload.id || payload.projectId || null, status: response.ok ? 'registered' : 'error' };
|
|
544
|
+
} catch {
|
|
545
|
+
result.terminal = { ok: false, status: 'pending' };
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const existingVector = (await this._listVectorHubProjects()).find(p => p.name === projectId && String(p.sourceDir || '').toLowerCase() === String(knowledgePath || '').toLowerCase());
|
|
549
|
+
if (existingVector) result.vectorHub = { ok: true, projectName: projectId, status: 'registered', reused: true };
|
|
550
|
+
else try {
|
|
551
|
+
const response = await this._fetch(`${this.vectorHubUrl}/api/import`, {
|
|
552
|
+
method: 'POST',
|
|
553
|
+
headers: { 'Content-Type': 'application/json' },
|
|
554
|
+
timeoutMs: REGISTER_TIMEOUT_MS,
|
|
555
|
+
body: JSON.stringify({ sourceDir: knowledgePath, projectName: projectId, watch: false }),
|
|
556
|
+
});
|
|
557
|
+
result.vectorHub = { ok: response.ok, projectName: projectId, status: response.ok ? 'registered' : 'error' };
|
|
558
|
+
} catch {
|
|
559
|
+
result.vectorHub = { ok: false, status: 'pending' };
|
|
560
|
+
}
|
|
561
|
+
// 幂等登记:会话控制台按 path(Git work tree)复用已有登记。store 遵循
|
|
562
|
+
// event-bridge 的推荐布局 <知识库>/dev-conversations;无知识库地址时交给
|
|
563
|
+
// 控制台的默认 store(全局 journal),登记本身不受阻。
|
|
564
|
+
const existingEventBridge = (await this._listEventBridgeProjects())
|
|
565
|
+
.find(p => !p.auto && String(p.path || '').toLowerCase() === String(workspacePath || '').toLowerCase());
|
|
566
|
+
if (existingEventBridge) {
|
|
567
|
+
result.eventBridge = { ok: true, projectId: existingEventBridge.id, status: 'registered', reused: true };
|
|
568
|
+
} else {
|
|
569
|
+
try {
|
|
570
|
+
const body = { path: workspacePath };
|
|
571
|
+
if (knowledgePath) body.store = path.join(knowledgePath, 'dev-conversations');
|
|
572
|
+
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects`, {
|
|
573
|
+
method: 'POST',
|
|
574
|
+
headers: { 'Content-Type': 'application/json' },
|
|
575
|
+
body: JSON.stringify(body),
|
|
576
|
+
});
|
|
577
|
+
const payload = response.status === 201 || response.ok ? await response.json().catch(() => ({})) : {};
|
|
578
|
+
const registeredProject = payload.project || {};
|
|
579
|
+
result.eventBridge = { ok: response.ok, projectId: registeredProject.id || null, status: response.ok ? 'registered' : 'error', errorCode: (!response.ok && payload.error && payload.error.code) || undefined };
|
|
580
|
+
} catch {
|
|
581
|
+
result.eventBridge = { ok: false, status: 'pending' };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
const config = this.projectStore ? this.projectStore.readConfig(projectId) : {};
|
|
585
|
+
const state = this.projectStore ? this.projectStore.readState(projectId) : {};
|
|
586
|
+
try {
|
|
587
|
+
const baselines = { ...(state.conversationBaselineCursors || {}), global: state.conversationBaselineCursor ?? 0 };
|
|
588
|
+
const journalId = result.eventBridge.projectId || state.modules?.eventBridge?.projectId;
|
|
589
|
+
if (journalId && baselines[journalId] === undefined) {
|
|
590
|
+
const watermark = result.eventBridge.reused && await this._fetch(`${this.eventBridgeUrl}/api/journal/high-watermark?journalId=${encodeURIComponent(journalId)}`);
|
|
591
|
+
const current = result.eventBridge.reused ? watermark.ok && await watermark.json() : { sequence: 0 };
|
|
592
|
+
if (!current || !Number.isSafeInteger(current.sequence)) throw new Error('JOURNAL_BASELINE_UNAVAILABLE');
|
|
593
|
+
baselines[journalId] = current.sequence;
|
|
594
|
+
if (this.projectStore) await this.projectStore.updateState(projectId, draft => { draft.conversationBaselineCursors = baselines; });
|
|
595
|
+
}
|
|
596
|
+
const response = await this._fetch(`${this.engineUrl}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ projectId, name: name || projectId, repoPath: workspacePath, knowledgePath, knowledgeLanguage: config.knowledgeLanguage || 'zh-CN', brainId: config.brainId || 'brain-default', domainId: config.domainId || null, repoIdentity: config.repoIdentity, journalId: journalId || null, vectorProjectId: projectId, trackingStartCommit: state.trackingStartCommit || state.lastAnalyzedCommit || null, conversationBaselineCursor: state.conversationBaselineCursor ?? 0, conversationBaselineCursors: baselines, profileId: config.aiProfileId || null }) });
|
|
597
|
+
result.engine = { ok: response.ok, projectId, status: response.ok ? 'registered' : 'error' };
|
|
598
|
+
} catch { result.engine = { ok: false, status: 'pending' }; }
|
|
599
|
+
await this._writeWiring(projectId, wiring => {
|
|
600
|
+
wiring.terminal = { ...wiring.terminal, ...result.terminal, name: name || projectId, rootPath: workspacePath, updatedAt: new Date().toISOString() };
|
|
601
|
+
wiring.vectorHub = { ...wiring.vectorHub, ...result.vectorHub, sourceDir: knowledgePath, updatedAt: new Date().toISOString() };
|
|
602
|
+
wiring.eventBridge = { ...wiring.eventBridge, ...result.eventBridge, path: workspacePath, store: knowledgePath ? path.join(knowledgePath, 'dev-conversations') : '', updatedAt: new Date().toISOString() };
|
|
603
|
+
wiring.engine = { ...wiring.engine, ...result.engine, updatedAt: new Date().toISOString() };
|
|
604
|
+
});
|
|
605
|
+
this.logger.info('modules.register', 'Module registration finished.', { context: { projectId, terminal: result.terminal.status, vectorHub: result.vectorHub.status, eventBridge: result.eventBridge.status } });
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Remove module registrations. Local data is preserved by contract: vector-hub
|
|
610
|
+
* only drops its derived index (source untouched); terminal removes the project
|
|
611
|
+
* with keepSessions=1 so session records survive; the event-bridge console only
|
|
612
|
+
* unregisters the project — journals and sealed commit documents stay on disk. */
|
|
613
|
+
removeProjectLinks(input) {
|
|
614
|
+
return this._withProjectWiring(input.projectId, async () => {
|
|
615
|
+
const result = await this._removeProjectLinks(input);
|
|
616
|
+
// The caller owns registry/lifecycle changes. Hold the wiring lease until
|
|
617
|
+
// its transaction settles, so delayed reconfiguration cannot resurrect it.
|
|
618
|
+
await input.onRemoved?.(result);
|
|
619
|
+
return result;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async _removeProjectLinks({ projectId, requireEngine = true }) {
|
|
624
|
+
const wiring = (this.projectStore ? this.projectStore.readState(projectId).modules : null) || {};
|
|
625
|
+
const result = { terminal: { ok: false }, vectorHub: { ok: false }, eventBridge: { ok: false }, engine: { ok: false } };
|
|
626
|
+
// Stop the writer first, before removing hooks/index registrations.
|
|
627
|
+
try {
|
|
628
|
+
if (!requireEngine) result.engine = { ok: true, status: 'not-owner' };
|
|
629
|
+
else {
|
|
630
|
+
const response = await this._fetch(`${this.engineUrl}/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
|
|
631
|
+
if (response.status === 409) throw new DomainError('PROJECT_BUSY', '此项目正在执行知识维护,请等待写入结束后再解绑。未开始的排队任务会随解绑取消。', { status: 409, retryable: true });
|
|
632
|
+
result.engine = { ok: response.ok || response.status === 404, status: response.ok || response.status === 404 ? 'removed' : 'pending' };
|
|
633
|
+
}
|
|
634
|
+
} catch (error) { if (error.code === 'PROJECT_BUSY') throw error; result.engine = { ok: false, status: 'pending' }; }
|
|
635
|
+
if (!result.engine.ok) throw new DomainError('PROJECT_BUSY', '知识引擎暂时不可用,无法安全停止写入;请恢复后再解绑。', { status: 409, retryable: true });
|
|
636
|
+
let terminalId = wiring.terminal && wiring.terminal.projectId;
|
|
637
|
+
if (!terminalId) {
|
|
638
|
+
const workspacePath = wiring.terminal && wiring.terminal.rootPath;
|
|
639
|
+
const match = (await this._listTerminalProjects())
|
|
640
|
+
.find(p => (workspacePath && String(p.rootPath || '').toLowerCase() === String(workspacePath).toLowerCase()) || p.name === projectId);
|
|
641
|
+
terminalId = match && match.id;
|
|
642
|
+
}
|
|
643
|
+
if (terminalId) {
|
|
644
|
+
try {
|
|
645
|
+
const response = await this._fetch(`${this.terminalUrl}/api/claude-workbench/v1/projects/${encodeURIComponent(terminalId)}?keepSessions=1`, { method: 'DELETE' });
|
|
646
|
+
result.terminal = { ok: response.ok || response.status === 204 };
|
|
647
|
+
} catch { result.terminal = { ok: false, status: 'pending' }; }
|
|
648
|
+
} else {
|
|
649
|
+
result.terminal = { ok: true, skipped: 'not-registered' };
|
|
650
|
+
}
|
|
651
|
+
const vectorHubName = (wiring.vectorHub && wiring.vectorHub.projectName) || projectId;
|
|
652
|
+
try {
|
|
653
|
+
const response = await this._fetch(`${this.vectorHubUrl}/api/project?name=${encodeURIComponent(vectorHubName)}`, { method: 'DELETE' });
|
|
654
|
+
result.vectorHub = { ok: response.ok || response.status === 204 || response.status === 404 };
|
|
655
|
+
} catch { result.vectorHub = { ok: false, status: 'pending' }; }
|
|
656
|
+
let eventBridgeId = wiring.eventBridge && wiring.eventBridge.projectId;
|
|
657
|
+
if (!eventBridgeId) {
|
|
658
|
+
const workspacePath = wiring.eventBridge && wiring.eventBridge.path;
|
|
659
|
+
const match = (await this._listEventBridgeProjects())
|
|
660
|
+
.find(p => !p.auto && workspacePath && String(p.path || '').toLowerCase() === String(workspacePath).toLowerCase());
|
|
661
|
+
eventBridgeId = match && match.id;
|
|
662
|
+
}
|
|
663
|
+
if (eventBridgeId) {
|
|
664
|
+
try {
|
|
665
|
+
const response = await this._fetch(`${this.eventBridgeUrl}/api/projects?id=${encodeURIComponent(eventBridgeId)}`, { method: 'DELETE' });
|
|
666
|
+
// 404 means already unregistered (or auto entry) — removal goal reached.
|
|
667
|
+
result.eventBridge = { ok: response.ok || response.status === 204 || response.status === 404 };
|
|
668
|
+
} catch { result.eventBridge = { ok: false, status: 'pending' }; }
|
|
669
|
+
} else {
|
|
670
|
+
result.eventBridge = { ok: true, skipped: 'not-registered' };
|
|
671
|
+
}
|
|
672
|
+
await this._writeWiring(projectId, wiring => {
|
|
673
|
+
if (result.terminal.ok) wiring.terminal = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
674
|
+
if (result.vectorHub.ok) wiring.vectorHub = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
675
|
+
if (result.eventBridge.ok) wiring.eventBridge = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
676
|
+
if (result.engine.ok) wiring.engine = { status: 'removed', updatedAt: new Date().toISOString() };
|
|
677
|
+
});
|
|
678
|
+
this.logger.info('modules.remove', 'Module de-registration finished.', { context: { projectId, terminal: result.terminal, vectorHub: result.vectorHub, eventBridge: result.eventBridge } });
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async _writeWiring(projectId, mutator) {
|
|
683
|
+
if (!this.projectStore) return;
|
|
684
|
+
await this.projectStore.updateState(projectId, state => {
|
|
685
|
+
state.modules = state.modules || {};
|
|
686
|
+
mutator(state.modules);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* T3.5 process orchestration: spawn the vendored module services as
|
|
692
|
+
* supervised child processes. ON by default now that the services ship in
|
|
693
|
+
* the package — opt out with KB_MODULES_AUTOSTART=0. A service that is
|
|
694
|
+
* already listening on its URL is never spawned twice.
|
|
695
|
+
*/
|
|
696
|
+
async startSupervised() {
|
|
697
|
+
if (String(process.env.KB_MODULES_AUTOSTART || '') === '0') return;
|
|
698
|
+
await this._resolveManagedUrls();
|
|
699
|
+
await Promise.all(this._supervisedSpecs().map(async spec => {
|
|
700
|
+
const kind = specNameToKind(spec.name);
|
|
701
|
+
if (kind && await this._probeClass(spec.url, kind) === 'module') {
|
|
702
|
+
this.logger.info('modules.supervisor', 'Module service already up; not spawning.', { context: { name: spec.name, url: spec.url } });
|
|
703
|
+
this.onReady?.(spec.name); return;
|
|
704
|
+
}
|
|
705
|
+
this._spawnSupervised(spec);
|
|
706
|
+
}));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Resolve final managed URLs BEFORE any spec is built or a process is
|
|
711
|
+
* spawned, so inter-module env wiring (engine URL, bridge URL, ...) uses
|
|
712
|
+
* the final dynamic ports. Sequential on purpose: parallel scans could
|
|
713
|
+
* race two kinds into the same overlapping candidate range
|
|
714
|
+
* (8787..8806 vs 8790..8809).
|
|
715
|
+
*/
|
|
716
|
+
async _resolveManagedUrls() {
|
|
717
|
+
for (const target of MODULE_URL_TARGETS) {
|
|
718
|
+
const preferredUrl = this[target.prop];
|
|
719
|
+
const resolved = await resolveModuleUrl({
|
|
720
|
+
kind: target.kind,
|
|
721
|
+
preferredUrl,
|
|
722
|
+
explicit: this.explicitModuleUrls[target.kind],
|
|
723
|
+
portFree: port => tcpPortFree(urlHost(preferredUrl), port),
|
|
724
|
+
probe: url => this._probeClass(url, target.kind),
|
|
725
|
+
});
|
|
726
|
+
this[target.prop] = resolved.url;
|
|
727
|
+
this.moduleUrlSources[target.kind] = resolved.source;
|
|
728
|
+
if (resolved.source !== 'external') {
|
|
729
|
+
this.logger.info('modules.port_resolution', 'Module URL resolved.', { context: { kind: target.kind, source: resolved.source, url: resolved.url, port: resolved.port, offset: resolved.offset } });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
_supervisedSpecs() {
|
|
735
|
+
const specs = [];
|
|
736
|
+
// C2 minimal wiring: the terminal service receives the non-secret product
|
|
737
|
+
// knowledge root via KB_KNOWLEDGE_DATA_DIR. Its own AGENT_TERMINAL_DATA_DIR
|
|
738
|
+
// stays the dedicated workbench subtree and is never widened.
|
|
739
|
+
const knowledgeDataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
740
|
+
if (process.env.KB_TERMINAL_COMMAND) {
|
|
741
|
+
specs.push({ name: 'terminal', command: process.env.KB_TERMINAL_COMMAND, url: this.terminalUrl, env: { KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl } });
|
|
742
|
+
} else {
|
|
743
|
+
const terminalEntry = resolveModuleEntry('terminal');
|
|
744
|
+
if (terminalEntry) {
|
|
745
|
+
specs.push({
|
|
746
|
+
name: 'terminal',
|
|
747
|
+
command: `node ${JSON.stringify(terminalEntry)}`,
|
|
748
|
+
url: this.terminalUrl,
|
|
749
|
+
env: { AGENT_TERMINAL_DATA_DIR: path.join(knowledgeDataDir, 'workbench'), KB_KNOWLEDGE_DATA_DIR: knowledgeDataDir, AGENT_TERMINAL_PORT: urlPort(this.terminalUrl, 5760), AGENT_TERMINAL_HOST: urlHost(this.terminalUrl), KB_WORKBENCH_RUNTIME_TOKEN: this.runtimeToken, KB_KNOWLEDGE_ENGINE_URL: this.engineUrl, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl },
|
|
750
|
+
});
|
|
751
|
+
} else {
|
|
752
|
+
this.logger.warn('modules.supervisor', 'claude-ai-workbench runtime not installed; expecting an external service.', {});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
// vector-hub declares engines >= 22; spawning it on older runtimes would
|
|
756
|
+
// crash-loop the supervisor for nothing.
|
|
757
|
+
if (process.env.KB_VECTORHUB_COMMAND) {
|
|
758
|
+
specs.push({ name: 'vector-hub', command: process.env.KB_VECTORHUB_COMMAND, url: this.vectorHubUrl, env: { KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken } });
|
|
759
|
+
} else if (parseInt(process.versions.node, 10) < 22) {
|
|
760
|
+
this.logger.warn('modules.supervisor', 'vector-hub needs Node >= 22; not spawning the vendored runtime.', { context: { node: process.versions.node } });
|
|
761
|
+
} else {
|
|
762
|
+
const vectorHubEntry = resolveModuleEntry('vector-hub');
|
|
763
|
+
if (!vectorHubEntry) {
|
|
764
|
+
this.logger.warn('modules.supervisor', '@sanqianx/vector-hub runtime not installed; expecting an external service.', {});
|
|
765
|
+
} else {
|
|
766
|
+
const dataDir = this.dataDir || require('./data-dir').getDataDir();
|
|
767
|
+
const configRoot = path.join(dataDir, 'vectorhub');
|
|
768
|
+
const root = new StorageLayout({ dataDir }).getVectorRootPath(this.settingsStore?.read());
|
|
769
|
+
specs.push({
|
|
770
|
+
name: 'vector-hub',
|
|
771
|
+
command: `node ${JSON.stringify(vectorHubEntry)} serve --host ${urlHost(this.vectorHubUrl)} --port ${urlPort(this.vectorHubUrl, 8787)} --root ${JSON.stringify(root)}`,
|
|
772
|
+
url: this.vectorHubUrl,
|
|
773
|
+
env: { VECTOR_HUB_ROOT: root, VECTOR_HUB_CONFIG: path.join(configRoot, 'config.json'), VECTOR_HUB_LEGACY_CONFIG: path.join(configRoot, 'no-legacy-config.json'), KB_VECTOR_MANAGED: '1', KB_VECTOR_RUNTIME_TOKEN: this.runtimeToken },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// ai-coding-event-bridge console: plain-JS CommonJS on Node >= 18 — no
|
|
778
|
+
// build gate, no runtime floor beyond the shell's own. The home flag keeps
|
|
779
|
+
// the console on the same bridge home the in-process BridgeAdapter uses,
|
|
780
|
+
// so conversations captured by PK show up in the embedded explorer. The
|
|
781
|
+
// folder-picker warm-up is an interactive-only feature the shell never
|
|
782
|
+
// routes here (import uses the terminal/vector-hub pickers) — skip it.
|
|
783
|
+
if (process.env.KB_EVENTBRIDGE_COMMAND) {
|
|
784
|
+
specs.push({ name: 'event-bridge', command: process.env.KB_EVENTBRIDGE_COMMAND, url: this.eventBridgeUrl, env: { KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken } });
|
|
785
|
+
} else {
|
|
786
|
+
const eventBridgeEntry = resolveModuleEntry('event-bridge');
|
|
787
|
+
if (!eventBridgeEntry) {
|
|
788
|
+
this.logger.warn('modules.supervisor', '@sanqianx/ai-coding-event-bridge-console runtime not installed; expecting an external service.', {});
|
|
789
|
+
} else {
|
|
790
|
+
const homeArgs = ` --home ${JSON.stringify(process.env.AI_CODING_EVENT_BRIDGE_HOME || path.join(this.dataDir || require('./data-dir').getDataDir(), 'events'))}`;
|
|
791
|
+
specs.push({
|
|
792
|
+
name: 'event-bridge',
|
|
793
|
+
command: `node ${JSON.stringify(eventBridgeEntry)} serve --host ${urlHost(this.eventBridgeUrl)} --port ${urlPort(this.eventBridgeUrl, 8790)}${homeArgs}`,
|
|
794
|
+
url: this.eventBridgeUrl,
|
|
795
|
+
env: { BRIDGE_CONSOLE_PICK_FOLDER: '0', KB_EVENT_BRIDGE_RUNTIME_TOKEN: this.runtimeToken },
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const engineEntry = resolveModuleEntry('knowledge-engine');
|
|
800
|
+
if (process.env.KB_KNOWLEDGE_ENGINE_COMMAND || engineEntry) specs.push({ name: 'knowledge-engine', command: process.env.KB_KNOWLEDGE_ENGINE_COMMAND || `node ${JSON.stringify(engineEntry)} serve --port ${urlPort(this.engineUrl, 5790)} --host ${urlHost(this.engineUrl)}`, url: this.engineUrl, env: { KNOWLEDGE_ENGINE_DATA: path.join(this.dataDir || require('./data-dir').getDataDir(), 'knowledge-engine'), KB_ENGINE_MANAGED: '1', KNOWLEDGE_ENGINE_CONSUME: '0', KB_ENGINE_RUNTIME_TOKEN: this.runtimeToken, KB_EVENT_BRIDGE_URL: this.eventBridgeUrl, KB_VECTOR_HUB_URL: this.vectorHubUrl, NODE_PATH: path.resolve(__dirname, '..', '..', 'node_modules') } });
|
|
801
|
+
return specs;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
_spawnSupervised(spec) {
|
|
805
|
+
if (this.stopped) return;
|
|
806
|
+
// Strip the quoting: spawn takes argv, not a shell line — a literal
|
|
807
|
+
// `"path"` argument makes node load a file whose name contains quotes.
|
|
808
|
+
const parts = (spec.command.match(/"[^"]+"|\S+/g) || [])
|
|
809
|
+
.map(part => { if (!part.startsWith('"') || !part.endsWith('"')) return part; try { return JSON.parse(part); } catch { return part.slice(1, -1); } });
|
|
810
|
+
const nodeProcess = parts[0] === 'node' || parts[0] === process.execPath;
|
|
811
|
+
const child = spawn(nodeProcess ? process.execPath : parts[0], parts.slice(1), {
|
|
812
|
+
// Module banners (ports, data roots) are diagnostics — inherit stdout
|
|
813
|
+
// so they land on the console in --fg mode and in launcher.log when the
|
|
814
|
+
// shell itself runs detached.
|
|
815
|
+
stdio: nodeProcess ? ['ignore', 'inherit', 'inherit', 'ipc'] : ['ignore', 'inherit', 'inherit'],
|
|
816
|
+
windowsHide: true,
|
|
817
|
+
env: { ...process.env, ...(spec.env || {}) },
|
|
818
|
+
});
|
|
819
|
+
child.on('message', message => {
|
|
820
|
+
if (message?.type !== 'kb:diagnostic' || !message.record || !['terminal', 'knowledge-engine'].includes(spec.name)) return;
|
|
821
|
+
const record = message.record;
|
|
822
|
+
if (!['debug', 'info', 'warn', 'error'].includes(record.level) || typeof record.event !== 'string' || typeof record.message !== 'string') return;
|
|
823
|
+
const component = spec.name === 'terminal' ? 'claude-ai-workbench' : 'knowledge-engine';
|
|
824
|
+
try { Promise.resolve(this.logger[record.level]?.(record.event, record.message, { component, projectId: record.projectId, runId: record.runId, operationId: record.operationId, commitSha: record.commitSha, phase: record.phase, error: record.error, context: record.context })).catch(() => {}); } catch {}
|
|
825
|
+
});
|
|
826
|
+
let attempts = spec._attempts || 0;
|
|
827
|
+
child.on('error', error => {
|
|
828
|
+
this.supervised = this.supervised.filter(existing => existing !== child);
|
|
829
|
+
this.logger.warn('modules.supervisor', 'Module process could not start.', { context: { name: spec.name, code: error.code || 'SPAWN_FAILED' } });
|
|
830
|
+
});
|
|
831
|
+
child.on('exit', code => {
|
|
832
|
+
this.onStateChange?.(spec.name);
|
|
833
|
+
this.supervised = this.supervised.filter(existing => existing !== child);
|
|
834
|
+
attempts += 1;
|
|
835
|
+
if (attempts > 5 || this.stopped) {
|
|
836
|
+
this.logger.warn('modules.supervisor', 'Module service stopped.', { context: { name: spec.name, code, attempts } });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const delay = Math.min(1000 * 2 ** attempts, 15000);
|
|
840
|
+
this.logger.warn('modules.supervisor', 'Module service exited; restarting.', { context: { name: spec.name, code, retryInMs: delay } });
|
|
841
|
+
const timer = setTimeout(() => { this.restartTimers.delete(timer); this._spawnSupervised({ ...spec, _attempts: attempts }); }, delay);
|
|
842
|
+
this.restartTimers = this.restartTimers || new Set(); this.restartTimers.add(timer);
|
|
843
|
+
});
|
|
844
|
+
this.supervised.push(child);
|
|
845
|
+
this.logger.info('modules.supervisor', 'Module service spawned.', { context: { name: spec.name, pid: child.pid } });
|
|
846
|
+
let attemptsReady = 0;
|
|
847
|
+
const checkReady = async () => {
|
|
848
|
+
if (this.stopped || child.exitCode !== null || ++attemptsReady > 20) return;
|
|
849
|
+
const kind = specNameToKind(spec.name);
|
|
850
|
+
if (kind && await this._probeClass(spec.url, kind) === 'module') { this.resetRuntimeConfiguration(); this.onReady?.(spec.name); return; }
|
|
851
|
+
const timer = setTimeout(() => { this.restartTimers.delete(timer); checkReady(); }, 500);
|
|
852
|
+
this.restartTimers ||= new Set(); this.restartTimers.add(timer);
|
|
853
|
+
};
|
|
854
|
+
void checkReady();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async stopSupervised() {
|
|
858
|
+
this.stopped = true;
|
|
859
|
+
for (const timer of this.restartTimers || []) clearTimeout(timer);
|
|
860
|
+
await Promise.all(this.supervised.map(child => new Promise(resolve => {
|
|
861
|
+
if (child.exitCode !== null || !child.pid) return resolve();
|
|
862
|
+
const timer = setTimeout(() => {
|
|
863
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
864
|
+
else try { child.kill('SIGKILL'); } catch {}
|
|
865
|
+
resolve();
|
|
866
|
+
}, 5000);
|
|
867
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
868
|
+
try { if (child.connected) child.send({ type: 'kb:shutdown' }, error => { if (error) try { child.kill(); } catch {} }); else child.kill(); } catch { try { child.kill(); } catch {} }
|
|
869
|
+
})));
|
|
870
|
+
this.supervised = [];
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
module.exports = { ModuleBridge, defaultTerminalUrl, defaultVectorHubUrl, defaultEventBridgeUrl, defaultEngineUrl };
|