From 8c67c7a7fa3f13ec8794b14557bcb16ffbfedc01 Mon Sep 17 00:00:00 2001 From: jbergner Date: Thu, 27 Aug 2026 07:51:39 +0200 Subject: [PATCH] Update 1.5.0 --- .env.example | 22 +- MANIFEST.sha256 | 99 +- Makefile | 17 +- README.md | 13 +- RELEASE-NOTES-v1.5.0.md | 52 + VERSION | 2 +- docker-compose.dev.yml | 30 + docker-compose.yml | 489 +- docs/BACKUP-RESTORE-v1.5.0.md | 23 + docs/CONTROL-MATRIX.md | 17 +- docs/ENV-MIGRATION-FROM-LEGACY.md | 8 +- docs/ENVIRONMENT.md | 12 +- docs/GO-LIVE-v1.5.0.md | 27 + docs/MIGRATION-v1.4.5-to-v1.5.0.md | 45 + docs/VALIDATION-v1.5.0.md | 34 + mega-project.json | 2 +- patches/SHA256SUMS | 1 + patches/v1.4.5-to-v1.5.0.diff | 7416 +++++++++++++++++ platform/neuroforge/Dockerfile | 4 +- platform/neuroforge/cmd/server/main.go | 56 +- platform/neuroforge/cmd/worker/main.go | 10 +- platform/neuroforge/internal/brain/brain.go | 22 +- .../internal/brain/goal_progress_test.go | 65 + platform/neuroforge/internal/brain/staging.go | 53 +- platform/neuroforge/internal/brain/v3.go | 92 +- platform/neuroforge/internal/brain/v8_test.go | 56 + platform/neuroforge/internal/core/types.go | 2 + .../neuroforge/internal/httpapi/httpapi.go | 190 +- .../internal/httpapi/integration_api_test.go | 26 +- .../httpapi/integration_graph_test.go | 10 +- .../internal/httpapi/knowledge_policy_test.go | 57 + .../internal/httpapi/outcomes_test.go | 8 +- platform/neuroforge/internal/httpapi/v3.go | 7 +- platform/neuroforge/internal/store/store.go | 6 + scripts/backup-data.sh | 33 + scripts/check-compose-env.py | 60 + scripts/generate-secrets.sh | 3 + scripts/go-live.sh | 13 + scripts/preflight.sh | 90 + scripts/release-gate.sh | 23 + scripts/research-up.sh | 2 +- scripts/restore-data.sh | 42 + scripts/secret-scan.sh | 60 + scripts/status.sh | 17 +- services/agent/Dockerfile | 5 +- services/agent/cmd/agent/main.go | 20 + services/agent/internal/config/config.go | 7 + services/agent/internal/config/config_test.go | 37 + services/control/Dockerfile | 4 +- services/control/engineering-graph.json | 1579 +++- services/control/main.go | 88 +- services/control/security_test.go | 43 + services/knowledge/Dockerfile | 4 +- services/knowledge/cmd/server/app.go | 46 +- services/knowledge/cmd/server/app_test.go | 90 + services/knowledge/cmd/server/main.go | 80 +- .../knowledge/internal/staging/staging.go | 29 +- services/knowledge/internal/store/store.go | 48 + 58 files changed, 10769 insertions(+), 627 deletions(-) create mode 100644 RELEASE-NOTES-v1.5.0.md create mode 100644 docker-compose.dev.yml create mode 100644 docs/BACKUP-RESTORE-v1.5.0.md create mode 100644 docs/GO-LIVE-v1.5.0.md create mode 100644 docs/MIGRATION-v1.4.5-to-v1.5.0.md create mode 100644 docs/VALIDATION-v1.5.0.md create mode 100644 patches/v1.4.5-to-v1.5.0.diff create mode 100644 scripts/backup-data.sh create mode 100644 scripts/check-compose-env.py create mode 100644 scripts/go-live.sh create mode 100644 scripts/preflight.sh create mode 100644 scripts/release-gate.sh create mode 100644 scripts/restore-data.sh create mode 100644 scripts/secret-scan.sh create mode 100644 services/control/security_test.go diff --git a/.env.example b/.env.example index 3b52786..6608ad5 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ ############################################################################### -# GLPI NEUROFORGE MEGA v1.4.x - VOLLSTÄNDIGE .ENV.example +# GLPI NEUROFORGE MEGA v1.5.0 - VOLLSTÄNDIGE .ENV.example # # Diese Datei ist die zentrale Konfiguration für docker compose. # Sie enthält: @@ -26,8 +26,11 @@ ############################################################################### ############################################################################### -# 01. MEGA STACK - HOST PORTS / PFADE +# 01. MEGA STACK - RELEASE / HOST PORTS / PFADE ############################################################################### +# Immutable Registry-Tag der sechs Projekt-Images. "latest" ist produktiv verboten. +IMAGE_TAG=1.5.0 + CONTROL_HOST_PORT=8070 AGENT_HOST_PORT=8080 KNOWLEDGE_HOST_PORT=8081 @@ -47,12 +50,19 @@ KB_STAGING_PATH=./staging # Erzeugen z. B. mit: ./scripts/generate-secrets.sh NEUROFORGE_ADMIN_TOKEN=CHANGE_ME_ADMIN_TOKEN NEUROFORGE_APP_API_KEY=CHANGE_ME_APP_API_KEY +# Nur Agent/Knowledge Integrations- und Outcome-Schreibpfade. +NEUROFORGE_INTEGRATION_TOKEN=CHANGE_ME_INTEGRATION_TOKEN_LONG +# Nur NeuroForge Status/Graph-Lesezugriffe des Control Centers. +NEUROFORGE_CONTROL_READ_TOKEN=CHANGE_ME_NF_CONTROL_READ_TOKEN_LONG NEUROFORGE_WORKER_TOKEN=CHANGE_ME_WORKER_TOKEN NEUROFORGE_METRICS_TOKEN=CHANGE_ME_METRICS_TOKEN # Nur bei Clusterbetrieb erforderlich. NEUROFORGE_CLUSTER_TOKEN= KB_INTEGRATION_TOKEN=CHANGE_ME_KB_INTEGRATION_TOKEN -CONTROL_READ_TOKEN=CHANGE_ME_CONTROL_READ_TOKEN +CONTROL_READ_TOKEN=CHANGE_ME_AGENT_CONTROL_READ_TOKEN_LONG +# Eigene Anmeldung des Control Centers; /healthz bleibt absichtlich öffentlich. +CONTROL_BASIC_AUTH_USER=admin +CONTROL_BASIC_AUTH_PASSWORD=CHANGE_ME_CONTROL_WEB_PASSWORD_LONG # Optionaler externer OpenAI-Fallback. Leer = deaktiviert. OPENAI_API_KEY= @@ -108,6 +118,8 @@ NEUROFORGE_CONTROLLED_LEARNING=true # Separate gate for semantic goal-cycle memories. Required for manual/autonomous # Goal cycles to learn while Controlled Learning remains enabled. NEUROFORGE_GOAL_LEARNING_ENABLED=false +# Mega-Readiness prüft Ollama /api/tags sowie Chat- und Embedding-Modell live. +NEUROFORGE_READINESS_OLLAMA_LIVE=true # Ticket -> KI-Vorschlag -> Techniker bestätigt/korrigiert -> Trusted Outcome. OUTCOME_LEARNING_ENABLED=true @@ -538,7 +550,9 @@ KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vend # gespeichert. # # Das statische KNOWLEDGE_DIR bleibt read-only. -KNOWLEDGE_WEB_EDIT_ENABLED=true +KNOWLEDGE_WEB_EDIT_ENABLED=false +# Nur Mega-Compose: expliziter Legacy-Schalter für den Agent-eigenen Editor. +AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=false ############################################################################### # 19. GLPI KNOWLEDGE BASE CONNECTOR ############################################################################### diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index bc07c6d..e96f4e7 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -1,9 +1,9 @@ 27dc46be5cbb1b171deff7fbd2f28bff1be802dff403797535fd8968bb98c8eb ./.cbmignore -24ffd2efb5aef4bbbc0770957f68fef633a1280d4fa7e893cf702d7d53928be4 ./.env.example +1450e55c6644f23616ea8215a4c3387b707fc1ecd5fd552e7c62305dd2c47317 ./.env.example ed22fda7661db8203563611dc144998161cd024e161b0471d615eaf0defeb7db ./.gitea/workflows/release-tag.yml e1ff71187cc3411a85067b964264011db7bd109a585ef7b9ea5b08bda039d813 ./.gitignore -9c18555764b03bdb004098bf6b8ca7f3eaebdccb8c17f15a11d2cebbab28cbd3 ./Makefile -debf4d1070224eb3aa07825459b86721474166a98a3eec4e46dd2509ce5c0129 ./README.md +ccfc4c139345a69d05cd2b809b0d09b4332d98e5b4f79aace4db0b648e3ea814 ./Makefile +fef01582db99291bf25dce75f4dcd12c6e46be470893b7a7b23a82dfab6d0b3d ./README.md 4858caa52c0fb6cf302a1c581d07d448e5e90e0daa797e5819610fd6223bd348 ./RELEASE-NOTES-v1.1.0.md 01163462f46314f57660677fdef407c6c2884412ea850aab13a4f650e8c29f50 ./RELEASE-NOTES-v1.2.0.md 4da388ce660aa3b7a0d8075ec066a025b5437960973397360fcb9a5d4cb58c96 ./RELEASE-NOTES-v1.3.0.md @@ -13,17 +13,21 @@ debf4d1070224eb3aa07825459b86721474166a98a3eec4e46dd2509ce5c0129 ./README.md 548f3168f8f29add4f510c4c8048a74465f311dbc3ba2436e5b8db5cc898c552 ./RELEASE-NOTES-v1.4.3.md 61ceebe5a891388336795fda2fd0d1ad10373b4ebaae7d2e670f35766115c604 ./RELEASE-NOTES-v1.4.4.md 15a3defdd5bbf07ebaec1a2e8626347a027a3622f10afb508f7a21c9b0bdfa71 ./RELEASE-NOTES-v1.4.5.md -f43a0292c674542a2d676a0f4f588a7ffbe1cbac25090df344b12ff1279f7820 ./VERSION +d10b1dbd87585d72144d816449800dc17b6d7f8faba2dfb4eca93ae3def2c631 ./RELEASE-NOTES-v1.5.0.md +acb57a7135b2d7d6e665f67f056e21353023b93835f54def1ae523bf76f1bfb3 ./VERSION e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./backups/.gitkeep 8127e9db5e5e0af1d88770dc8fa60b381de45dbcc843262698cf9501409b4d58 ./deploy/searxng/settings.yml -5165e1128184d4951ab5a8a0d3cdc3fe538034eca53471bd88251497e0f08a82 ./docker-compose.yml +3e153ad540d6cb1b20275775f2d7c129ec0216764cc9d0d27b963c634f347b43 ./docker-compose.dev.yml +2d6a4d764223bebffb44c96121c39aecb3348d6334a1baf98fcb774b6f076ff2 ./docker-compose.yml 0f2adaa0765ff00d9c3a1133840c7a768a011f9d8f820f8a340b43600c7649d8 ./docs/ARCHITECTURE.md +34e2c7ac5992389cc51b40f740c1e206f20bffc67bdf19cb59d94b5df67a7082 ./docs/BACKUP-RESTORE-v1.5.0.md 9467a3c0796a87bada0eab6ad191913e628b4abc05520cbb4b73fac76d334481 ./docs/CODEBASE-MEMORY-MCP.md 765c39f0db69165a70d8175d1fe7686ff6dd512669db088a6fdf6a93de5404f6 ./docs/CONTROL-CENTER.md -ea8a6d298d33ac3f23825dc58f2bc12dc369ed9487b7b96c4b28a8febf1299c0 ./docs/CONTROL-MATRIX.md +88955b0ea289ccc63cbd3404f5b00bb446b38270408dcee23a6103a698f1f76b ./docs/CONTROL-MATRIX.md 323402668da4d8e1d97a29c45db753834f6c3a16739eebebf7e016f06af160bf ./docs/CONTROLLED-AUTONOMY.md -15f27a2ec770aceb3f9a4cbe82cd39953620b913171a1b63142edb3883eaab09 ./docs/ENV-MIGRATION-FROM-LEGACY.md -a7233425091d9ae80e9865127ab5850c92aafa06656da1d7875ebab4246f47e1 ./docs/ENVIRONMENT.md +51b4a8d7daff787f099b77bd5a33ebf8038c0812bd3056f0ee6afe17e103681d ./docs/ENV-MIGRATION-FROM-LEGACY.md +8a8eb1c4ebcc3447ab5deb2d3f091d4b6c06049ca90ff7e7497402c5541a1d95 ./docs/ENVIRONMENT.md +9b8b4bba601fdd7a9a92fea817c8278a0f67673f3334e988662e50eb698d5e9b ./docs/GO-LIVE-v1.5.0.md 2465c837c243ef03856ad1297b540d6df90005c74b9f4a9bba223066a612009f ./docs/IMPLEMENTED.md 9c38807cc12fba6f94cadc4694996de58e6d2d5396e55fc0c4bd404bb13f53ad ./docs/MIGRATION-CUTOVER.md a71d28353529906cee08cf90f35e0b0cf97bbc94ccfcef830145a2d561b21a9c ./docs/MIGRATION-MANIFEST.md @@ -31,11 +35,13 @@ a71d28353529906cee08cf90f35e0b0cf97bbc94ccfcef830145a2d561b21a9c ./docs/MIGRATI 46672e5984cdcf5f31e88dccc273041aa277552cc9c5ecbf59a46b3de133da7b ./docs/MIGRATION-v1.2.0-to-v1.3.0.md 3738dc79be0598316dc397f1fcc71cedda67b604a3038b116c2ea001dc3105ce ./docs/MIGRATION-v1.3.0-to-v1.4.0.md b285d050223844f5fd05014c2278199eaccd7525fbbf8a6dae724ed87ced0ee8 ./docs/MIGRATION-v1.4.4-to-v1.4.5.md +57858f4f2b6670106edf5a88ce705649d547c65c26407d6117040296122a0e26 ./docs/MIGRATION-v1.4.5-to-v1.5.0.md 2a01fb10a3e04eae1800a7c7e0aafc31e9bbb23584cea54d849b004716ef81b4 ./docs/OBSIDIAN-EXPORT.md 040010a807178d33797106e04716822f8d147d7d5833c1ba70865a1827f8484a ./docs/OPERATIONS.md 69ea49bc76690ac489aea908f4e784a14293e92d62478a240eabea53c5f90820 ./docs/QUALITY-REPLAY-example.json bd2d3c43a09a89fefd0e844433064d7d7384a414179a754c6de94232d738fb04 ./docs/QUALITY-REPLAY.md be749a09ddbcd4cf427316c6fe531f138e9bfaa6c104f4c11a08f7555231e8b2 ./docs/UNIFIED-GRAPH.md +77d91565660789c621b05bd69fecfa3c2ef736f93855eca1f58b017e61c76f90 ./docs/VALIDATION-v1.5.0.md 3ea1953b2b4eb8f966310a65a12a681fb44e4eb3857b843312e235759aacfce5 ./docs/VALIDATION.md be4810451750abb676eee0edcc6f164d86f372f32b185f6f730bf84538cef4a8 ./exports/knowledge-obsidian-snapshot.zip dfa65e55e9ccf642ae5ef8ef91c62f4220b31ce4304ea4e1061f2e7d8c1b0fb4 ./exports/knowledge-obsidian-snapshot.zip.sha256 @@ -143,8 +149,8 @@ cb3cde795233c0d4842dc7451c5855d1c071e459efbde3be0cabac32a837ba1b ./knowledge/14 7f1d67faf4a6cea0c41c84d7b275d8a8979b19420b52db4925d8e2cd71ead3e0 ./knowledge/16_office-aktivierung-und-lizenzierung.json f1eab883370e0a40ef52a6b6d785a510d8ed48a19d25e0a2bc95f4f2cc8e329e ./knowledge/17_serienbriefe-und-dokumentfunktionen.json 5a0b3d5d5bc712e30a67a4de3432f69070f7e43dd99931f675e72c213006b363 ./knowledge/example-vpn.json -0a4372aec4bd297083cbc6604de9e6e863d2c81cc8c3f385382cd027a6357731 ./mega-project.json -0504dee0ee757b4ac143860452168b52b5c2ad9c6cec0010edd60d5b2e573d99 ./patches/SHA256SUMS +4fcf49ee06fc1aa712ade9fdca357ca29afa21ce888a6885f9040c876eee1082 ./mega-project.json +87ec7642014d4f20e1c75c6d01156c60eb0ed4e65c8cba42fbb4f1a33dee7039 ./patches/SHA256SUMS 47a6fa2c79bbba0c04af86dfa65d58529f492c698060fe586456c22a4eadb877 ./patches/glpi-agent-mega.diff 9b411c90d96a86c80f088ee4637046eeefaf31c62059d11aa1a999d6eb08b5b4 ./patches/glpi-knowledge-mega.diff f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neuroforge-mega.diff @@ -156,13 +162,14 @@ f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 ./patches/neur 651c883facddfd0316f89e3cf3c24fd913371fcec2a5f447b8d105fa5dec463e ./patches/v1.4.2-to-v1.4.3.diff d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada ./patches/v1.4.3-to-v1.4.4.diff 01ba8cb6ea6ab0abd84295fb969fcfb93d841cd46ae1813fb0de9b23b2d5b22e ./patches/v1.4.4-to-v1.4.5.diff +617b434dcdc0dd0be5cdb15740661f0a513b330b99835364b508e21467f86ae4 ./patches/v1.4.5-to-v1.5.0.diff 564817f8edabde0c4e4a1a427a3aa5418aae7bf12e9463044a7e6e0f13973657 ./platform/neuroforge/.env.example 39319b6f2058e4c8d6656a9cf01675374f81a04075b956b093a2befb5e05ada4 ./platform/neuroforge/.gitignore 189486a885c7fb78e0eb878d93cda0c70ca6d7ff9bfdfb3f5f32487cf03a9688 ./platform/neuroforge/BENCHMARK-v0.5.0.md b9cfe0119f316b8143ebd33e9f66a8da36493a2c6c17acf2519ecba023223424 ./platform/neuroforge/BENCHMARK-v0.5.1.md fc3aaadc0675ebb6dfeb42d9612a68c1905ac575e2c33299ca6e1522d8bdbeb9 ./platform/neuroforge/CHANGELOG-v0.7.0.md 9a1923b4c01a02d18b982aab3579ee7a976a95a6997dcc60647ae4283dd1e863 ./platform/neuroforge/CHANGELOG.md -0cf6f4a2f51b3f9ef1d985375476de68ec96338f85f8070c555807cd42e0abc3 ./platform/neuroforge/Dockerfile +2b286a4066da5b6bf7ce83c42ba7c97df6d4c6f8b71aa3f28d1ff824a7814391 ./platform/neuroforge/Dockerfile 9a9fd7708b61027e3677044ab1cbf7177119eac796df4d8786d39011da28729f ./platform/neuroforge/IMPLEMENTATION-NOTES-v0.6.0-dev.md 3146656e702607501cb348c77dadff608f9c152c4104647230848004e2692251 ./platform/neuroforge/MIGRATION-SQAR-VECTOR-JOURNAL.md ee7f52ec20dcce87b751637229fe26c65d5cb0a926ac32915f46bf1b5eedad3d ./platform/neuroforge/MIGRATION-v0.2-to-v0.3.md @@ -183,8 +190,8 @@ c3d34504b1b8a4ef73382df061c7b272a55c2a3a7a720435c6050254da3f22e0 ./platform/neu b4896439112f9ca0f0a43d55d43cb3a2c4c802c568b65a96d04a3ca9d841c358 ./platform/neuroforge/VALIDATION-v0.8.2.txt ceb9b2c03afb769df3b1e518520c4e1798e9a3313a1c5ffffcb054fef5d6d6fb ./platform/neuroforge/VERSION 0c5308f5a3d37ac23dce162fd5fab78ce598e41671db5dd50c9c4ae7f215d49f ./platform/neuroforge/cmd/bench/main.go -7ceb003d31ac1f126fc4815cb1bb1cb3376f018b10716d9ffdb6b033df3c5bed ./platform/neuroforge/cmd/server/main.go -1043f1658a672f9cdfa3f68ca3d19c1b81ca5d69f240d01924ddf393613c7f75 ./platform/neuroforge/cmd/worker/main.go +c281ce4122ecd165fb14eb76464b2f459fb71e3bc0dafe89a36e309f420c4448 ./platform/neuroforge/cmd/server/main.go +ea9b7a78ff7e4987d4f3e8a48799ed2c109632eeb8c38284fe123dd21d7566bb ./platform/neuroforge/cmd/worker/main.go e3dacdaec3c629dd432f24218a50fa4de5e1698ac7f27e84b14f776b50beeb83 ./platform/neuroforge/deploy/learning-policy.example.json 3765a5faea1faeb72aad7878ff0159a56fa1b0ca1fc94a348a8c599e460b61dd ./platform/neuroforge/deploy/model-routing.example.json dcb7f80239cf955941ede9bcdc3bda8f39d81db5e5b0e0962b373036b40a0df5 ./platform/neuroforge/deploy/neuroforge.service.example @@ -193,15 +200,15 @@ c30e5b2fd39e72894db22499259b6f97d225c5929a7f3472277853a348cab9be ./platform/neu dbbaa7fd4430b9cdb5302144b6d40f7ac4ab9f73e81da4ddb51e788dd373d95e ./platform/neuroforge/deploy/searxng/settings.yml.example f94bc850fc5cc5004f71b1dd591a75b9f9488b7f9fb39d643d904c9d67b7380b ./platform/neuroforge/docker-compose.yml fc993dc95fa49802ecb62994e4140dff18a27438e8a4f3c6352229c79b041710 ./platform/neuroforge/go.mod -5e4abc0d7c1da1fd43082378238d0e2c15a04f0f83123dba5983fffe61f503be ./platform/neuroforge/internal/brain/brain.go +995aa439cd162029b3e545d9270b3951285771553021ab3474256d59bee9d933 ./platform/neuroforge/internal/brain/brain.go 976288422c0c4116d8c98af8a9b164ac670f9caf03eddc2d5e2e48747456d3b4 ./platform/neuroforge/internal/brain/consolidation_test.go 0478fa10145e3a656ff84b612a2a60eea004b8fa795dec9f192f60022272d171 ./platform/neuroforge/internal/brain/goal_progress.go -f6495c67e194b134bb38f4b719f851e2182fc1680c5e683aaa36c3ba8a2d3d5c ./platform/neuroforge/internal/brain/goal_progress_test.go +f6848679e80d1bfc369489490f99826b40e67c9c430c3a00284b9ab82432f8e1 ./platform/neuroforge/internal/brain/goal_progress_test.go 359955653c647125559afd6dc3ebe69aa5ca19ff7e825ce801b7bc24e5fbcfcb ./platform/neuroforge/internal/brain/policy.go 27e87af473d2d71ba94ffb9bf7a70934776f8c23ce45496ca0998ad3000fc156 ./platform/neuroforge/internal/brain/policy_test.go 0a3c8f7d149e814e091595982dbaa69467f6bf11eec631471d133a9b21585ae4 ./platform/neuroforge/internal/brain/research_trace.go -b5a879a03bd8a180002b312895b4b9f1f5a5c8bda56812b93dba7a17d46ea109 ./platform/neuroforge/internal/brain/staging.go -38823dae22a587e709c9a26866698bdc668d57ec10835d0de1dd46c6c2ddea01 ./platform/neuroforge/internal/brain/v3.go +36796c3c9478938bdaa1b7577d8f01875112ab2118943be0d02716fffe0ec76c ./platform/neuroforge/internal/brain/staging.go +d4fed8b68d31f6fbd1992bde76abd4e7b2f9d211e53bf8b234369a6a53692fe8 ./platform/neuroforge/internal/brain/v3.go 3ae13251512ecad1423a33ce09889f961130fefaed9342170b2d5cc6b3b51893 ./platform/neuroforge/internal/brain/v3_test.go 4bc58463b659bd7e51db4c7dbeba053de90fcb41f392a7d6e62a8cd84ddaa092 ./platform/neuroforge/internal/brain/v4.go a9619d9571f6ab6363b36af9fdf1690f9f88e9333eb6f2773ae68580b19666d8 ./platform/neuroforge/internal/brain/v4_cluster_test.go @@ -209,29 +216,29 @@ a9619d9571f6ab6363b36af9fdf1690f9f88e9333eb6f2773ae68580b19666d8 ./platform/neu 816b725594ea5f6938999799394eaea9dd619addaeec5447d8e7707da4c28c69 ./platform/neuroforge/internal/brain/v5_cluster_test.go cd8f1281e25ce42ae8918abbed16d4bf57ada9c3a3cc1212edd28f77ccc9328f ./platform/neuroforge/internal/brain/v6.go e287993397922611b98a3b8fa6e60f1f05a4c780a9ea58e998c55e5f0147073a ./platform/neuroforge/internal/brain/v8.go -3f01ce1b13b63489dcff2e0d63862ce27d6eda5609e0a2432f1d092de377cfe0 ./platform/neuroforge/internal/brain/v8_test.go -e5352767a282d19ad7f8048e0c4dfe3816aae983c5220c7bc6061c4c736e5eaa ./platform/neuroforge/internal/core/types.go +cc2ab445fbc69e64f2e4adafbab045828c80f00ca97170cd8cf80346b247509e ./platform/neuroforge/internal/brain/v8_test.go +cf6e43d8daa0461ec38dc725197aa851332caa466bf9e0414c02fa88c446e064 ./platform/neuroforge/internal/core/types.go 65a8b8196343e7cfd9444ca314da4a83c81bc046d9478b8b93217d9cc68ba562 ./platform/neuroforge/internal/cost/cost.go b9bb2934e01ed2bfb6b16e4139e10e51f1387112eb8654f7f692779a2fd2d273 ./platform/neuroforge/internal/cost/cost_test.go fb66ce4ab760b979eacd4f7f17dedc41916f93c53ba582be572d250f99654695 ./platform/neuroforge/internal/httpapi/admin_app_auth_test.go -4b8555feb594fb71aac1c868eca00cc952eb4e998fbea10b442389f7d5e8160b ./platform/neuroforge/internal/httpapi/httpapi.go +1e2cbeecce139a32d4a12137dea59d5071ed9dbebd26a2d9933c1737ae6a851a ./platform/neuroforge/internal/httpapi/httpapi.go 668eec30df3bc92fccbdacbd77effeab985189480fb39c48e3af0832e352de24 ./platform/neuroforge/internal/httpapi/index.html 20f4a6cc30d5ce84ceed4fbdfc9f5c57ab5f82274e0c54464817d571e0e881b0 ./platform/neuroforge/internal/httpapi/integration.go -7f6e4767dfcb948570e05baadca6917f27a406fe57ef82eeb63e3b3979ec94b6 ./platform/neuroforge/internal/httpapi/integration_api_test.go +c346dd0205548f7cdb00ea026f1afd82d59f87824eb983ebe84abfa7cb078974 ./platform/neuroforge/internal/httpapi/integration_api_test.go f8cfcc7a2bc781394231c25e36e75c41563f5250a1387e5687880d2591efd93b ./platform/neuroforge/internal/httpapi/integration_graph.go -532529043062ebc5a919abc58b68ed2c578dc7bd0105cbcdfce3f432ce776293 ./platform/neuroforge/internal/httpapi/integration_graph_test.go +c289fe8748d28d4c1207b22924f7d1a7b2df25703d6df5bd1869acb325211b2e ./platform/neuroforge/internal/httpapi/integration_graph_test.go b1a9df15a4d0263dd90b47672866f3e4e308498ae99002ba63ce128b759c8ad9 ./platform/neuroforge/internal/httpapi/knowledge.go 409dd8d7c5ca93cc82ce2b7bf17a12655c97dd27ad9767aa40619502830a0ab2 ./platform/neuroforge/internal/httpapi/knowledge_integration_test.go -9935c98831e586aefc9d1439ba16cad836103546b1343de646a61c17648d8cef ./platform/neuroforge/internal/httpapi/knowledge_policy_test.go +cf285170ff175ce2bb223b4ffd87a35da604ec73b5fe386ac35402af1f9c3531 ./platform/neuroforge/internal/httpapi/knowledge_policy_test.go cf8d0808d3f37de187c4acb5e50e094f8b43d2b7e731eecf99368ab8a1325d41 ./platform/neuroforge/internal/httpapi/metrics.go c40412c77dd8635bbd4bf9c6d7eb11b0d7902aee896bdd1ee99e5cdfb72c7453 ./platform/neuroforge/internal/httpapi/metrics_test.go 83033a18a319ffb162c7cdefb2efe108a47dba81095ac34662349e232f8c22da ./platform/neuroforge/internal/httpapi/model_routing_test.go 38a9388b6907975aec933697ad87ddeb9e7016a279523f2526db3810df70b006 ./platform/neuroforge/internal/httpapi/outcomes.go -093f6c37c1021cb252501d2aa9c0f42a75444ed69748b455847623da122947b1 ./platform/neuroforge/internal/httpapi/outcomes_test.go +0b0b111e6b47f38dd3ca331a0c1b79b3de95012820f1c04cbe37c79460c09e63 ./platform/neuroforge/internal/httpapi/outcomes_test.go b3b1f5cfd6b0ec04341978898d72856af03a95b8922daf9864caeb27e37f1925 ./platform/neuroforge/internal/httpapi/research_live.go b37f7ec3be867666999a9ec314139dd1bc4c74efb53edfa224b4fe8119858aac ./platform/neuroforge/internal/httpapi/research_live_test.go 4e65ab0a760369a6d6c4932c180ff6c67f0768d91e08ccf471f53c6ecd5d09be ./platform/neuroforge/internal/httpapi/status_dashboard_test.go -8a2251d4f4ae8114ac3f5e98fe2e1d6ce24e7817adcfbfc1f383d770e6ce139c ./platform/neuroforge/internal/httpapi/v3.go +cccfebd3fdac137cc7334b7059c8ecb53951056499e5b8895f2efa38584178ee ./platform/neuroforge/internal/httpapi/v3.go b1587e066b56ef72f17162583698614d31c413e2ac7e75359b731c2e882a50ee ./platform/neuroforge/internal/httpapi/v4.go 1ef5cacb0652f2a6301c57f8987dd848fbe590abb73cab44899d9152ccce45d0 ./platform/neuroforge/internal/httpapi/v5.go 1d534726d7a75f21f7ad28a959f73a3402c021f7f4a1b554f0600cf3abf669d1 ./platform/neuroforge/internal/httpapi/v6.go @@ -267,7 +274,7 @@ ea968456538367ed536c212707209a173098ec65823152a0813422bc8071c952 ./platform/neu 4d41586c15bfdd13139e19bfa102613f18ddc6120e4214a1f45dd9e2eaa195af ./platform/neuroforge/internal/store/sources.go 0ff464ef7fab01324d77ee4e406a8c15451b3333b75d4afa444be3c3c94f016b ./platform/neuroforge/internal/store/sources_test.go 61cd82b106e74f2aa0a7591d757a0872491a635dc1fccc78c66d41c052d0c9d2 ./platform/neuroforge/internal/store/sqar_vector.go -4faa443230f87326449d80b731ce27a6590256dbdd2f1209e8faaab8fce20610 ./platform/neuroforge/internal/store/store.go +d8f9d45c6675fdd6dbc04b8c1ca5173c2e013024676ccab3a6594572f30a51d7 ./platform/neuroforge/internal/store/store.go f86b648e8b4845ab83e672ae3fcfb0bcb0dc1bf891abf4bd30d380304f0b259a ./platform/neuroforge/internal/store/tiering.go 55f8e410258a37b4da1175753342017b94481a77142f9015a8b1f9a99b34d36a ./platform/neuroforge/internal/store/v3.go 091e4c9cacfc76786a76f6afc4eb2b697e5a641e611475b6dab97e99371360ea ./platform/neuroforge/internal/store/v3_test.go @@ -284,13 +291,20 @@ c854f70f4141344dc2c6fbec4419afe5dfe25fd1bc2da11e41781730a21c8834 ./platform/neu c5463c525f5ea703ea1f6df74cabc30f937f3846b1f75f237adeee2700baa977 ./platform/neuroforge/internal/vector/vector.go 276cb34ddd9f87abdac17be110e2bf15135ab83ba66bf91663d59d61fbd81125 ./platform/neuroforge/neuroforge-v0.7.3-rtx4090-example.json 5199b597b3e6b83654c7912b6ee7b22173ff21f77cc6ba26e385d335a0e6acf4 ./platform/neuroforge/openapi.yaml +3e9a34479e5106cd7551c5bbe0eb1f6b206421ef9fbb99d7f7dd0f153f54dfeb ./scripts/backup-data.sh +df684b1b487762248516aebb82742e5777395b504cbde42ebda85b999ae353be ./scripts/check-compose-env.py 6f1d21b7aea3265a088256801aa37a00931157523d8bd834aa4f473e99511f04 ./scripts/codebase-memory-ui.sh 5abbc6b60bca94fcb880abebae19c85c6a220c9eabb9865928b23ae86246705b ./scripts/export-obsidian.sh -6ffe1e8e4f69d7f366edb2b72190d155d20b68a98228cff30f81a751b4b65ad1 ./scripts/generate-secrets.sh +01611fcaef13df301147ad539f38e96e09d32c8f0f17c0fc9a7e3dae38de6292 ./scripts/generate-secrets.sh +8b0a4be74d87cf42c98360819c6f797b9caaf10528e729159412daaaffba0771 ./scripts/go-live.sh +f8598a38e913730d5d054dd973cecc6a7632434fbbeb45932891c915c0fbc318 ./scripts/preflight.sh fe35fa5db6dabf06e6266e0de4c372604160180366d4c0a8042c7ee54b7c6dcd ./scripts/propose-draft.sh 1744be0629e845c8d94dc2a06e91ca2cc9ce5928db00300b7fb894445aeda9ff ./scripts/quality-replay.py -0835b14909b5d001ef93a1678be968a87a525b6836c2c6fe8885a8bf889b4718 ./scripts/research-up.sh -457c349025a9508cc67154d571533dcd1ee77b5e0b89927de0846f0355e6a7e8 ./scripts/status.sh +7f629a9fe9b538eaa028165ba2b5befd7d50fde20c052bcd453539a9a89e2351 ./scripts/release-gate.sh +42fed802f6bf6c9d46bf6eb2700fc0e10a4aa2c9447027fbae34d162805d61a0 ./scripts/research-up.sh +2e3622c298cbc7dad4df8b2b2d69a69c1f9585ee20a1474cf71f5c5807571df1 ./scripts/restore-data.sh +ddd353d613d17320d6d705aee459b5b290a5f8535e19b5b664e592d13aef8fc8 ./scripts/secret-scan.sh +a2c56eb668137e058cb78bb592ac359ef7e608cb9bedc7eaa930b3f4f66411ca ./scripts/status.sh 2970d5f5049323b14a3f3983c2f4eb5392d745b4fea34742e3cf56e52d740f17 ./scripts/validate.sh 17d7149607bf40ccc6f64a3c4cf3e4023a8325675918c18df0be309ea9731a36 ./services/agent/.dockerignore d1d8c18c6f7c7c6f0b2d72f421837d010afaafc931596ae48442a826e50f0bc6 ./services/agent/.env.example @@ -298,7 +312,7 @@ d1d8c18c6f7c7c6f0b2d72f421837d010afaafc931596ae48442a826e50f0bc6 ./services/age 9ee46f2156ee805c4ff40e418b004ead5cfbc3a9b6df3968a1b772fe1b313cdc ./services/agent/.gitignore 4c893d6049499eb70ca755a333e97f0a726dd212e4ded4a9d4acabf0a25d9bd8 ./services/agent/BETRIEBSANLEITUNG_GLPI_AI_AGENT.md 9339ce57dfc58d4731ea02c8d0ab9f2835e5966a29c071062fc6a2effd17da2c ./services/agent/BETRIEBSANLEITUNG_GLPI_AI_AGENT_OLLAMA_POOL.md -4813b4c07ab63a2931142ebc0b028b9c26a73fbbade7294a1fd225365e9a2b85 ./services/agent/Dockerfile +81c91a44c5d3b7eca3051d8cee35f231ef283563da7a636c56c98b61da60218b ./services/agent/Dockerfile 2ec93f81187ebf92665a6a15a6672b2f7dfce23bfea1546f2d4188f49de5dce0 ./services/agent/EMERGENCY-HOTFIX-TICKETVERARBEITUNG.md 2bce6e10780004bc12042a416b8a05b417130db2d730d01a95d9d86a564e01cd ./services/agent/ESCALATION.md bc344ff4486bd38356fab0d2da46590d9f738ece0f712d043597012f215bae49 ./services/agent/HOTFIX-GLPI-KB-AUTO-REPLY.md @@ -315,7 +329,7 @@ c0e13c6703cf3feda57a9eaaa718e501280fbb526f466040eb685a27700b5797 ./services/age a7a3f969150005ef062e7a77cf852371931bc2fa5ca4be01cb0e9eb07d35335c ./services/agent/README.md aeac85d1adc92a8b34ffa5a95f7f19c2c6fb94742a7359a7fd452529d59ad87b ./services/agent/SECURITY.md edb92cdf2d1863df53ea8045657f64e415f206d503c8da3b45ea96b7f7f0960d ./services/agent/UPGRADE.md -8cce9f5a3872854605e163579ec07b94d07bca39cf507211eef155e27dfc7f4c ./services/agent/cmd/agent/main.go +68cfcbf3bd8eb0c24f12e41cb3d95c1654b98fb7cd8b9606844d68e409428c23 ./services/agent/cmd/agent/main.go 98be0fd63dd4fde52342c5385da113c926ac29195aedb657c0ca0f98e1593fff ./services/agent/compose_local.yml e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./services/agent/data/.gitkeep b74f901cca99eae4bed1b168f1f077a28bf1134a71c867d263c7834f6d896684 ./services/agent/deploy/glpi-ai-agent.service @@ -338,8 +352,8 @@ c4bf8fcb4ba6eb7962ec55cacff806a8a99561d134ab5238abd012763a2f9c74 ./services/age 6b70a29176602e499935a85ac8831dbe36bf75fddb130a56cda376e124ecb7db ./services/agent/internal/agent/status_reply.go 6f95bd3c85b42f1b2b098f05828c0b9e3dbb2e57f1f89b3dc4d1806453e0c4ce ./services/agent/internal/agent/status_reply_test.go 9057a742c3fe8e0fa76efd6dd9bcdf9fcdf7ab2c37ed823822808946e8649566 ./services/agent/internal/brainactivity/client.go -1b0ac9efe4cc3b0d98cdb943379974e96be0fce07c670c542cad042aa04abe06 ./services/agent/internal/config/config.go -c5f23c579a35d25e9f319588093f08a04951a759843c0f8f6574adfe500facaa ./services/agent/internal/config/config_test.go +cbb14913455ead81f9fe85c3dc185a29431e28271f8babe840759bc93417c0a3 ./services/agent/internal/config/config.go +fa1717032ad536bff867a07d3fdf0ae2e0a9d5d7637a27b43274832d51322eb6 ./services/agent/internal/config/config_test.go 6fe7335b0e66b60d5f6f27583b1ac11a0948719cfa657d51e77de0b337aab5d5 ./services/agent/internal/contextdata/collector.go 12e9eb6fd567b5d4173a63fa2b89be913b6d1c97e17b69048f46d119e88dce26 ./services/agent/internal/contextdata/collector_test.go 5eee0e130a9cb8452a07e9f5d4c76048ad2eb62aa411ea61c9dc6066fadff934 ./services/agent/internal/glpi/client.go @@ -485,27 +499,28 @@ cb3cde795233c0d4842dc7451c5855d1c071e459efbde3be0cabac32a837ba1b ./services/age f1eab883370e0a40ef52a6b6d785a510d8ed48a19d25e0a2bc95f4f2cc8e329e ./services/agent/knowledge/17_serienbriefe-und-dokumentfunktionen.json 5a0b3d5d5bc712e30a67a4de3432f69070f7e43dd99931f675e72c213006b363 ./services/agent/knowledge/example-vpn.json 895f8400aaad550ff2b96262eaa54e954d603129682e97aa4964539eb19bd7c2 ./services/agent/run.ps1 -5e2ea444f8321b723313184f8ae12219c319435cdac8904596a4481fd2be8822 ./services/control/Dockerfile +d1d4ab8145a7131c36df51d3a9fbdcdeb0cda3de134fe24c0e8d52370862f3b2 ./services/control/Dockerfile f30ddf9251860d92717276483f7a2c2d7405f0516940ca62abc21d97ff3f3ad2 ./services/control/cmd/engineering-graph/main.go -410249aea09fa099da8f52510e47fad5d5808550d935a345f7a21e1c539419bd ./services/control/engineering-graph.json +d9a33f614749c122570eaa646478651f0371912c3ca29511da220cede48d4eeb ./services/control/engineering-graph.json ba44c599b9faf861eca647614c4abd18548e2708233cf462b11eb277e96449b8 ./services/control/go.mod da37393e58ff53847f26b1051f6d3a5270571d290b33d79360bae0dcb3513829 ./services/control/graph.go e6a8021a44219219fe37ab5edc45d1908fc6579f653aa80c9966810d891ad27d ./services/control/graph_test.go 2702567a72bd89c2d4a1ab4d2fe748347b5e678493b1d99a513cc7e75d0d91dc ./services/control/index.html -4bb2a82e942ce49201252208bffb584d1ec4dce19e8ab85b1f98627784001c0b ./services/control/main.go +6358f633a2e80844538f5f35c6f366800eb44db7ccdd8dd7cde81a9dfb33e983 ./services/control/main.go +f0ec671cd3f00083d9b9d69c82ffcaa8efe08122837671e812f2a510f3c4e321 ./services/control/security_test.go 457fa6b4c81a62197eb1e76a5472cd571d5c69659110fcd9bc016582f1b5dfa1 ./services/knowledge/.dockerignore e82cbdb6336e424c4b9d8db5d149016d606579292184582788a9bf295895cda4 ./services/knowledge/.env.example 236713daf159ff0a8067e80a442ae3404fa28a5251ae6f24782f263bcfc17005 ./services/knowledge/.gitea/workflows/registry.yml e45bab225ede9bacef0b53f84fb0577235d9a81967ae2a53ab7c7f4d66bd4ccd ./services/knowledge/.gitignore d3becb2b7565ba0b1d32bd8d3b085dca6adec2d2b153583c8c6855e0eea43b66 ./services/knowledge/CHANGELOG.md -a8cb88ef493c9cf7167f819502baf8e932fd8b2fa2dfadddf659b3e7d703b5a4 ./services/knowledge/Dockerfile +d3c5e5636f96c641b0181fd91bb2cf4802529a7355ba0065cea49356057e402f ./services/knowledge/Dockerfile 31f5067cf8e45271081ff08bf0d9afffef74d300628ddc1bd1db6e87e6d4e922 ./services/knowledge/Makefile d50e9892824dc9e2f86b7f4fbfdc81165a8c6b403e4b2b9f6fd5bd2f22c295f0 ./services/knowledge/QUALITY_REPORT.txt bcafd8739157e677416bd4e7c3693eafb1572d72384830ab839dd4dcef74d3ee ./services/knowledge/README.md e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./services/knowledge/backups/.gitkeep -351b518025e65f01eaa3d5a689142fc1dc822dd0a469b1293c0985486855ab4f ./services/knowledge/cmd/server/app.go -b9ca4277e3f192ea6ff945e2c16ed2559f23decfb7d13177475a71e4f13e50cf ./services/knowledge/cmd/server/app_test.go -f7349dae3b79794010a594fdff23fb9379171b950d2049f9eef17dd5e81e51eb ./services/knowledge/cmd/server/main.go +cbaed4e619df99e83eef694b12b660757b749bf64c0a340143bab523a121c7f6 ./services/knowledge/cmd/server/app.go +ba6569476482d21ee6eb4852d6f1a8dbe052335f3be4669ef96298d0f5a5028b ./services/knowledge/cmd/server/app_test.go +fdaeb96aea39074522e75c2aeae8036ada253167d2e44bba156b10a7b8cf7c61 ./services/knowledge/cmd/server/main.go 1867f5c83786628441a1c9e3bb96d90d496f5cab24b6dd62a80376f256bfa497 ./services/knowledge/cmd/server/viewer/app.js 9537e946b22208a5a78f11a29a92c8f379805f51f3de3d3c74a7cd068d876418 ./services/knowledge/cmd/server/viewer/index.html 58f16f955f1287bc077933980e50eaae238ab2eb40599242abd820a31aca79f5 ./services/knowledge/cmd/server/viewer/style.css @@ -520,9 +535,9 @@ f5d6b8c7891ac8ab237423914f67bff5b91f7e3abd255622eeef26ec83feda37 ./services/kno 9057a742c3fe8e0fa76efd6dd9bcdf9fcdf7ab2c37ed823822808946e8649566 ./services/knowledge/internal/brainactivity/client.go ff6d65a0a4648464a89c67e06f5c33a4ec87d7a41e84d894725e8ed582d64acb ./services/knowledge/internal/obsidian/export.go e8e91a8ce16c1905c963ac57a156006e92323be80615637a7b1b477b89464012 ./services/knowledge/internal/obsidian/export_test.go -cb51849257e967e6e15657dcb44fa074b850152c71e47e3cbc527b66d447cd13 ./services/knowledge/internal/staging/staging.go +7fa9c38a719f2f46bd169438aec3c6e8595b047d63236d2a5e78db9cde6924f0 ./services/knowledge/internal/staging/staging.go 3c1f5726489a773536e430152b41944bd623ed0f88939688744611371462dd6c ./services/knowledge/internal/staging/staging_test.go -f8db5f01b1ff61108f9d642002ef01f1946918468e644a6146ff4afabaa5688b ./services/knowledge/internal/store/store.go +3bcb39124c72cb46efc7cf794254ab5821dbd7da57e7a20b1366862a71b874a9 ./services/knowledge/internal/store/store.go c98bd048680978d06d34303db9729b130eb7cfc0750f15ee1a4e7abfe582dca1 ./services/knowledge/internal/store/store_test.go e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./services/knowledge/knowledge/.gitkeep 81893d2cba93d7a30c5c728559e81a7b7e2eca87cae8f9e239aeefcaaf604d60 ./services/knowledge/run.ps1 diff --git a/Makefile b/Makefile index f331ffd..ae3c4c0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/sh -.PHONY: test vet build up research-up down logs status ps engineering-graph engineering-graph-check +.PHONY: test vet build dev-up up preflight release-gate research-up down logs status ps engineering-graph engineering-graph-check test: cd platform/neuroforge && go test ./... @@ -15,10 +15,21 @@ vet: cd services/control && go vet ./... build: - docker compose build + docker compose -f docker-compose.yml -f docker-compose.dev.yml build + +dev-up: + docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build + +preflight: + ./scripts/preflight.sh + +release-gate: + ./scripts/release-gate.sh up: - docker compose up -d --build + ./scripts/preflight.sh + docker compose pull + docker compose up -d --remove-orphans research-up: ./scripts/research-up.sh diff --git a/README.md b/README.md index 503e678..a31b5e0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -# GLPI NeuroForge Mega v1.4.5 +# GLPI NeuroForge Mega v1.5.0 -> Release: **v1.4.5** · korrigierter Research-Goal-Fortschritt und echte NeuroForge→Knowledge-Staging-Pipeline mit Human Review. +> Release: **v1.5.0** · Stop-the-Line-Hardening für Trust Boundaries, Registry-Deployment, Readiness und transaktionales Knowledge-Staging. Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi. -## Research → Human-Review-Staging (v1.4.5) +## Research → Human-Review-Staging (v1.5.0) Autonome Research-Goals können ihre quellengebundene Evidenz jetzt tatsächlich in einen **KB-Staging-Entwurf** überführen. Die Bridge ist einseitig: NeuroForge darf ausschließlich `POST /api/integrations/staging` mit dem separaten `KB_INTEGRATION_TOKEN` verwenden; `auto_reply=false` wird serverseitig erzwungen und Produktivwissen bleibt menschlich freigabepflichtig. @@ -43,8 +43,9 @@ cp .env.example .env ./scripts/generate-secrets.sh # Werte in .env übernehmen und GLPI-Zugangsdaten setzen. -docker compose config -docker compose up -d --build +./scripts/preflight.sh +docker compose pull +docker compose up -d --remove-orphans ./scripts/status.sh ``` @@ -80,7 +81,7 @@ Im produktionsnahen Standard (`NEUROFORGE_CONTROLLED_LEARNING=true`) werden rohe Ticket -> KI-Vorschlag -> Techniker bestätigt/korrigiert -> auditiertes Outcome -> NeuroForge lernt ``` -Im Agent-Dashboard kann ein Antwortvorschlag als **„KI-Antwort bestätigen“** oder **„KI-Antwort korrigieren“** validiert werden. Jede Entscheidung wird lokal in `ticket-outcomes.json` mit Sync-Status gespeichert. Eine spätere Korrektur überschreibt die frühere Entscheidung nicht, sondern erzeugt eine neue Revision mit `supersedes_id`. Nur `accepted` und `corrected` dürfen den App-Key-geschützten NeuroForge-Endpunkt `/api/v1/integrations/outcomes` verwenden; NeuroForge weist die vertrauenswürdige Provenance serverseitig zu. +Im Agent-Dashboard kann ein Antwortvorschlag als **„KI-Antwort bestätigen“** oder **„KI-Antwort korrigieren“** validiert werden. Jede Entscheidung wird lokal in `ticket-outcomes.json` mit Sync-Status gespeichert. Eine spätere Korrektur überschreibt die frühere Entscheidung nicht, sondern erzeugt eine neue Revision mit `supersedes_id`. Nur `accepted` und `corrected` dürfen den Integration-Token-geschützten NeuroForge-Endpunkt `/api/v1/integrations/outcomes` verwenden; NeuroForge weist die vertrauenswürdige Provenance serverseitig zu. Vor der Hochstufung verifiziert der Agent außerdem, dass sich der GLPI-Ticketzustand seit dem analysierten Run nicht geändert hat. Ein veralteter Run darf nicht als Trusted Outcome gelernt werden. diff --git a/RELEASE-NOTES-v1.5.0.md b/RELEASE-NOTES-v1.5.0.md new file mode 100644 index 0000000..ecf3a81 --- /dev/null +++ b/RELEASE-NOTES-v1.5.0.md @@ -0,0 +1,52 @@ +# GLPI NeuroForge Mega v1.5.0 + +v1.5.0 is a Stop-the-Line hardening release. It closes deployment, secret-isolation, authorization, readiness, staging-consistency and concurrent-goal gaps found during the v1.4.5 end-to-end review. + +## Security and trust boundaries + +- NeuroForge now has separate App, Integration, Control-Read, Worker, Metrics, Admin and optional Cluster credentials. +- Integration writes (`knowledge`, events, validated outcomes) accept only the Integration token or Admin token. +- Control-Read can read NeuroForge stats/graphs but cannot call `/learn` or integration writes. +- Environment-managed NeuroForge secrets cannot be pseudo-rotated through the Admin UI and silently revert after restart. +- Placeholder/short critical secrets are rejected by runtime validation and production preflight. +- Control Center has its own Basic Auth; `/healthz` stays public for container monitoring. +- Knowledge browser writes are protected by same-origin / `Sec-Fetch-Site` checks. +- Production Compose no longer uses `env_file`; Agent and Knowledge receive explicitly scoped environment variables only. + +## Research, goals and Knowledge staging + +- Research → progress → human-review staging no longer depends on semantic goal-summary learning. +- Draft evidence considers up to 200 Research runs and durable goal provenance. +- NeuroForge checks the Knowledge staging bridge through an authenticated health endpoint. +- Goal cycles are single-flight per Goal; a parallel duplicate receives `409 Conflict`. +- Promotion is transactional: if staging archival fails after production import, the new production article is rolled back. +- Atomic Knowledge/Staging namespace updates use directory sync and hardened rollback handling. + +## Runtime and deployment + +- Production `docker-compose.yml` is registry-only and requires immutable `${IMAGE_TAG}` for all six project images. +- `docker-compose.dev.yml` is the explicit local-source-build override. +- Worker token is environment-only and no longer appears in process arguments. +- Agent has a distroless-compatible binary healthcheck. +- NeuroForge readiness can live-check Ollama `/api/tags` and requires both configured chat and embedding models; Mega enables this by default. +- Knowledge and Control have graceful SIGTERM shutdown; Compose grace periods are aligned. +- Knowledge and Control no longer depend on NeuroForge availability; Agent no longer waits for NeuroForge health before starting. +- Agent's legacy Knowledge editor is disabled by default in Mega; `AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=true` is the explicit compatibility escape hatch. +- `AI_CONTENT_LABEL_ENABLED` is now actually loaded by the Agent configuration. +- All four project Dockerfiles run `go test ./...` and `go vet ./...` before producing runtime binaries. + +## Release operations + +- `scripts/preflight.sh`: no-`make` production validation, immutable image tag and secret checks. +- `scripts/go-live.sh`: preflight → registry pull → compose up. +- `scripts/backup-data.sh` / `scripts/restore-data.sh`: NeuroForge, Agent and Knowledge data backup/restore with SHA-256 manifest. +- `scripts/secret-scan.sh` and `scripts/release-gate.sh`: source security and Go test/vet/build/race gates. +- Host smoke procedure: `docs/GO-LIVE-v1.5.0.md`. + +## Required operator changes from v1.4.5 + +Create new unique values for `NEUROFORGE_INTEGRATION_TOKEN`, `NEUROFORGE_CONTROL_READ_TOKEN`, `CONTROL_BASIC_AUTH_USER` and `CONTROL_BASIC_AUTH_PASSWORD`; set a non-`latest` `IMAGE_TAG`; then run `./scripts/preflight.sh` before pulling/updating containers. See `docs/MIGRATION-v1.4.5-to-v1.5.0.md`. + +## External release gate + +This review environment has Go but no Docker daemon and no real GLPI/SearXNG/Ollama production services. Source, race, static deployment, patch and package checks are performed locally; the real registry pull/container/GLPI smoke test remains mandatory on the target host and is not claimed as locally passed. diff --git a/VERSION b/VERSION index e516bb9..bc80560 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.5 +1.5.0 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..88dcad2 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,30 @@ +# Local-development override. Production must use docker-compose.yml alone. +# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +services: + neuroforge: + build: + context: ./platform/neuroforge + target: server + pull_policy: never + neuroforge-worker: + build: + context: ./platform/neuroforge + target: worker + pull_policy: never + agent-data-init: + build: + context: ./services/agent + target: data-init + pull_policy: never + agent: + build: + context: ./services/agent + pull_policy: never + knowledge: + build: + context: ./services/knowledge + pull_policy: never + control: + build: + context: ./services/control + pull_policy: never diff --git a/docker-compose.yml b/docker-compose.yml index f51b041..40212ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,52 +1,58 @@ +# Production compose: registry-only. No local source build is permitted here. +# Use docker-compose.dev.yml as an override for local source builds. name: glpi-neuroforge-mega - services: ollama: image: ollama/ollama:latest restart: unless-stopped volumes: - - ollama-data:/root/.ollama + - ollama-data:/root/.ollama ports: - - "127.0.0.1:${OLLAMA_HOST_PORT:-11434}:11434" + - 127.0.0.1:${OLLAMA_HOST_PORT:-11434}:11434 security_opt: - - no-new-privileges:true - + - no-new-privileges:true searxng: image: ${SEARXNG_IMAGE:-docker.io/searxng/searxng:latest} - profiles: ["research"] + profiles: + - research restart: unless-stopped environment: SEARXNG_SECRET: ${SEARXNG_SECRET} SEARXNG_BASE_URL: http://searxng:8080/ - FORCE_OWNERSHIP: "false" + FORCE_OWNERSHIP: 'false' volumes: - - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro - - searxng-cache:/var/cache/searxng + - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro + - searxng-cache:/var/cache/searxng ports: - - "127.0.0.1:${SEARXNG_HOST_PORT:-8888}:8080" + - 127.0.0.1:${SEARXNG_HOST_PORT:-8888}:8080 read_only: true tmpfs: - - /tmp:size=64m,mode=1777 + - /tmp:size=64m,mode=1777 security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - + - no-new-privileges:true + cap_drop: + - ALL neuroforge: - build: - context: ./platform/neuroforge - target: server - command: ["-data", "/app/data", "-listen", ":8080"] + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + command: + - -data + - /app/data + - -listen + - :8080 restart: unless-stopped environment: - NEUROFORGE_ADMIN_TOKEN: ${NEUROFORGE_ADMIN_TOKEN} - NEUROFORGE_APP_API_KEY: ${NEUROFORGE_APP_API_KEY} - NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN} - NEUROFORGE_METRICS_TOKEN: ${NEUROFORGE_METRICS_TOKEN} + NEUROFORGE_ADMIN_TOKEN: ${NEUROFORGE_ADMIN_TOKEN:?Set a unique NeuroForge admin token} + NEUROFORGE_APP_API_KEY: ${NEUROFORGE_APP_API_KEY:?Set a unique NeuroForge app API key} + NEUROFORGE_INTEGRATION_TOKEN: ${NEUROFORGE_INTEGRATION_TOKEN:?Set a unique NeuroForge integration token} + NEUROFORGE_CONTROL_READ_TOKEN: ${NEUROFORGE_CONTROL_READ_TOKEN:?Set a unique NeuroForge control read token} + NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN:?Set a unique NeuroForge worker token} + NEUROFORGE_METRICS_TOKEN: ${NEUROFORGE_METRICS_TOKEN:?Set a unique NeuroForge metrics token} NEUROFORGE_CLUSTER_TOKEN: ${NEUROFORGE_CLUSTER_TOKEN:-} OPENAI_API_KEY: ${OPENAI_API_KEY:-} NEUROFORGE_OLLAMA_URL: http://ollama:11434 NEUROFORGE_OLLAMA_CHAT_MODEL: ${OLLAMA_MODEL:-gemma3} NEUROFORGE_OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma} + NEUROFORGE_READINESS_OLLAMA_LIVE: ${NEUROFORGE_READINESS_OLLAMA_LIVE:-true} NEUROFORGE_CONTROLLED_LEARNING: ${NEUROFORGE_CONTROLLED_LEARNING:-true} NEUROFORGE_GOAL_LEARNING_ENABLED: ${NEUROFORGE_GOAL_LEARNING_ENABLED:-false} NEUROFORGE_RESEARCH_ENABLED: ${NEUROFORGE_RESEARCH_ENABLED:-false} @@ -59,152 +65,345 @@ services: NEUROFORGE_RESEARCH_MAX_PAGES: ${NEUROFORGE_RESEARCH_MAX_PAGES:-4} NEUROFORGE_KB_STAGING_ENABLED: ${NEUROFORGE_KB_STAGING_ENABLED:-true} NEUROFORGE_KB_STAGING_URL: http://knowledge:8080/api/integrations/staging - NEUROFORGE_KB_STAGING_TOKEN: ${KB_INTEGRATION_TOKEN} + NEUROFORGE_KB_STAGING_TOKEN: ${KB_INTEGRATION_TOKEN:?Set the Knowledge integration token} NEUROFORGE_KB_STAGING_MIN_EVIDENCE: ${NEUROFORGE_KB_STAGING_MIN_EVIDENCE:-4} NEUROFORGE_KB_STAGING_MIN_SOURCES: ${NEUROFORGE_KB_STAGING_MIN_SOURCES:-2} NEUROFORGE_KB_STAGING_MIN_CORROBORATIONS: ${NEUROFORGE_KB_STAGING_MIN_CORROBORATIONS:-0} NEUROFORGE_KB_STAGING_MAX_EVIDENCE: ${NEUROFORGE_KB_STAGING_MAX_EVIDENCE:-12} ports: - - "127.0.0.1:${NEUROFORGE_HOST_PORT:-8090}:8080" + - 127.0.0.1:${NEUROFORGE_HOST_PORT:-8090}:8080 volumes: - - neuroforge-data:/app/data + - neuroforge-data:/app/data depends_on: - - ollama - read_only: true - tmpfs: - - /tmp:size=64m,mode=1777 - security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/readyz"] - interval: 15s - timeout: 3s - retries: 8 - start_period: 10s - - neuroforge-worker: - build: - context: ./platform/neuroforge - target: worker - command: ["-server", "http://neuroforge:8080", "-token", "${NEUROFORGE_WORKER_TOKEN}", "-id", "mega-worker-1"] - restart: unless-stopped - depends_on: - neuroforge: - condition: service_healthy - read_only: true - tmpfs: - - /tmp:size=32m,mode=1777 - security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - - agent-data-init: - build: - context: ./services/agent - target: data-init - restart: "no" - user: "0:0" - volumes: - - agent-data:/app/data - security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - cap_add: ["CHOWN", "FOWNER"] - - agent: - build: - context: ./services/agent - restart: unless-stopped - env_file: .env - environment: - # Internal container values are owned by Compose. This prevents legacy - # standalone .env values (for example HTTP_ADDR=:7080) from breaking - # service-to-service networking in the Mega stack. - HTTP_ADDR: :8080 - DATA_DIR: /app/data - KNOWLEDGE_DIR: /app/knowledge - OLLAMA_URL: http://ollama:11434 - KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual} - NEUROFORGE_URL: http://neuroforge:8080 - NEUROFORGE_API_KEY: ${NEUROFORGE_APP_API_KEY} - NEUROFORGE_NAMESPACE: ${NEUROFORGE_NAMESPACE:-glpi-agent} - NEUROFORGE_SEARCH_K: ${NEUROFORGE_SEARCH_K:-128} - NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true} - BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events - BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_APP_API_KEY} - OUTCOME_LEARNING_ENABLED: ${OUTCOME_LEARNING_ENABLED:-true} - OUTCOME_LEARNING_FAIL_OPEN: ${OUTCOME_LEARNING_FAIL_OPEN:-false} - OUTCOME_LEARNING_MAX_OUTCOMES: ${OUTCOME_LEARNING_MAX_OUTCOMES:-2000} - OUTCOME_RETRIEVAL_ENABLED: ${OUTCOME_RETRIEVAL_ENABLED:-true} - OUTCOME_RETRIEVAL_SEARCH_K: ${OUTCOME_RETRIEVAL_SEARCH_K:-6} - OUTCOME_RETRIEVAL_MIN_SIMILARITY: ${OUTCOME_RETRIEVAL_MIN_SIMILARITY:-0.58} - OUTCOME_RETRIEVAL_FAIL_OPEN: ${OUTCOME_RETRIEVAL_FAIL_OPEN:-true} - CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN} - ports: - - "127.0.0.1:${AGENT_HOST_PORT:-8080}:8080" - volumes: - - agent-data:/app/data - - ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro - depends_on: - agent-data-init: - condition: service_completed_successfully - neuroforge: - condition: service_healthy ollama: condition: service_started read_only: true tmpfs: - - /tmp:size=64m,mode=1777 + - /tmp:size=64m,mode=1777 security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - - knowledge: - build: - context: ./services/knowledge + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD + - wget + - -q + - -O + - '-' + - http://127.0.0.1:8080/readyz + interval: 15s + timeout: 3s + retries: 8 + start_period: 15s + stop_grace_period: 35s + neuroforge-worker: + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + command: + - -server + - http://neuroforge:8080 + - -id + - mega-worker-1 + restart: unless-stopped + environment: + NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN:?Set a unique NeuroForge worker token} + depends_on: + neuroforge: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=32m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + agent-data-init: + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + restart: 'no' + user: 0:0 + volumes: + - agent-data:/app/data + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER + agent: + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + restart: unless-stopped + environment: + AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-} + AUTO_CATEGORY: ${AUTO_CATEGORY:-} + AUTO_ESCALATION: ${AUTO_ESCALATION:-} + AUTO_PRIORITY: ${AUTO_PRIORITY:-} + AUTO_REPLY: ${AUTO_REPLY:-} + BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} + BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events + CATEGORY_CONFIDENCE: ${CATEGORY_CONFIDENCE:-} + CATEGORY_PROMPT_LIMIT: ${CATEGORY_PROMPT_LIMIT:-} + CHANGE_CALENDAR_ENABLED: ${CHANGE_CALENDAR_ENABLED:-} + CHANGE_LOOKAHEAD: ${CHANGE_LOOKAHEAD:-} + CHANGE_LOOKBACK: ${CHANGE_LOOKBACK:-} + COMMUNICATION_CLOSING: ${COMMUNICATION_CLOSING:-} + COMMUNICATION_LANGUAGE: ${COMMUNICATION_LANGUAGE:-} + COMMUNICATION_SALUTATION: ${COMMUNICATION_SALUTATION:-} + COMMUNICATION_SIGNATURE: ${COMMUNICATION_SIGNATURE:-} + COMMUNICATION_STYLE: ${COMMUNICATION_STYLE:-} + CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS: ${CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS:-} + CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT: ${CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT:-} + CONTEXT_ENABLED: ${CONTEXT_ENABLED:-} + CONTEXT_INCIDENT_REPLY_TEXT: ${CONTEXT_INCIDENT_REPLY_TEXT:-} + CONTEXT_MAINTENANCE_REPLY_TEXT: ${CONTEXT_MAINTENANCE_REPLY_TEXT:-} + CONTEXT_RELEVANCE_MIN_SCORE: ${CONTEXT_RELEVANCE_MIN_SCORE:-} + CONTEXT_STATUS_REPLY_ENABLED: ${CONTEXT_STATUS_REPLY_ENABLED:-} + CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE: ${CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE:-} + CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE: ${CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE:-} + CONTEXT_STATUS_REPLY_MIN_RELEVANCE: ${CONTEXT_STATUS_REPLY_MIN_RELEVANCE:-} + CONTEXT_TIMEOUT: ${CONTEXT_TIMEOUT:-} + CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN:?Set the Agent control read token} + DATA_DIR: /app/data + DRY_RUN: ${DRY_RUN:-} + ESCALATION_ADD_PRIVATE_FOLLOWUP: ${ESCALATION_ADD_PRIVATE_FOLLOWUP:-} + ESCALATION_ALLOWED_ACTIONS: ${ESCALATION_ALLOWED_ACTIONS:-} + ESCALATION_ALLOWED_REASON_CODES: ${ESCALATION_ALLOWED_REASON_CODES:-} + ESCALATION_ANALYSIS_TIMEOUT: ${ESCALATION_ANALYSIS_TIMEOUT:-} + ESCALATION_CONFIDENCE: ${ESCALATION_CONFIDENCE:-} + ESCALATION_ENABLED: ${ESCALATION_ENABLED:-} + ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE: ${ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE:-} + ESCALATION_MAJOR_INCIDENT_NOTE: ${ESCALATION_MAJOR_INCIDENT_NOTE:-} + ESCALATION_MANAGER_REVIEW_GROUP_ID: ${ESCALATION_MANAGER_REVIEW_GROUP_ID:-} + ESCALATION_MANAGER_REVIEW_MIN_LEVEL: ${ESCALATION_MANAGER_REVIEW_MIN_LEVEL:-} + ESCALATION_MANAGER_REVIEW_NOTE: ${ESCALATION_MANAGER_REVIEW_NOTE:-} + ESCALATION_MANAGER_REVIEW_USER_ID: ${ESCALATION_MANAGER_REVIEW_USER_ID:-} + ESCALATION_MAX_LEVEL: ${ESCALATION_MAX_LEVEL:-} + ESCALATION_MIN_AGE: ${ESCALATION_MIN_AGE:-} + ESCALATION_MIN_INACTIVITY: ${ESCALATION_MIN_INACTIVITY:-} + ESCALATION_SCAN_INTERVAL: ${ESCALATION_SCAN_INTERVAL:-} + ESCALATION_SECOND_LEVEL_GROUP_ID: ${ESCALATION_SECOND_LEVEL_GROUP_ID:-} + ESCALATION_SECOND_LEVEL_NOTE: ${ESCALATION_SECOND_LEVEL_NOTE:-} + ESCALATION_SECURITY_GROUP_ID: ${ESCALATION_SECURITY_GROUP_ID:-} + ESCALATION_SECURITY_NOTE: ${ESCALATION_SECURITY_NOTE:-} + ESCALATION_SERVICE_OWNER_GROUP_ID: ${ESCALATION_SERVICE_OWNER_GROUP_ID:-} + ESCALATION_SERVICE_OWNER_MIN_LEVEL: ${ESCALATION_SERVICE_OWNER_MIN_LEVEL:-} + ESCALATION_SERVICE_OWNER_NOTE: ${ESCALATION_SERVICE_OWNER_NOTE:-} + ESCALATION_SERVICE_OWNER_USER_ID: ${ESCALATION_SERVICE_OWNER_USER_ID:-} + ESCALATION_SLA_RISK_WINDOW: ${ESCALATION_SLA_RISK_WINDOW:-} + ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP: ${ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP:-} + ESCALATION_WEBHOOK_BEARER_TOKEN: ${ESCALATION_WEBHOOK_BEARER_TOKEN:-} + ESCALATION_WEBHOOK_TIMEOUT: ${ESCALATION_WEBHOOK_TIMEOUT:-} + ESCALATION_WEBHOOK_URL: ${ESCALATION_WEBHOOK_URL:-} + GLPI_AGENT_USER_ID: ${GLPI_AGENT_USER_ID:-} + GLPI_ALLOWED_STATUS_IDS: ${GLPI_ALLOWED_STATUS_IDS:-} + GLPI_ALLOW_INSECURE_HTTP: ${GLPI_ALLOW_INSECURE_HTTP:-} + GLPI_API_VERSION: ${GLPI_API_VERSION:-} + GLPI_CHANGE_FILTER: ${GLPI_CHANGE_FILTER:-} + GLPI_CHANGE_LIMIT: ${GLPI_CHANGE_LIMIT:-} + GLPI_CHANGE_PATH: ${GLPI_CHANGE_PATH:-} + GLPI_CLIENT_ID: ${GLPI_CLIENT_ID:-} + GLPI_CLIENT_SECRET: ${GLPI_CLIENT_SECRET:-} + GLPI_ESCALATION_FILTER: ${GLPI_ESCALATION_FILTER:-} + GLPI_ESCALATION_GROUP_PATCH_FIELD: ${GLPI_ESCALATION_GROUP_PATCH_FIELD:-} + GLPI_ESCALATION_ITIL_LINK_BODY: ${GLPI_ESCALATION_ITIL_LINK_BODY:-} + GLPI_ESCALATION_ITIL_LINK_PATH: ${GLPI_ESCALATION_ITIL_LINK_PATH:-} + GLPI_ESCALATION_LIMIT: ${GLPI_ESCALATION_LIMIT:-} + GLPI_ESCALATION_USER_PATCH_FIELD: ${GLPI_ESCALATION_USER_PATCH_FIELD:-} + GLPI_KB_AUTO_REPLY: ${GLPI_KB_AUTO_REPLY:-} + GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED: ${GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED:-} + GLPI_KB_AUTO_REPLY_CATEGORY_IDS: ${GLPI_KB_AUTO_REPLY_CATEGORY_IDS:-} + GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS: ${GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS:-} + GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS: ${GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS:-} + GLPI_KB_ENABLED: ${GLPI_KB_ENABLED:-} + GLPI_KB_FILTER: ${GLPI_KB_FILTER:-} + GLPI_KB_LIMIT: ${GLPI_KB_LIMIT:-} + GLPI_KB_PATH: ${GLPI_KB_PATH:-} + GLPI_KB_SOURCE: ${GLPI_KB_SOURCE:-} + GLPI_KB_SYNC_INTERVAL: ${GLPI_KB_SYNC_INTERVAL:-} + GLPI_MAJOR_INCIDENT_FILTER: ${GLPI_MAJOR_INCIDENT_FILTER:-} + GLPI_MAJOR_INCIDENT_LIMIT: ${GLPI_MAJOR_INCIDENT_LIMIT:-} + GLPI_PASSWORD: ${GLPI_PASSWORD:-} + GLPI_POLL_INTERVAL: ${GLPI_POLL_INTERVAL:-} + GLPI_POLL_LIMIT: ${GLPI_POLL_LIMIT:-} + GLPI_TICKET_FILTER: ${GLPI_TICKET_FILTER:-} + GLPI_TIMEOUT: ${GLPI_TIMEOUT:-} + GLPI_URL: ${GLPI_URL:-} + GLPI_USERNAME: ${GLPI_USERNAME:-} + GLPI_USER_DEVICE_FILTER_TEMPLATE: ${GLPI_USER_DEVICE_FILTER_TEMPLATE:-} + GLPI_USER_DEVICE_LIMIT: ${GLPI_USER_DEVICE_LIMIT:-} + GLPI_USER_DEVICE_PATHS: ${GLPI_USER_DEVICE_PATHS:-} + HTTP_ADDR: :8080 + KNOWLEDGE_ALLOWED_SOURCES: ${KNOWLEDGE_ALLOWED_SOURCES:-} + KNOWLEDGE_AUDIT_TOP_K: ${KNOWLEDGE_AUDIT_TOP_K:-} + KNOWLEDGE_AUTO_REPLY_SOURCES: ${KNOWLEDGE_AUTO_REPLY_SOURCES:-} + KNOWLEDGE_CANDIDATE_MAX_GAP: ${KNOWLEDGE_CANDIDATE_MAX_GAP:-} + KNOWLEDGE_CATEGORY_MAP_FILE: ${KNOWLEDGE_CATEGORY_MAP_FILE:-} + KNOWLEDGE_CATEGORY_MODE: ${KNOWLEDGE_CATEGORY_MODE:-} + KNOWLEDGE_CATEGORY_SOURCES: ${KNOWLEDGE_CATEGORY_SOURCES:-} + KNOWLEDGE_CHUNK_OVERLAP_WORDS: ${KNOWLEDGE_CHUNK_OVERLAP_WORDS:-} + KNOWLEDGE_CHUNK_WORDS: ${KNOWLEDGE_CHUNK_WORDS:-} + KNOWLEDGE_DIR: /app/knowledge + KNOWLEDGE_EMBEDDING_PROFILE: ${KNOWLEDGE_EMBEDDING_PROFILE:-} + KNOWLEDGE_EMBED_BATCH_SIZE: ${KNOWLEDGE_EMBED_BATCH_SIZE:-} + KNOWLEDGE_EVIDENCE_WEIGHT_AI: ${KNOWLEDGE_EVIDENCE_WEIGHT_AI:-} + KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY: ${KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY:-} + KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL: ${KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL:-} + KNOWLEDGE_IGNORE_GLOBS: ${KNOWLEDGE_IGNORE_GLOBS:-} + KNOWLEDGE_INDEX_MODE: ${KNOWLEDGE_INDEX_MODE:-} + KNOWLEDGE_INDEX_SCAN_INTERVAL: ${KNOWLEDGE_INDEX_SCAN_INTERVAL:-} + KNOWLEDGE_MAX_CHUNKS_PER_DOC: ${KNOWLEDGE_MAX_CHUNKS_PER_DOC:-} + KNOWLEDGE_MAX_QUERY_CHUNKS: ${KNOWLEDGE_MAX_QUERY_CHUNKS:-} + KNOWLEDGE_MIN_SCORE: ${KNOWLEDGE_MIN_SCORE:-} + KNOWLEDGE_RETRIEVAL_FLOOR: ${KNOWLEDGE_RETRIEVAL_FLOOR:-} + KNOWLEDGE_TOP_K: ${KNOWLEDGE_TOP_K:-} + KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual} + KNOWLEDGE_WEB_EDIT_ENABLED: ${AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED:-false} + KNOWLEDGE_WEIGHT_CATEGORY: ${KNOWLEDGE_WEIGHT_CATEGORY:-} + KNOWLEDGE_WEIGHT_KEYWORDS: ${KNOWLEDGE_WEIGHT_KEYWORDS:-} + KNOWLEDGE_WEIGHT_LEXICAL: ${KNOWLEDGE_WEIGHT_LEXICAL:-} + KNOWLEDGE_WEIGHT_SEMANTIC: ${KNOWLEDGE_WEIGHT_SEMANTIC:-} + KNOWLEDGE_WEIGHT_TITLE: ${KNOWLEDGE_WEIGHT_TITLE:-} + LEARNING_ENABLED: ${LEARNING_ENABLED:-} + LEARNING_EXAMPLES_PER_CATEGORY: ${LEARNING_EXAMPLES_PER_CATEGORY:-} + LEARNING_MAX_EXAMPLES: ${LEARNING_MAX_EXAMPLES:-} + LOG_LEVEL: ${LOG_LEVEL:-} + MAJOR_INCIDENTS_ENABLED: ${MAJOR_INCIDENTS_ENABLED:-} + NEUROFORGE_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} + NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true} + NEUROFORGE_NAMESPACE: ${NEUROFORGE_NAMESPACE:-glpi-agent} + NEUROFORGE_SEARCH_K: ${NEUROFORGE_SEARCH_K:-128} + NEUROFORGE_TIMEOUT: ${NEUROFORGE_TIMEOUT:-} + NEUROFORGE_URL: http://neuroforge:8080 + OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-} + OLLAMA_FAILOVER_ATTEMPTS: ${OLLAMA_FAILOVER_ATTEMPTS:-} + OLLAMA_FAILOVER_ENABLED: ${OLLAMA_FAILOVER_ENABLED:-} + OLLAMA_JSON_RETRIES: ${OLLAMA_JSON_RETRIES:-} + OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-} + OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-} + OLLAMA_MODEL: ${OLLAMA_MODEL:-} + OLLAMA_NODE_FAILURE_COOLDOWN: ${OLLAMA_NODE_FAILURE_COOLDOWN:-} + OLLAMA_NODE_HEALTH_INTERVAL: ${OLLAMA_NODE_HEALTH_INTERVAL:-} + OLLAMA_NODE_MAX_INFLIGHT: ${OLLAMA_NODE_MAX_INFLIGHT:-} + OLLAMA_NODE_NAMES: ${OLLAMA_NODE_NAMES:-} + OLLAMA_NODE_REQUEST_TIMEOUT: ${OLLAMA_NODE_REQUEST_TIMEOUT:-} + OLLAMA_NODE_WEIGHTS: ${OLLAMA_NODE_WEIGHTS:-} + OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-} + OLLAMA_REQUIRE_EMBEDDING_MODEL: ${OLLAMA_REQUIRE_EMBEDDING_MODEL:-} + OLLAMA_REQUIRE_SAME_MODEL_DIGEST: ${OLLAMA_REQUIRE_SAME_MODEL_DIGEST:-} + OLLAMA_ROUTING_MODE: ${OLLAMA_ROUTING_MODE:-} + OLLAMA_THINK: ${OLLAMA_THINK:-} + OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-} + OLLAMA_URL: http://ollama:11434 + OLLAMA_URLS: http://ollama:11434 + OUTCOME_LEARNING_ENABLED: ${OUTCOME_LEARNING_ENABLED:-true} + OUTCOME_LEARNING_FAIL_OPEN: ${OUTCOME_LEARNING_FAIL_OPEN:-false} + OUTCOME_LEARNING_MAX_OUTCOMES: ${OUTCOME_LEARNING_MAX_OUTCOMES:-2000} + OUTCOME_RETRIEVAL_ENABLED: ${OUTCOME_RETRIEVAL_ENABLED:-true} + OUTCOME_RETRIEVAL_FAIL_OPEN: ${OUTCOME_RETRIEVAL_FAIL_OPEN:-true} + OUTCOME_RETRIEVAL_MIN_SIMILARITY: ${OUTCOME_RETRIEVAL_MIN_SIMILARITY:-0.58} + OUTCOME_RETRIEVAL_SEARCH_K: ${OUTCOME_RETRIEVAL_SEARCH_K:-6} + PRIORITY_ALLOWED_REASON_CODES: ${PRIORITY_ALLOWED_REASON_CODES:-} + PRIORITY_ANALYSIS_TIMEOUT: ${PRIORITY_ANALYSIS_TIMEOUT:-} + PRIORITY_CONFIDENCE: ${PRIORITY_CONFIDENCE:-} + PRIORITY_ENABLED: ${PRIORITY_ENABLED:-} + PRIORITY_MAX_INCREASE: ${PRIORITY_MAX_INCREASE:-} + QUEUE_SIZE: ${QUEUE_SIZE:-} + RAG_ENABLED: ${RAG_ENABLED:-} + REPLY_CONFIDENCE: ${REPLY_CONFIDENCE:-} + UPTIME_KUMA_API_KEY: ${UPTIME_KUMA_API_KEY:-} + UPTIME_KUMA_ENABLED: ${UPTIME_KUMA_ENABLED:-} + UPTIME_KUMA_INCLUDE_MAINTENANCE: ${UPTIME_KUMA_INCLUDE_MAINTENANCE:-} + UPTIME_KUMA_MAX_ISSUES: ${UPTIME_KUMA_MAX_ISSUES:-} + UPTIME_KUMA_MODE: ${UPTIME_KUMA_MODE:-} + UPTIME_KUMA_STATUS_PAGES: ${UPTIME_KUMA_STATUS_PAGES:-} + UPTIME_KUMA_TIMEOUT: ${UPTIME_KUMA_TIMEOUT:-} + UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-} + USER_DEVICE_CONTEXT_ENABLED: ${USER_DEVICE_CONTEXT_ENABLED:-} + WEBHOOK_SECRET: ${WEBHOOK_SECRET:-} + WEB_ALLOW_ANONYMOUS: ${WEB_ALLOW_ANONYMOUS:-} + WEB_PASSWORD: ${WEB_PASSWORD:-} + WEB_USERNAME: ${WEB_USERNAME:-} + WORKERS: ${WORKERS:-} + ports: + - 127.0.0.1:${AGENT_HOST_PORT:-8080}:8080 + volumes: + - agent-data:/app/data + - ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro + depends_on: + agent-data-init: + condition: service_completed_successfully + ollama: + condition: service_started + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + healthcheck: + test: + - CMD + - /app/glpi-ai-agent + - healthcheck + interval: 15s + timeout: 3s + retries: 8 + start_period: 10s + stop_grace_period: 20s + knowledge: + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} restart: unless-stopped - env_file: .env environment: APP_MODE: ${KB_APP_MODE:-editor} DATA_DIR: /data/knowledge BACKUP_DIR: /data/backups STAGING_DIR: /data/staging LISTEN_ADDR: :8080 + APP_TITLE: ${APP_TITLE:-} + APP_SUBTITLE: ${APP_SUBTITLE:-} + AUTO_RELOAD_INTERVAL: ${AUTO_RELOAD_INTERVAL:-} + BASIC_AUTH_USER: ${BASIC_AUTH_USER:?Set the Knowledge web user} + BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD:?Set the Knowledge web password} + AI_FALLBACK_ENABLED: ${AI_FALLBACK_ENABLED:-false} OLLAMA_BASE_URL: http://ollama:11434 OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3} + OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-10m} + OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-2} + OLLAMA_STAGING_AUTO_REPLY: ${OLLAMA_STAGING_AUTO_REPLY:-false} + OLLAMA_STAGING_MIN_SCORE: ${OLLAMA_STAGING_MIN_SCORE:-0.72} BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events - BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_APP_API_KEY} - KB_INTEGRATION_TOKEN: ${KB_INTEGRATION_TOKEN} + BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} + KB_INTEGRATION_TOKEN: ${KB_INTEGRATION_TOKEN:?Set the Knowledge integration token} ports: - - "127.0.0.1:${KNOWLEDGE_HOST_PORT:-8081}:8080" + - 127.0.0.1:${KNOWLEDGE_HOST_PORT:-8081}:8080 volumes: - - ${KB_DATA_PATH:-./knowledge}:/data/knowledge:${KB_DATA_MOUNT_MODE:-rw} - - ${KB_STAGING_PATH:-./staging}:/data/staging:rw - - ${KB_BACKUP_PATH:-./backups}:/data/backups:rw + - ${KB_DATA_PATH:-./knowledge}:/data/knowledge:${KB_DATA_MOUNT_MODE:-rw} + - ${KB_STAGING_PATH:-./staging}:/data/staging:rw + - ${KB_BACKUP_PATH:-./backups}:/data/backups:rw depends_on: - neuroforge: - condition: service_healthy ollama: condition: service_started read_only: true tmpfs: - - /tmp:size=32m,mode=1777 + - /tmp:size=32m,mode=1777 security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] - + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 35s control: - build: - context: ./services/control + image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} restart: unless-stopped environment: CONTROL_ADDR: :8070 + CONTROL_BASIC_AUTH_USER: ${CONTROL_BASIC_AUTH_USER:?Set the Control Center user} + CONTROL_BASIC_AUTH_PASSWORD: ${CONTROL_BASIC_AUTH_PASSWORD:?Set the Control Center password} AGENT_URL: http://agent:8080 KNOWLEDGE_URL: http://knowledge:8080 NEUROFORGE_URL: http://neuroforge:8080 - NEUROFORGE_API_KEY: ${NEUROFORGE_APP_API_KEY} - CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN} + NEUROFORGE_API_KEY: ${NEUROFORGE_CONTROL_READ_TOKEN:?Set the NeuroForge control read token} + CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN:?Set the Agent control read token} CODEBASE_MEMORY_URL: ${CODEBASE_MEMORY_URL:-} PUBLIC_CODEBASE_MEMORY_URL: ${PUBLIC_CODEBASE_MEMORY_URL:-} KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual} @@ -225,25 +424,19 @@ services: PUBLIC_KNOWLEDGE_URL: http://localhost:${KNOWLEDGE_HOST_PORT:-8081} PUBLIC_NEUROFORGE_URL: http://localhost:${NEUROFORGE_HOST_PORT:-8090}/admin ports: - - "127.0.0.1:${CONTROL_HOST_PORT:-8070}:8070" - depends_on: - neuroforge: - condition: service_healthy - agent: - condition: service_started - knowledge: - condition: service_started + - 127.0.0.1:${CONTROL_HOST_PORT:-8070}:8070 read_only: true tmpfs: - - /tmp:size=16m,mode=1777 + - /tmp:size=16m,mode=1777 security_opt: - - no-new-privileges:true - cap_drop: ["ALL"] + - no-new-privileges:true + cap_drop: + - ALL extra_hosts: - - "host.docker.internal:host-gateway" - + - host.docker.internal:host-gateway + stop_grace_period: 25s volumes: - neuroforge-data: - agent-data: - ollama-data: - searxng-cache: + neuroforge-data: null + agent-data: null + ollama-data: null + searxng-cache: null diff --git a/docs/BACKUP-RESTORE-v1.5.0.md b/docs/BACKUP-RESTORE-v1.5.0.md new file mode 100644 index 0000000..d139f01 --- /dev/null +++ b/docs/BACKUP-RESTORE-v1.5.0.md @@ -0,0 +1,23 @@ +# Backup und Restore – v1.5.0 + +Die Release-Skripte sichern die drei zustandsbehafteten Produktbereiche konsistent während eines kurzen Maintenance-Stopps: NeuroForge `/app/data`, Agent `/app/data` sowie Knowledge-Produktion, Staging und Knowledge-Backups auf dem Host. + +## Backup + +```sh +./scripts/backup-data.sh /srv/glpi-neuroforge-backups +``` + +Das Skript führt zuerst den Produktions-Preflight aus, stoppt `neuroforge-worker`, `agent`, `neuroforge` und `knowledge`, kopiert die beiden Named Volumes sowie die drei Knowledge-Verzeichnisse und erzeugt `MANIFEST` plus `SHA256SUMS`. Danach werden die Services wieder gestartet. + +## Restore + +```sh +./scripts/restore-data.sh /srv/glpi-neuroforge-backups/20260827T071500Z +``` + +Restore validiert zuerst alle SHA-256-Prüfsummen, stoppt dieselben Services, leert die beiden Daten-Volumes kontrolliert über `busybox:1.36`, stellt alle Datenbereiche wieder her und startet die Services anschließend. `BACKUP_HELPER_IMAGE` kann auf ein intern freigegebenes Helper-Image gesetzt werden. + +## Pflichtprüfung nach Restore + +Nach jedem Restore müssen `docker compose ps`, NeuroForge `/readyz`, Knowledge `/api/health`, Agent `/readyz` und ein read-only Control-Center-Aufruf geprüft werden. GLPI-Schreibautomation bleibt bis zum erfolgreichen Smoke-Test deaktiviert (`DRY_RUN=true`, `AUTO_REPLY=false`, `AUTO_PRIORITY=false`, `AUTO_ESCALATION=false`). diff --git a/docs/CONTROL-MATRIX.md b/docs/CONTROL-MATRIX.md index 13af5fa..b3f3829 100644 --- a/docs/CONTROL-MATRIX.md +++ b/docs/CONTROL-MATRIX.md @@ -8,11 +8,11 @@ | Produktive KB schreiben | nein | ja | nein | nein | nein | | KB-Staging schreiben | nein | ja | über getrennten KB Integration Token möglich | nein | nein | | KB-Staging promoten | nein | ja | nein | nein | nein | -| Knowledge-Vektoren upserten | ja, App Key | nein | ja | ja | nein | -| Knowledge-Vektoren suchen | ja, App Key | nein | ja | ja | nein | +| Knowledge-Vektoren upserten | ja, Integration Token | nein | ja | ja | nein | +| Knowledge-Vektoren suchen | ja, Integration Token | nein | ja | ja | nein | | NeuroForge Config ändern | nein | nein | nein | ja | nein | | NeuroForge Secrets lesen/rotieren | nein | nein | nein | ja | nein | -| Systemstatus lesen | eigene Readiness | eigene Health | Stats mit App Key | ja | aggregiert read-only | +| Systemstatus lesen | eigene Readiness | eigene Health | Stats mit Control-Read-Token | ja | aggregiert read-only | | Obsidian-Export | Live-Sicht inkl. GLPI-Relations | kanonische KB | nein | nein | verlinkt Ziel-UI | | Human Outcome erfassen | ja, authentifizierter Techniker | nein | empfängt nur validated outcome | sichtbar/admin | Status read-only | | Trusted Outcome-Source setzen | nein | nein | **serverseitig fest** | ja | nein | @@ -22,11 +22,14 @@ ## Credentials - `NEUROFORGE_ADMIN_TOKEN`: nur Betreiber/Admin. -- `NEUROFORGE_APP_API_KEY`: Agent und read-only Control-Stats; keine Admin-Config. +- `NEUROFORGE_APP_API_KEY`: allgemeine App-API; keine Admin-Config und keine Integration-Schreibpfade. +- `NEUROFORGE_INTEGRATION_TOKEN`: ausschließlich Agent/Knowledge-Integration, Events und validierte Outcomes. +- `NEUROFORGE_CONTROL_READ_TOKEN`: ausschließlich read-only NeuroForge-Stats/Graph für das Control Center. - `NEUROFORGE_WORKER_TOKEN`: nur NeuroForge Worker. - `NEUROFORGE_METRICS_TOKEN`: nur Metrics-Scraper. - `KB_INTEGRATION_TOKEN`: ausschließlich maschineller Staging-Ingress. - `BASIC_AUTH_USER/PASSWORD`: Knowledgebase-Editor. +- `CONTROL_BASIC_AUTH_USER/PASSWORD`: Control Center; `/healthz` bleibt öffentlich. - `WEB_USERNAME/PASSWORD`: Agent-Webzugang. - `SEARXNG_SECRET`: nur optionaler SearXNG-Container/Betreiber. - GLPI-Credentials: ausschließlich Agent. @@ -65,19 +68,19 @@ Folgende Informationen bleiben absichtlich außerhalb des NeuroForge-Learnings: | Akteur | Outcome suchen | Outcome lernen | Outcome superseden | Quality Replay | Auto-Reply autorisieren | |---|---:|---:|---:|---:|---:| -| GLPI Agent App-Key | ja, nur aktives validated Outcome API | ja, accepted/corrected | indirekt nur über neue korrigierte Revision | nein über NeuroForge; eigener read-only Agent-Endpunkt | nur über bestehende Agent-Policies + freigegebene KB | +| GLPI Agent Integration-Token | ja, nur aktives validated Outcome API | ja, accepted/corrected | indirekt nur über neue korrigierte Revision | nein über NeuroForge; eigener read-only Agent-Endpunkt | nur über bestehende Agent-Policies + freigegebene KB | | Agent Web-Operator | indirekt sichtbar | explizit bestätigen/korrigieren | durch Korrektur | ja, authentifiziert/read-only | nicht durch Outcome allein | | NeuroForge Admin | technische Brain-Administration | technisch ja | technisch ja | nein | nein | | Control Center | Status/Konfiguration sichtbar | nein | nein | Verfügbarkeit sichtbar | nein | | Research/SearXNG | nein | Research-Evidence, nicht trusted outcome | nein | nein | nein | -`POST /api/v1/integrations/outcomes/search` akzeptiert den NeuroForge App-Key und liefert ausschließlich aktive Memories der serverseitig festgelegten Outcome-Provenance. Es ist kein generischer Memory-Search-Endpunkt und gewährt keine Admin-Funktionen. +`POST /api/v1/integrations/outcomes/search` akzeptiert ausschließlich den NeuroForge Integration-Token oder Admin-Token und liefert ausschließlich aktive Memories der serverseitig festgelegten Outcome-Provenance. Es ist kein generischer Memory-Search-Endpunkt und gewährt keine Admin-Funktionen. ### v1.4 graph scopes | Actor | Capability | Credential | Write authority | |---|---|---|---| | Control -> Agent | runs/evidence/learning graphs | `CONTROL_READ_TOKEN` | none | -| Control -> NeuroForge | research/brain graph | app API key | none through graph endpoints | +| Control -> NeuroForge | research/brain graph | `NEUROFORGE_CONTROL_READ_TOKEN` | none through graph endpoints | | Control -> embedded Engineering Graph | structural read | none/internal | none | | Optional Codebase Memory MCP | developer code analysis | local process / allowed root | none in platform | diff --git a/docs/ENV-MIGRATION-FROM-LEGACY.md b/docs/ENV-MIGRATION-FROM-LEGACY.md index b3ca7ad..20706f3 100644 --- a/docs/ENV-MIGRATION-FROM-LEGACY.md +++ b/docs/ENV-MIGRATION-FROM-LEGACY.md @@ -2,7 +2,7 @@ ## Ergebnis der Prüfung -Die 110 Variablennamen der bisherigen Agent-Konfiguration werden im aktuellen v1.4.x-Agenten +Die 110 Variablennamen der bisherigen Agent-Konfiguration werden im aktuellen v1.5.0-Agenten weiterhin verwendet. Das Problem lag nicht in entfernten Agent-Funktionen, sondern in der zu kurzen Top-Level-`.env.example` des Mega-Projekts: 86 dieser 110 bisherigen Variablen waren dort nicht explizit dokumentiert. @@ -20,7 +20,7 @@ Diese Werte sollten im Mega-Stack nicht zur Host-Konfiguration benutzt werden: | `KNOWLEDGE_DIR` | `/app/knowledge` | `KB_DATA_PATH` | | `OLLAMA_URL` | `http://ollama:11434` | Compose-Service `ollama` | | `NEUROFORGE_URL` | `http://neuroforge:8080` | Compose-Service `neuroforge` | -| `NEUROFORGE_API_KEY` | aus `NEUROFORGE_APP_API_KEY` | `NEUROFORGE_APP_API_KEY` | +| `NEUROFORGE_API_KEY` | aus `NEUROFORGE_INTEGRATION_TOKEN` | `NEUROFORGE_INTEGRATION_TOKEN` | | `BRAIN_ACTIVITY_URL` | NeuroForge Event API | intern verdrahtet | Compose überschreibt `HTTP_ADDR` jetzt explizit. Dadurch kann eine alte Standalone-Konfiguration @@ -50,7 +50,7 @@ Diese Unterschiede sind nicht automatisch falsch, müssen aber bewusst entschied Neu gegenüber der bisherigen Agent-Only-Konfiguration sind insbesondere: -- NeuroForge Admin/App/Worker/Metrics Tokens +- getrennte NeuroForge Admin/App/Integration/Control-Read/Worker/Metrics Tokens - `KNOWLEDGE_VECTOR_BACKEND=local|dual|neuroforge` - NeuroForge Namespace/Search/Failure Policy - Controlled Learning @@ -60,7 +60,7 @@ Neu gegenüber der bisherigen Agent-Only-Konfiguration sind insbesondere: - getrennte Autonomy-Aktivierung - Knowledge Integration Token - scoped `CONTROL_READ_TOKEN` -- Control Center / NeuroForge / Knowledge Host Ports +- Control-Center-Basic-Auth sowie Control Center / NeuroForge / Knowledge Host Ports - optionale Codebase-Memory-UI ## Empfohlene Migration diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 36d0c23..18ab436 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -1,4 +1,4 @@ -# Environment configuration (v1.4.x) +# Environment configuration (v1.5.0) The repository-level `.env.example` is the canonical configuration template for the Mega stack. It intentionally includes the complete GLPI Agent configuration plus NeuroForge, controlled-learning, @@ -14,8 +14,8 @@ The old variables are still supported, but container-internal values are now own - `KNOWLEDGE_DIR=/app/knowledge` - `OLLAMA_URL=http://ollama:11434` - `NEUROFORGE_URL=http://neuroforge:8080` -- `NEUROFORGE_API_KEY` is derived from `NEUROFORGE_APP_API_KEY` -- Brain-activity endpoints are wired internally by Compose +- `NEUROFORGE_API_KEY` is derived from `NEUROFORGE_INTEGRATION_TOKEN` +- Brain-activity endpoints are wired internally by Compose and use `NEUROFORGE_INTEGRATION_TOKEN` The host-facing ports are configured separately with `AGENT_HOST_PORT`, `KNOWLEDGE_HOST_PORT`, `CONTROL_HOST_PORT`, `NEUROFORGE_HOST_PORT`, `OLLAMA_HOST_PORT` and `SEARXNG_HOST_PORT`. @@ -64,7 +64,7 @@ Never commit `.env`. The tracked file must remain `.env.example` only. If credentials were pasted into issue trackers, chats, CI logs, shell history or screenshots, rotate them before production use. -## Research → Knowledge Staging (v1.4.5+) +## Research → Knowledge Staging (v1.5.0+) The autonomous research bridge is controlled independently from Research and Goal Learning: @@ -79,3 +79,7 @@ NEUROFORGE_KB_STAGING_MAX_EVIDENCE=12 `NEUROFORGE_KB_STAGING_URL` and `NEUROFORGE_KB_STAGING_TOKEN` are container-internal values owned by the root Compose file. The token is derived from the existing `KB_INTEGRATION_TOKEN`; do not duplicate it under a second operator-managed secret name. This bridge can only create/update **human-review staging**. The Knowledge service enforces `auto_reply=false` and does not expose production promotion through this integration token. + +## Production secret isolation + +The production Compose does not use `env_file`. Agent and Knowledge receive only explicit runtime variables. NeuroForge Admin/Worker/Metrics, Knowledge editor and Control Center credentials are therefore not broadly inherited by unrelated containers. Local source builds use the separate `docker-compose.dev.yml` override. diff --git a/docs/GO-LIVE-v1.5.0.md b/docs/GO-LIVE-v1.5.0.md new file mode 100644 index 0000000..08d3356 --- /dev/null +++ b/docs/GO-LIVE-v1.5.0.md @@ -0,0 +1,27 @@ +# Go-Live Gate – v1.5.0 + +## Registry und Start + +Auf dem Produktionshost wird **nicht gebaut**. Voraussetzung ist ein freigegebener, unveränderlicher `IMAGE_TAG` in `.env`. + +```sh +./scripts/preflight.sh +docker compose pull +docker compose up -d --remove-orphans +docker compose ps +``` + +Alternativ führt `./scripts/go-live.sh` genau diesen Ablauf ohne `make` aus. `IMAGE_TAG=latest`, Placeholder-Secrets, wiederverwendete Trust-Boundary-Tokens, `build:` oder `env_file:` im Produktions-Compose brechen den Preflight ab. + +## Pflicht-Smoke-Test auf dem echten Host + +1. Ollama enthält Chat- **und** Embedding-Modell (`gemma3`/konfiguriert und `embeddinggemma`/konfiguriert); NeuroForge `/readyz` muss 200 liefern. +2. Knowledge `/api/health` und Agent `/readyz` liefern 200; Control `/healthz` bleibt ohne Login erreichbar, alle Betriebs-/Graphseiten verlangen Control-Basic-Auth. +3. Agent kann mit dem Integration-Token Knowledge-Integration/Outcome-Pfade nutzen; der Control-Read-Token kann diese Schreibpfade nicht nutzen. +4. Einen Research-Goal manuell ausführen. Fortschritt und ein Staging-Draft müssen auch dann entstehen können, wenn `NEUROFORGE_GOAL_LEARNING_ENABLED=false` ist. +5. Einen zweiten parallelen Cycle desselben Goals auslösen; er muss `409 Conflict` erhalten. +6. Staging-Draft im Knowledge-Editor prüfen und promoten. Der Produktionsartikel erscheint genau einmal und der Draft wird archiviert. +7. NeuroForge stoppen: Knowledge und Control müssen weiterlaufen; Agent verhält sich entsprechend `KNOWLEDGE_VECTOR_BACKEND`/`NEUROFORGE_FAIL_OPEN`. +8. Vor GLPI-Schreibfreigabe einen vollständigen Ticketdurchlauf in `DRY_RUN=true` prüfen. Erst danach die gewünschten Automationen einzeln aktivieren. + +Docker, eine echte GLPI-Instanz, SearXNG und Ollama stehen in der Build-/Review-Umgebung nicht zur Verfügung; dieser Host-Smoke-Test ist deshalb ein bewusstes externes Release-Gate und darf nicht als lokal bestanden markiert werden. diff --git a/docs/MIGRATION-v1.4.5-to-v1.5.0.md b/docs/MIGRATION-v1.4.5-to-v1.5.0.md new file mode 100644 index 0000000..b0e1f7a --- /dev/null +++ b/docs/MIGRATION-v1.4.5-to-v1.5.0.md @@ -0,0 +1,45 @@ +# Migration v1.4.5 → v1.5.0 + +## 1. Back up v1.4.5 data + +Take an existing host backup before replacing the checkout. For future v1.5.0 backups use `scripts/backup-data.sh`. + +## 2. Replace/update configuration + +Start from the v1.5.0 `.env.example`; do not simply keep the old broad Compose environment behavior. Preserve reviewed Agent/GLPI policy values and add: + +```env +IMAGE_TAG=1.5.0 +NEUROFORGE_INTEGRATION_TOKEN= +NEUROFORGE_CONTROL_READ_TOKEN= +CONTROL_BASIC_AUTH_USER=admin +CONTROL_BASIC_AUTH_PASSWORD= +NEUROFORGE_READINESS_OLLAMA_LIVE=true +AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=false +``` + +Keep the pre-existing Admin/App/Worker/Metrics, Knowledge integration and Agent Control-Read tokens unique. `./scripts/generate-secrets.sh` emits all required new secret values. + +## 3. Understand credential remapping + +- Agent `NEUROFORGE_API_KEY` and Brain Activity use `NEUROFORGE_INTEGRATION_TOKEN` internally. +- Control Center uses `NEUROFORGE_CONTROL_READ_TOKEN` for NeuroForge and `CONTROL_READ_TOKEN` for Agent read-only endpoints. +- The general `NEUROFORGE_APP_API_KEY` no longer authorizes `/api/v1/integrations/*` writes. + +## 4. Deploy registry images only + +```sh +./scripts/preflight.sh +docker compose pull +docker compose up -d --remove-orphans +``` + +Do not run a production source build. Local development uses: + +```sh +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +``` + +## 5. Verify before enabling GLPI writes + +Follow every gate in `docs/GO-LIVE-v1.5.0.md`. In particular verify both Ollama models, Research→Staging with goal-summary learning disabled, duplicate-cycle `409`, promotion, degraded NeuroForge behavior, and a complete `DRY_RUN=true` GLPI ticket flow. diff --git a/docs/VALIDATION-v1.5.0.md b/docs/VALIDATION-v1.5.0.md new file mode 100644 index 0000000..ea07610 --- /dev/null +++ b/docs/VALIDATION-v1.5.0.md @@ -0,0 +1,34 @@ +# Validation – v1.5.0 + +This file records reproducible release gates. It deliberately distinguishes checks possible in the source-review environment from host-only Docker/GLPI checks. + +## Locally executed + +- Four Go modules: `go test ./...` — PASS. +- Four Go modules: `go vet ./...` — PASS. +- Four Go modules: `go build ./...` — PASS. +- Targeted race suites: NeuroForge Store/Brain/HTTP API, Agent state/knowledge/learning/core agent, Knowledge Store/Staging/Server, Control — PASS. +- NeuroForge live Ollama readiness regression: missing embedding model → 503; both configured models → 200 — PASS. +- Goal per-ID single-flight regression — PASS. +- Scoped-token trust-boundary regression — PASS. +- Environment-managed Admin-secret mutation regression — PASS. +- Knowledge browser same-origin regression — PASS. +- Knowledge promotion rollback regression — PASS. +- Control Center Basic Auth regression — PASS. +- Production Compose static gate: no project `build:`, no `env_file:`, exact six registry image families with `${IMAGE_TAG}` — PASS. +- Agent production environment secret-isolation check — PASS. +- Shell syntax (`sh -n`) and `git diff --check` — PASS. +- Engineering graph regeneration/idempotence — PASS. + +## Host-only, still mandatory + +The review environment does not provide Docker/Compose, GLPI, SearXNG or a production Ollama. Therefore the following are release-host gates rather than local PASS claims: + +- authenticated registry pull of the final immutable tag; +- `docker compose config`, `pull`, `up` and health state using the real `.env`; +- real Ollama model inventory and inference; +- GLPI read/write smoke flow; +- optional SearXNG research flow; +- backup/restore rehearsal against actual Docker volumes and host paths. + +Use `scripts/preflight.sh`, `scripts/go-live.sh` and `docs/GO-LIVE-v1.5.0.md` on the target host. diff --git a/mega-project.json b/mega-project.json index 4bbcbb4..62b23f5 100644 --- a/mega-project.json +++ b/mega-project.json @@ -28,7 +28,7 @@ "schema": "Wiki/Schema.md", "glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI" }, - "version": "1.4.5", + "version": "1.5.0", "controlled_learning": { "raw_chat_auto_learning": false, "validated_outcomes": [ diff --git a/patches/SHA256SUMS b/patches/SHA256SUMS index 7d64305..64d1fa4 100644 --- a/patches/SHA256SUMS +++ b/patches/SHA256SUMS @@ -9,3 +9,4 @@ f0491de3cb6201f98ca6be8e865237adbba4772471f7fc7165d030e0c045fdb6 patches/neurof 651c883facddfd0316f89e3cf3c24fd913371fcec2a5f447b8d105fa5dec463e patches/v1.4.2-to-v1.4.3.diff d90be604acf6ecf5d821890d5864307853caf0e0678a3249eeaa1d29748a6ada patches/v1.4.3-to-v1.4.4.diff 01ba8cb6ea6ab0abd84295fb969fcfb93d841cd46ae1813fb0de9b23b2d5b22e patches/v1.4.4-to-v1.4.5.diff +617b434dcdc0dd0be5cdb15740661f0a513b330b99835364b508e21467f86ae4 patches/v1.4.5-to-v1.5.0.diff diff --git a/patches/v1.4.5-to-v1.5.0.diff b/patches/v1.4.5-to-v1.5.0.diff new file mode 100644 index 0000000..4b79b2a --- /dev/null +++ b/patches/v1.4.5-to-v1.5.0.diff @@ -0,0 +1,7416 @@ +diff --git a/.env.example b/.env.example +index 3b527867826911ff262ef2259e5f8e26105522b2..6608ad592b5787d8b7500b8db65c061ce0da02a2 100644 +--- a/.env.example ++++ b/.env.example +@@ -1,5 +1,5 @@ + ############################################################################### +-# GLPI NEUROFORGE MEGA v1.4.x - VOLLSTÄNDIGE .ENV.example ++# GLPI NEUROFORGE MEGA v1.5.0 - VOLLSTÄNDIGE .ENV.example + # + # Diese Datei ist die zentrale Konfiguration für docker compose. + # Sie enthält: +@@ -26,8 +26,11 @@ + ############################################################################### + + ############################################################################### +-# 01. MEGA STACK - HOST PORTS / PFADE ++# 01. MEGA STACK - RELEASE / HOST PORTS / PFADE + ############################################################################### ++# Immutable Registry-Tag der sechs Projekt-Images. "latest" ist produktiv verboten. ++IMAGE_TAG=1.5.0 ++ + CONTROL_HOST_PORT=8070 + AGENT_HOST_PORT=8080 + KNOWLEDGE_HOST_PORT=8081 +@@ -47,12 +50,19 @@ KB_STAGING_PATH=./staging + # Erzeugen z. B. mit: ./scripts/generate-secrets.sh + NEUROFORGE_ADMIN_TOKEN=CHANGE_ME_ADMIN_TOKEN + NEUROFORGE_APP_API_KEY=CHANGE_ME_APP_API_KEY ++# Nur Agent/Knowledge Integrations- und Outcome-Schreibpfade. ++NEUROFORGE_INTEGRATION_TOKEN=CHANGE_ME_INTEGRATION_TOKEN_LONG ++# Nur NeuroForge Status/Graph-Lesezugriffe des Control Centers. ++NEUROFORGE_CONTROL_READ_TOKEN=CHANGE_ME_NF_CONTROL_READ_TOKEN_LONG + NEUROFORGE_WORKER_TOKEN=CHANGE_ME_WORKER_TOKEN + NEUROFORGE_METRICS_TOKEN=CHANGE_ME_METRICS_TOKEN + # Nur bei Clusterbetrieb erforderlich. + NEUROFORGE_CLUSTER_TOKEN= + KB_INTEGRATION_TOKEN=CHANGE_ME_KB_INTEGRATION_TOKEN +-CONTROL_READ_TOKEN=CHANGE_ME_CONTROL_READ_TOKEN ++CONTROL_READ_TOKEN=CHANGE_ME_AGENT_CONTROL_READ_TOKEN_LONG ++# Eigene Anmeldung des Control Centers; /healthz bleibt absichtlich öffentlich. ++CONTROL_BASIC_AUTH_USER=admin ++CONTROL_BASIC_AUTH_PASSWORD=CHANGE_ME_CONTROL_WEB_PASSWORD_LONG + # Optionaler externer OpenAI-Fallback. Leer = deaktiviert. + OPENAI_API_KEY= + +@@ -108,6 +118,8 @@ NEUROFORGE_CONTROLLED_LEARNING=true + # Separate gate for semantic goal-cycle memories. Required for manual/autonomous + # Goal cycles to learn while Controlled Learning remains enabled. + NEUROFORGE_GOAL_LEARNING_ENABLED=false ++# Mega-Readiness prüft Ollama /api/tags sowie Chat- und Embedding-Modell live. ++NEUROFORGE_READINESS_OLLAMA_LIVE=true + + # Ticket -> KI-Vorschlag -> Techniker bestätigt/korrigiert -> Trusted Outcome. + OUTCOME_LEARNING_ENABLED=true +@@ -538,7 +550,9 @@ KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb,vendor-docs,vendor-docs-ms,vend + # gespeichert. + # + # Das statische KNOWLEDGE_DIR bleibt read-only. +-KNOWLEDGE_WEB_EDIT_ENABLED=true ++KNOWLEDGE_WEB_EDIT_ENABLED=false ++# Nur Mega-Compose: expliziter Legacy-Schalter für den Agent-eigenen Editor. ++AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=false + ############################################################################### + # 19. GLPI KNOWLEDGE BASE CONNECTOR + ############################################################################### +diff --git a/Makefile b/Makefile +index f331ffd2f3c59511b2f1f2a16e534a52064873b6..ae3c4c0e4eb6e5961281906aa6467b39b81d3ec1 100644 +--- a/Makefile ++++ b/Makefile +@@ -1,6 +1,6 @@ + SHELL := /bin/sh + +-.PHONY: test vet build up research-up down logs status ps engineering-graph engineering-graph-check ++.PHONY: test vet build dev-up up preflight release-gate research-up down logs status ps engineering-graph engineering-graph-check + + test: + cd platform/neuroforge && go test ./... +@@ -15,10 +15,21 @@ vet: + cd services/control && go vet ./... + + build: +- docker compose build ++ docker compose -f docker-compose.yml -f docker-compose.dev.yml build ++ ++dev-up: ++ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build ++ ++preflight: ++ ./scripts/preflight.sh ++ ++release-gate: ++ ./scripts/release-gate.sh + + up: +- docker compose up -d --build ++ ./scripts/preflight.sh ++ docker compose pull ++ docker compose up -d --remove-orphans + + research-up: + ./scripts/research-up.sh +diff --git a/README.md b/README.md +index 503e67814e7ee8ae00986e1de2a4e6c1f582ee32..a31b5e06dc5f385aa25e56479badfabd4b452599 100644 +--- a/README.md ++++ b/README.md +@@ -1,11 +1,11 @@ +-# GLPI NeuroForge Mega v1.4.5 ++# GLPI NeuroForge Mega v1.5.0 + +-> Release: **v1.4.5** · korrigierter Research-Goal-Fortschritt und echte NeuroForge→Knowledge-Staging-Pipeline mit Human Review. ++> Release: **v1.5.0** · Stop-the-Line-Hardening für Trust Boundaries, Registry-Deployment, Readiness und transaktionales Knowledge-Staging. + + Ein kontrolliertes Monorepo aus **GLPI AI Agent**, **GLPI AI Knowledgebase** und **NeuroForge + SQAR**. Ziel ist nicht ein untrennbarer Monolith, sondern eine gemeinsame Plattform mit klaren Zuständigkeiten, getrennten Credentials und nachvollziehbaren Failure-Modi. + + +-## Research → Human-Review-Staging (v1.4.5) ++## Research → Human-Review-Staging (v1.5.0) + + Autonome Research-Goals können ihre quellengebundene Evidenz jetzt tatsächlich in einen **KB-Staging-Entwurf** überführen. Die Bridge ist einseitig: NeuroForge darf ausschließlich `POST /api/integrations/staging` mit dem separaten `KB_INTEGRATION_TOKEN` verwenden; `auto_reply=false` wird serverseitig erzwungen und Produktivwissen bleibt menschlich freigabepflichtig. + +@@ -43,8 +43,9 @@ cp .env.example .env + ./scripts/generate-secrets.sh + # Werte in .env übernehmen und GLPI-Zugangsdaten setzen. + +-docker compose config +-docker compose up -d --build ++./scripts/preflight.sh ++docker compose pull ++docker compose up -d --remove-orphans + ./scripts/status.sh + ``` + +@@ -80,7 +81,7 @@ Im produktionsnahen Standard (`NEUROFORGE_CONTROLLED_LEARNING=true`) werden rohe + Ticket -> KI-Vorschlag -> Techniker bestätigt/korrigiert -> auditiertes Outcome -> NeuroForge lernt + ``` + +-Im Agent-Dashboard kann ein Antwortvorschlag als **„KI-Antwort bestätigen“** oder **„KI-Antwort korrigieren“** validiert werden. Jede Entscheidung wird lokal in `ticket-outcomes.json` mit Sync-Status gespeichert. Eine spätere Korrektur überschreibt die frühere Entscheidung nicht, sondern erzeugt eine neue Revision mit `supersedes_id`. Nur `accepted` und `corrected` dürfen den App-Key-geschützten NeuroForge-Endpunkt `/api/v1/integrations/outcomes` verwenden; NeuroForge weist die vertrauenswürdige Provenance serverseitig zu. ++Im Agent-Dashboard kann ein Antwortvorschlag als **„KI-Antwort bestätigen“** oder **„KI-Antwort korrigieren“** validiert werden. Jede Entscheidung wird lokal in `ticket-outcomes.json` mit Sync-Status gespeichert. Eine spätere Korrektur überschreibt die frühere Entscheidung nicht, sondern erzeugt eine neue Revision mit `supersedes_id`. Nur `accepted` und `corrected` dürfen den Integration-Token-geschützten NeuroForge-Endpunkt `/api/v1/integrations/outcomes` verwenden; NeuroForge weist die vertrauenswürdige Provenance serverseitig zu. + + Vor der Hochstufung verifiziert der Agent außerdem, dass sich der GLPI-Ticketzustand seit dem analysierten Run nicht geändert hat. Ein veralteter Run darf nicht als Trusted Outcome gelernt werden. + +diff --git a/RELEASE-NOTES-v1.5.0.md b/RELEASE-NOTES-v1.5.0.md +new file mode 100644 +index 0000000000000000000000000000000000000000..ecf3a8196c48d2a58e4ad2e2481ab15d1f7409b0 +--- /dev/null ++++ b/RELEASE-NOTES-v1.5.0.md +@@ -0,0 +1,52 @@ ++# GLPI NeuroForge Mega v1.5.0 ++ ++v1.5.0 is a Stop-the-Line hardening release. It closes deployment, secret-isolation, authorization, readiness, staging-consistency and concurrent-goal gaps found during the v1.4.5 end-to-end review. ++ ++## Security and trust boundaries ++ ++- NeuroForge now has separate App, Integration, Control-Read, Worker, Metrics, Admin and optional Cluster credentials. ++- Integration writes (`knowledge`, events, validated outcomes) accept only the Integration token or Admin token. ++- Control-Read can read NeuroForge stats/graphs but cannot call `/learn` or integration writes. ++- Environment-managed NeuroForge secrets cannot be pseudo-rotated through the Admin UI and silently revert after restart. ++- Placeholder/short critical secrets are rejected by runtime validation and production preflight. ++- Control Center has its own Basic Auth; `/healthz` stays public for container monitoring. ++- Knowledge browser writes are protected by same-origin / `Sec-Fetch-Site` checks. ++- Production Compose no longer uses `env_file`; Agent and Knowledge receive explicitly scoped environment variables only. ++ ++## Research, goals and Knowledge staging ++ ++- Research → progress → human-review staging no longer depends on semantic goal-summary learning. ++- Draft evidence considers up to 200 Research runs and durable goal provenance. ++- NeuroForge checks the Knowledge staging bridge through an authenticated health endpoint. ++- Goal cycles are single-flight per Goal; a parallel duplicate receives `409 Conflict`. ++- Promotion is transactional: if staging archival fails after production import, the new production article is rolled back. ++- Atomic Knowledge/Staging namespace updates use directory sync and hardened rollback handling. ++ ++## Runtime and deployment ++ ++- Production `docker-compose.yml` is registry-only and requires immutable `${IMAGE_TAG}` for all six project images. ++- `docker-compose.dev.yml` is the explicit local-source-build override. ++- Worker token is environment-only and no longer appears in process arguments. ++- Agent has a distroless-compatible binary healthcheck. ++- NeuroForge readiness can live-check Ollama `/api/tags` and requires both configured chat and embedding models; Mega enables this by default. ++- Knowledge and Control have graceful SIGTERM shutdown; Compose grace periods are aligned. ++- Knowledge and Control no longer depend on NeuroForge availability; Agent no longer waits for NeuroForge health before starting. ++- Agent's legacy Knowledge editor is disabled by default in Mega; `AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=true` is the explicit compatibility escape hatch. ++- `AI_CONTENT_LABEL_ENABLED` is now actually loaded by the Agent configuration. ++- All four project Dockerfiles run `go test ./...` and `go vet ./...` before producing runtime binaries. ++ ++## Release operations ++ ++- `scripts/preflight.sh`: no-`make` production validation, immutable image tag and secret checks. ++- `scripts/go-live.sh`: preflight → registry pull → compose up. ++- `scripts/backup-data.sh` / `scripts/restore-data.sh`: NeuroForge, Agent and Knowledge data backup/restore with SHA-256 manifest. ++- `scripts/secret-scan.sh` and `scripts/release-gate.sh`: source security and Go test/vet/build/race gates. ++- Host smoke procedure: `docs/GO-LIVE-v1.5.0.md`. ++ ++## Required operator changes from v1.4.5 ++ ++Create new unique values for `NEUROFORGE_INTEGRATION_TOKEN`, `NEUROFORGE_CONTROL_READ_TOKEN`, `CONTROL_BASIC_AUTH_USER` and `CONTROL_BASIC_AUTH_PASSWORD`; set a non-`latest` `IMAGE_TAG`; then run `./scripts/preflight.sh` before pulling/updating containers. See `docs/MIGRATION-v1.4.5-to-v1.5.0.md`. ++ ++## External release gate ++ ++This review environment has Go but no Docker daemon and no real GLPI/SearXNG/Ollama production services. Source, race, static deployment, patch and package checks are performed locally; the real registry pull/container/GLPI smoke test remains mandatory on the target host and is not claimed as locally passed. +diff --git a/VERSION b/VERSION +index e516bb9d963a0cc79a2d530e45103ac21eac0643..bc80560fad66ca670bdfbd1e5c973a024d4d0325 100644 +--- a/VERSION ++++ b/VERSION +@@ -1 +1 @@ +-1.4.5 ++1.5.0 +diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml +new file mode 100644 +index 0000000000000000000000000000000000000000..88dcad272e2493f57209f3ddce9e2856a9ce57c9 +--- /dev/null ++++ b/docker-compose.dev.yml +@@ -0,0 +1,30 @@ ++# Local-development override. Production must use docker-compose.yml alone. ++# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ++services: ++ neuroforge: ++ build: ++ context: ./platform/neuroforge ++ target: server ++ pull_policy: never ++ neuroforge-worker: ++ build: ++ context: ./platform/neuroforge ++ target: worker ++ pull_policy: never ++ agent-data-init: ++ build: ++ context: ./services/agent ++ target: data-init ++ pull_policy: never ++ agent: ++ build: ++ context: ./services/agent ++ pull_policy: never ++ knowledge: ++ build: ++ context: ./services/knowledge ++ pull_policy: never ++ control: ++ build: ++ context: ./services/control ++ pull_policy: never +diff --git a/docker-compose.yml b/docker-compose.yml +index f51b041122b2e278f11049a15a320e34598aab48..40212ecbc4e2f45eec29a25dbf996efbd452d614 100644 +--- a/docker-compose.yml ++++ b/docker-compose.yml +@@ -1,52 +1,58 @@ ++# Production compose: registry-only. No local source build is permitted here. ++# Use docker-compose.dev.yml as an override for local source builds. + name: glpi-neuroforge-mega +- + services: + ollama: + image: ollama/ollama:latest + restart: unless-stopped + volumes: +- - ollama-data:/root/.ollama ++ - ollama-data:/root/.ollama + ports: +- - "127.0.0.1:${OLLAMA_HOST_PORT:-11434}:11434" ++ - 127.0.0.1:${OLLAMA_HOST_PORT:-11434}:11434 + security_opt: +- - no-new-privileges:true +- ++ - no-new-privileges:true + searxng: + image: ${SEARXNG_IMAGE:-docker.io/searxng/searxng:latest} +- profiles: ["research"] ++ profiles: ++ - research + restart: unless-stopped + environment: + SEARXNG_SECRET: ${SEARXNG_SECRET} + SEARXNG_BASE_URL: http://searxng:8080/ +- FORCE_OWNERSHIP: "false" ++ FORCE_OWNERSHIP: 'false' + volumes: +- - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro +- - searxng-cache:/var/cache/searxng ++ - ./deploy/searxng/settings.yml:/etc/searxng/settings.yml:ro ++ - searxng-cache:/var/cache/searxng + ports: +- - "127.0.0.1:${SEARXNG_HOST_PORT:-8888}:8080" ++ - 127.0.0.1:${SEARXNG_HOST_PORT:-8888}:8080 + read_only: true + tmpfs: +- - /tmp:size=64m,mode=1777 ++ - /tmp:size=64m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] +- ++ - no-new-privileges:true ++ cap_drop: ++ - ALL + neuroforge: +- build: +- context: ./platform/neuroforge +- target: server +- command: ["-data", "/app/data", "-listen", ":8080"] ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} ++ command: ++ - -data ++ - /app/data ++ - -listen ++ - :8080 + restart: unless-stopped + environment: +- NEUROFORGE_ADMIN_TOKEN: ${NEUROFORGE_ADMIN_TOKEN} +- NEUROFORGE_APP_API_KEY: ${NEUROFORGE_APP_API_KEY} +- NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN} +- NEUROFORGE_METRICS_TOKEN: ${NEUROFORGE_METRICS_TOKEN} ++ NEUROFORGE_ADMIN_TOKEN: ${NEUROFORGE_ADMIN_TOKEN:?Set a unique NeuroForge admin token} ++ NEUROFORGE_APP_API_KEY: ${NEUROFORGE_APP_API_KEY:?Set a unique NeuroForge app API key} ++ NEUROFORGE_INTEGRATION_TOKEN: ${NEUROFORGE_INTEGRATION_TOKEN:?Set a unique NeuroForge integration token} ++ NEUROFORGE_CONTROL_READ_TOKEN: ${NEUROFORGE_CONTROL_READ_TOKEN:?Set a unique NeuroForge control read token} ++ NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN:?Set a unique NeuroForge worker token} ++ NEUROFORGE_METRICS_TOKEN: ${NEUROFORGE_METRICS_TOKEN:?Set a unique NeuroForge metrics token} + NEUROFORGE_CLUSTER_TOKEN: ${NEUROFORGE_CLUSTER_TOKEN:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + NEUROFORGE_OLLAMA_URL: http://ollama:11434 + NEUROFORGE_OLLAMA_CHAT_MODEL: ${OLLAMA_MODEL:-gemma3} + NEUROFORGE_OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-embeddinggemma} ++ NEUROFORGE_READINESS_OLLAMA_LIVE: ${NEUROFORGE_READINESS_OLLAMA_LIVE:-true} + NEUROFORGE_CONTROLLED_LEARNING: ${NEUROFORGE_CONTROLLED_LEARNING:-true} + NEUROFORGE_GOAL_LEARNING_ENABLED: ${NEUROFORGE_GOAL_LEARNING_ENABLED:-false} + NEUROFORGE_RESEARCH_ENABLED: ${NEUROFORGE_RESEARCH_ENABLED:-false} +@@ -59,152 +65,345 @@ services: + NEUROFORGE_RESEARCH_MAX_PAGES: ${NEUROFORGE_RESEARCH_MAX_PAGES:-4} + NEUROFORGE_KB_STAGING_ENABLED: ${NEUROFORGE_KB_STAGING_ENABLED:-true} + NEUROFORGE_KB_STAGING_URL: http://knowledge:8080/api/integrations/staging +- NEUROFORGE_KB_STAGING_TOKEN: ${KB_INTEGRATION_TOKEN} ++ NEUROFORGE_KB_STAGING_TOKEN: ${KB_INTEGRATION_TOKEN:?Set the Knowledge integration token} + NEUROFORGE_KB_STAGING_MIN_EVIDENCE: ${NEUROFORGE_KB_STAGING_MIN_EVIDENCE:-4} + NEUROFORGE_KB_STAGING_MIN_SOURCES: ${NEUROFORGE_KB_STAGING_MIN_SOURCES:-2} + NEUROFORGE_KB_STAGING_MIN_CORROBORATIONS: ${NEUROFORGE_KB_STAGING_MIN_CORROBORATIONS:-0} + NEUROFORGE_KB_STAGING_MAX_EVIDENCE: ${NEUROFORGE_KB_STAGING_MAX_EVIDENCE:-12} + ports: +- - "127.0.0.1:${NEUROFORGE_HOST_PORT:-8090}:8080" ++ - 127.0.0.1:${NEUROFORGE_HOST_PORT:-8090}:8080 + volumes: +- - neuroforge-data:/app/data ++ - neuroforge-data:/app/data + depends_on: +- - ollama ++ ollama: ++ condition: service_started + read_only: true + tmpfs: +- - /tmp:size=64m,mode=1777 ++ - /tmp:size=64m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] ++ - no-new-privileges:true ++ cap_drop: ++ - ALL + healthcheck: +- test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/readyz"] ++ test: ++ - CMD ++ - wget ++ - -q ++ - -O ++ - '-' ++ - http://127.0.0.1:8080/readyz + interval: 15s + timeout: 3s + retries: 8 +- start_period: 10s +- ++ start_period: 15s ++ stop_grace_period: 35s + neuroforge-worker: +- build: +- context: ./platform/neuroforge +- target: worker +- command: ["-server", "http://neuroforge:8080", "-token", "${NEUROFORGE_WORKER_TOKEN}", "-id", "mega-worker-1"] ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} ++ command: ++ - -server ++ - http://neuroforge:8080 ++ - -id ++ - mega-worker-1 + restart: unless-stopped ++ environment: ++ NEUROFORGE_WORKER_TOKEN: ${NEUROFORGE_WORKER_TOKEN:?Set a unique NeuroForge worker token} + depends_on: + neuroforge: + condition: service_healthy + read_only: true + tmpfs: +- - /tmp:size=32m,mode=1777 ++ - /tmp:size=32m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] +- ++ - no-new-privileges:true ++ cap_drop: ++ - ALL + agent-data-init: +- build: +- context: ./services/agent +- target: data-init +- restart: "no" +- user: "0:0" ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} ++ restart: 'no' ++ user: 0:0 + volumes: +- - agent-data:/app/data ++ - agent-data:/app/data + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] +- cap_add: ["CHOWN", "FOWNER"] +- ++ - no-new-privileges:true ++ cap_drop: ++ - ALL ++ cap_add: ++ - CHOWN ++ - FOWNER + agent: +- build: +- context: ./services/agent ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + restart: unless-stopped +- env_file: .env + environment: +- # Internal container values are owned by Compose. This prevents legacy +- # standalone .env values (for example HTTP_ADDR=:7080) from breaking +- # service-to-service networking in the Mega stack. +- HTTP_ADDR: :8080 ++ AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-} ++ AUTO_CATEGORY: ${AUTO_CATEGORY:-} ++ AUTO_ESCALATION: ${AUTO_ESCALATION:-} ++ AUTO_PRIORITY: ${AUTO_PRIORITY:-} ++ AUTO_REPLY: ${AUTO_REPLY:-} ++ BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} ++ BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events ++ CATEGORY_CONFIDENCE: ${CATEGORY_CONFIDENCE:-} ++ CATEGORY_PROMPT_LIMIT: ${CATEGORY_PROMPT_LIMIT:-} ++ CHANGE_CALENDAR_ENABLED: ${CHANGE_CALENDAR_ENABLED:-} ++ CHANGE_LOOKAHEAD: ${CHANGE_LOOKAHEAD:-} ++ CHANGE_LOOKBACK: ${CHANGE_LOOKBACK:-} ++ COMMUNICATION_CLOSING: ${COMMUNICATION_CLOSING:-} ++ COMMUNICATION_LANGUAGE: ${COMMUNICATION_LANGUAGE:-} ++ COMMUNICATION_SALUTATION: ${COMMUNICATION_SALUTATION:-} ++ COMMUNICATION_SIGNATURE: ${COMMUNICATION_SIGNATURE:-} ++ COMMUNICATION_STYLE: ${COMMUNICATION_STYLE:-} ++ CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS: ${CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS:-} ++ CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT: ${CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT:-} ++ CONTEXT_ENABLED: ${CONTEXT_ENABLED:-} ++ CONTEXT_INCIDENT_REPLY_TEXT: ${CONTEXT_INCIDENT_REPLY_TEXT:-} ++ CONTEXT_MAINTENANCE_REPLY_TEXT: ${CONTEXT_MAINTENANCE_REPLY_TEXT:-} ++ CONTEXT_RELEVANCE_MIN_SCORE: ${CONTEXT_RELEVANCE_MIN_SCORE:-} ++ CONTEXT_STATUS_REPLY_ENABLED: ${CONTEXT_STATUS_REPLY_ENABLED:-} ++ CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE: ${CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE:-} ++ CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE: ${CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE:-} ++ CONTEXT_STATUS_REPLY_MIN_RELEVANCE: ${CONTEXT_STATUS_REPLY_MIN_RELEVANCE:-} ++ CONTEXT_TIMEOUT: ${CONTEXT_TIMEOUT:-} ++ CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN:?Set the Agent control read token} + DATA_DIR: /app/data ++ DRY_RUN: ${DRY_RUN:-} ++ ESCALATION_ADD_PRIVATE_FOLLOWUP: ${ESCALATION_ADD_PRIVATE_FOLLOWUP:-} ++ ESCALATION_ALLOWED_ACTIONS: ${ESCALATION_ALLOWED_ACTIONS:-} ++ ESCALATION_ALLOWED_REASON_CODES: ${ESCALATION_ALLOWED_REASON_CODES:-} ++ ESCALATION_ANALYSIS_TIMEOUT: ${ESCALATION_ANALYSIS_TIMEOUT:-} ++ ESCALATION_CONFIDENCE: ${ESCALATION_CONFIDENCE:-} ++ ESCALATION_ENABLED: ${ESCALATION_ENABLED:-} ++ ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE: ${ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE:-} ++ ESCALATION_MAJOR_INCIDENT_NOTE: ${ESCALATION_MAJOR_INCIDENT_NOTE:-} ++ ESCALATION_MANAGER_REVIEW_GROUP_ID: ${ESCALATION_MANAGER_REVIEW_GROUP_ID:-} ++ ESCALATION_MANAGER_REVIEW_MIN_LEVEL: ${ESCALATION_MANAGER_REVIEW_MIN_LEVEL:-} ++ ESCALATION_MANAGER_REVIEW_NOTE: ${ESCALATION_MANAGER_REVIEW_NOTE:-} ++ ESCALATION_MANAGER_REVIEW_USER_ID: ${ESCALATION_MANAGER_REVIEW_USER_ID:-} ++ ESCALATION_MAX_LEVEL: ${ESCALATION_MAX_LEVEL:-} ++ ESCALATION_MIN_AGE: ${ESCALATION_MIN_AGE:-} ++ ESCALATION_MIN_INACTIVITY: ${ESCALATION_MIN_INACTIVITY:-} ++ ESCALATION_SCAN_INTERVAL: ${ESCALATION_SCAN_INTERVAL:-} ++ ESCALATION_SECOND_LEVEL_GROUP_ID: ${ESCALATION_SECOND_LEVEL_GROUP_ID:-} ++ ESCALATION_SECOND_LEVEL_NOTE: ${ESCALATION_SECOND_LEVEL_NOTE:-} ++ ESCALATION_SECURITY_GROUP_ID: ${ESCALATION_SECURITY_GROUP_ID:-} ++ ESCALATION_SECURITY_NOTE: ${ESCALATION_SECURITY_NOTE:-} ++ ESCALATION_SERVICE_OWNER_GROUP_ID: ${ESCALATION_SERVICE_OWNER_GROUP_ID:-} ++ ESCALATION_SERVICE_OWNER_MIN_LEVEL: ${ESCALATION_SERVICE_OWNER_MIN_LEVEL:-} ++ ESCALATION_SERVICE_OWNER_NOTE: ${ESCALATION_SERVICE_OWNER_NOTE:-} ++ ESCALATION_SERVICE_OWNER_USER_ID: ${ESCALATION_SERVICE_OWNER_USER_ID:-} ++ ESCALATION_SLA_RISK_WINDOW: ${ESCALATION_SLA_RISK_WINDOW:-} ++ ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP: ${ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP:-} ++ ESCALATION_WEBHOOK_BEARER_TOKEN: ${ESCALATION_WEBHOOK_BEARER_TOKEN:-} ++ ESCALATION_WEBHOOK_TIMEOUT: ${ESCALATION_WEBHOOK_TIMEOUT:-} ++ ESCALATION_WEBHOOK_URL: ${ESCALATION_WEBHOOK_URL:-} ++ GLPI_AGENT_USER_ID: ${GLPI_AGENT_USER_ID:-} ++ GLPI_ALLOWED_STATUS_IDS: ${GLPI_ALLOWED_STATUS_IDS:-} ++ GLPI_ALLOW_INSECURE_HTTP: ${GLPI_ALLOW_INSECURE_HTTP:-} ++ GLPI_API_VERSION: ${GLPI_API_VERSION:-} ++ GLPI_CHANGE_FILTER: ${GLPI_CHANGE_FILTER:-} ++ GLPI_CHANGE_LIMIT: ${GLPI_CHANGE_LIMIT:-} ++ GLPI_CHANGE_PATH: ${GLPI_CHANGE_PATH:-} ++ GLPI_CLIENT_ID: ${GLPI_CLIENT_ID:-} ++ GLPI_CLIENT_SECRET: ${GLPI_CLIENT_SECRET:-} ++ GLPI_ESCALATION_FILTER: ${GLPI_ESCALATION_FILTER:-} ++ GLPI_ESCALATION_GROUP_PATCH_FIELD: ${GLPI_ESCALATION_GROUP_PATCH_FIELD:-} ++ GLPI_ESCALATION_ITIL_LINK_BODY: ${GLPI_ESCALATION_ITIL_LINK_BODY:-} ++ GLPI_ESCALATION_ITIL_LINK_PATH: ${GLPI_ESCALATION_ITIL_LINK_PATH:-} ++ GLPI_ESCALATION_LIMIT: ${GLPI_ESCALATION_LIMIT:-} ++ GLPI_ESCALATION_USER_PATCH_FIELD: ${GLPI_ESCALATION_USER_PATCH_FIELD:-} ++ GLPI_KB_AUTO_REPLY: ${GLPI_KB_AUTO_REPLY:-} ++ GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED: ${GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED:-} ++ GLPI_KB_AUTO_REPLY_CATEGORY_IDS: ${GLPI_KB_AUTO_REPLY_CATEGORY_IDS:-} ++ GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS: ${GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS:-} ++ GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS: ${GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS:-} ++ GLPI_KB_ENABLED: ${GLPI_KB_ENABLED:-} ++ GLPI_KB_FILTER: ${GLPI_KB_FILTER:-} ++ GLPI_KB_LIMIT: ${GLPI_KB_LIMIT:-} ++ GLPI_KB_PATH: ${GLPI_KB_PATH:-} ++ GLPI_KB_SOURCE: ${GLPI_KB_SOURCE:-} ++ GLPI_KB_SYNC_INTERVAL: ${GLPI_KB_SYNC_INTERVAL:-} ++ GLPI_MAJOR_INCIDENT_FILTER: ${GLPI_MAJOR_INCIDENT_FILTER:-} ++ GLPI_MAJOR_INCIDENT_LIMIT: ${GLPI_MAJOR_INCIDENT_LIMIT:-} ++ GLPI_PASSWORD: ${GLPI_PASSWORD:-} ++ GLPI_POLL_INTERVAL: ${GLPI_POLL_INTERVAL:-} ++ GLPI_POLL_LIMIT: ${GLPI_POLL_LIMIT:-} ++ GLPI_TICKET_FILTER: ${GLPI_TICKET_FILTER:-} ++ GLPI_TIMEOUT: ${GLPI_TIMEOUT:-} ++ GLPI_URL: ${GLPI_URL:-} ++ GLPI_USERNAME: ${GLPI_USERNAME:-} ++ GLPI_USER_DEVICE_FILTER_TEMPLATE: ${GLPI_USER_DEVICE_FILTER_TEMPLATE:-} ++ GLPI_USER_DEVICE_LIMIT: ${GLPI_USER_DEVICE_LIMIT:-} ++ GLPI_USER_DEVICE_PATHS: ${GLPI_USER_DEVICE_PATHS:-} ++ HTTP_ADDR: :8080 ++ KNOWLEDGE_ALLOWED_SOURCES: ${KNOWLEDGE_ALLOWED_SOURCES:-} ++ KNOWLEDGE_AUDIT_TOP_K: ${KNOWLEDGE_AUDIT_TOP_K:-} ++ KNOWLEDGE_AUTO_REPLY_SOURCES: ${KNOWLEDGE_AUTO_REPLY_SOURCES:-} ++ KNOWLEDGE_CANDIDATE_MAX_GAP: ${KNOWLEDGE_CANDIDATE_MAX_GAP:-} ++ KNOWLEDGE_CATEGORY_MAP_FILE: ${KNOWLEDGE_CATEGORY_MAP_FILE:-} ++ KNOWLEDGE_CATEGORY_MODE: ${KNOWLEDGE_CATEGORY_MODE:-} ++ KNOWLEDGE_CATEGORY_SOURCES: ${KNOWLEDGE_CATEGORY_SOURCES:-} ++ KNOWLEDGE_CHUNK_OVERLAP_WORDS: ${KNOWLEDGE_CHUNK_OVERLAP_WORDS:-} ++ KNOWLEDGE_CHUNK_WORDS: ${KNOWLEDGE_CHUNK_WORDS:-} + KNOWLEDGE_DIR: /app/knowledge +- OLLAMA_URL: http://ollama:11434 ++ KNOWLEDGE_EMBEDDING_PROFILE: ${KNOWLEDGE_EMBEDDING_PROFILE:-} ++ KNOWLEDGE_EMBED_BATCH_SIZE: ${KNOWLEDGE_EMBED_BATCH_SIZE:-} ++ KNOWLEDGE_EVIDENCE_WEIGHT_AI: ${KNOWLEDGE_EVIDENCE_WEIGHT_AI:-} ++ KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY: ${KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY:-} ++ KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL: ${KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL:-} ++ KNOWLEDGE_IGNORE_GLOBS: ${KNOWLEDGE_IGNORE_GLOBS:-} ++ KNOWLEDGE_INDEX_MODE: ${KNOWLEDGE_INDEX_MODE:-} ++ KNOWLEDGE_INDEX_SCAN_INTERVAL: ${KNOWLEDGE_INDEX_SCAN_INTERVAL:-} ++ KNOWLEDGE_MAX_CHUNKS_PER_DOC: ${KNOWLEDGE_MAX_CHUNKS_PER_DOC:-} ++ KNOWLEDGE_MAX_QUERY_CHUNKS: ${KNOWLEDGE_MAX_QUERY_CHUNKS:-} ++ KNOWLEDGE_MIN_SCORE: ${KNOWLEDGE_MIN_SCORE:-} ++ KNOWLEDGE_RETRIEVAL_FLOOR: ${KNOWLEDGE_RETRIEVAL_FLOOR:-} ++ KNOWLEDGE_TOP_K: ${KNOWLEDGE_TOP_K:-} + KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual} +- NEUROFORGE_URL: http://neuroforge:8080 +- NEUROFORGE_API_KEY: ${NEUROFORGE_APP_API_KEY} ++ KNOWLEDGE_WEB_EDIT_ENABLED: ${AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED:-false} ++ KNOWLEDGE_WEIGHT_CATEGORY: ${KNOWLEDGE_WEIGHT_CATEGORY:-} ++ KNOWLEDGE_WEIGHT_KEYWORDS: ${KNOWLEDGE_WEIGHT_KEYWORDS:-} ++ KNOWLEDGE_WEIGHT_LEXICAL: ${KNOWLEDGE_WEIGHT_LEXICAL:-} ++ KNOWLEDGE_WEIGHT_SEMANTIC: ${KNOWLEDGE_WEIGHT_SEMANTIC:-} ++ KNOWLEDGE_WEIGHT_TITLE: ${KNOWLEDGE_WEIGHT_TITLE:-} ++ LEARNING_ENABLED: ${LEARNING_ENABLED:-} ++ LEARNING_EXAMPLES_PER_CATEGORY: ${LEARNING_EXAMPLES_PER_CATEGORY:-} ++ LEARNING_MAX_EXAMPLES: ${LEARNING_MAX_EXAMPLES:-} ++ LOG_LEVEL: ${LOG_LEVEL:-} ++ MAJOR_INCIDENTS_ENABLED: ${MAJOR_INCIDENTS_ENABLED:-} ++ NEUROFORGE_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} ++ NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true} + NEUROFORGE_NAMESPACE: ${NEUROFORGE_NAMESPACE:-glpi-agent} + NEUROFORGE_SEARCH_K: ${NEUROFORGE_SEARCH_K:-128} +- NEUROFORGE_FAIL_OPEN: ${NEUROFORGE_FAIL_OPEN:-true} +- BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events +- BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_APP_API_KEY} ++ NEUROFORGE_TIMEOUT: ${NEUROFORGE_TIMEOUT:-} ++ NEUROFORGE_URL: http://neuroforge:8080 ++ OLLAMA_EMBEDDING_MODEL: ${OLLAMA_EMBEDDING_MODEL:-} ++ OLLAMA_FAILOVER_ATTEMPTS: ${OLLAMA_FAILOVER_ATTEMPTS:-} ++ OLLAMA_FAILOVER_ENABLED: ${OLLAMA_FAILOVER_ENABLED:-} ++ OLLAMA_JSON_RETRIES: ${OLLAMA_JSON_RETRIES:-} ++ OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-} ++ OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-} ++ OLLAMA_MODEL: ${OLLAMA_MODEL:-} ++ OLLAMA_NODE_FAILURE_COOLDOWN: ${OLLAMA_NODE_FAILURE_COOLDOWN:-} ++ OLLAMA_NODE_HEALTH_INTERVAL: ${OLLAMA_NODE_HEALTH_INTERVAL:-} ++ OLLAMA_NODE_MAX_INFLIGHT: ${OLLAMA_NODE_MAX_INFLIGHT:-} ++ OLLAMA_NODE_NAMES: ${OLLAMA_NODE_NAMES:-} ++ OLLAMA_NODE_REQUEST_TIMEOUT: ${OLLAMA_NODE_REQUEST_TIMEOUT:-} ++ OLLAMA_NODE_WEIGHTS: ${OLLAMA_NODE_WEIGHTS:-} ++ OLLAMA_NUM_PREDICT: ${OLLAMA_NUM_PREDICT:-} ++ OLLAMA_REQUIRE_EMBEDDING_MODEL: ${OLLAMA_REQUIRE_EMBEDDING_MODEL:-} ++ OLLAMA_REQUIRE_SAME_MODEL_DIGEST: ${OLLAMA_REQUIRE_SAME_MODEL_DIGEST:-} ++ OLLAMA_ROUTING_MODE: ${OLLAMA_ROUTING_MODE:-} ++ OLLAMA_THINK: ${OLLAMA_THINK:-} ++ OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-} ++ OLLAMA_URL: http://ollama:11434 ++ OLLAMA_URLS: http://ollama:11434 + OUTCOME_LEARNING_ENABLED: ${OUTCOME_LEARNING_ENABLED:-true} + OUTCOME_LEARNING_FAIL_OPEN: ${OUTCOME_LEARNING_FAIL_OPEN:-false} + OUTCOME_LEARNING_MAX_OUTCOMES: ${OUTCOME_LEARNING_MAX_OUTCOMES:-2000} + OUTCOME_RETRIEVAL_ENABLED: ${OUTCOME_RETRIEVAL_ENABLED:-true} +- OUTCOME_RETRIEVAL_SEARCH_K: ${OUTCOME_RETRIEVAL_SEARCH_K:-6} +- OUTCOME_RETRIEVAL_MIN_SIMILARITY: ${OUTCOME_RETRIEVAL_MIN_SIMILARITY:-0.58} + OUTCOME_RETRIEVAL_FAIL_OPEN: ${OUTCOME_RETRIEVAL_FAIL_OPEN:-true} +- CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN} ++ OUTCOME_RETRIEVAL_MIN_SIMILARITY: ${OUTCOME_RETRIEVAL_MIN_SIMILARITY:-0.58} ++ OUTCOME_RETRIEVAL_SEARCH_K: ${OUTCOME_RETRIEVAL_SEARCH_K:-6} ++ PRIORITY_ALLOWED_REASON_CODES: ${PRIORITY_ALLOWED_REASON_CODES:-} ++ PRIORITY_ANALYSIS_TIMEOUT: ${PRIORITY_ANALYSIS_TIMEOUT:-} ++ PRIORITY_CONFIDENCE: ${PRIORITY_CONFIDENCE:-} ++ PRIORITY_ENABLED: ${PRIORITY_ENABLED:-} ++ PRIORITY_MAX_INCREASE: ${PRIORITY_MAX_INCREASE:-} ++ QUEUE_SIZE: ${QUEUE_SIZE:-} ++ RAG_ENABLED: ${RAG_ENABLED:-} ++ REPLY_CONFIDENCE: ${REPLY_CONFIDENCE:-} ++ UPTIME_KUMA_API_KEY: ${UPTIME_KUMA_API_KEY:-} ++ UPTIME_KUMA_ENABLED: ${UPTIME_KUMA_ENABLED:-} ++ UPTIME_KUMA_INCLUDE_MAINTENANCE: ${UPTIME_KUMA_INCLUDE_MAINTENANCE:-} ++ UPTIME_KUMA_MAX_ISSUES: ${UPTIME_KUMA_MAX_ISSUES:-} ++ UPTIME_KUMA_MODE: ${UPTIME_KUMA_MODE:-} ++ UPTIME_KUMA_STATUS_PAGES: ${UPTIME_KUMA_STATUS_PAGES:-} ++ UPTIME_KUMA_TIMEOUT: ${UPTIME_KUMA_TIMEOUT:-} ++ UPTIME_KUMA_URL: ${UPTIME_KUMA_URL:-} ++ USER_DEVICE_CONTEXT_ENABLED: ${USER_DEVICE_CONTEXT_ENABLED:-} ++ WEBHOOK_SECRET: ${WEBHOOK_SECRET:-} ++ WEB_ALLOW_ANONYMOUS: ${WEB_ALLOW_ANONYMOUS:-} ++ WEB_PASSWORD: ${WEB_PASSWORD:-} ++ WEB_USERNAME: ${WEB_USERNAME:-} ++ WORKERS: ${WORKERS:-} + ports: +- - "127.0.0.1:${AGENT_HOST_PORT:-8080}:8080" ++ - 127.0.0.1:${AGENT_HOST_PORT:-8080}:8080 + volumes: +- - agent-data:/app/data +- - ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro ++ - agent-data:/app/data ++ - ${KB_DATA_PATH:-./knowledge}:/app/knowledge:ro + depends_on: + agent-data-init: + condition: service_completed_successfully +- neuroforge: +- condition: service_healthy + ollama: + condition: service_started + read_only: true + tmpfs: +- - /tmp:size=64m,mode=1777 ++ - /tmp:size=64m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] +- ++ - no-new-privileges:true ++ cap_drop: ++ - ALL ++ healthcheck: ++ test: ++ - CMD ++ - /app/glpi-ai-agent ++ - healthcheck ++ interval: 15s ++ timeout: 3s ++ retries: 8 ++ start_period: 10s ++ stop_grace_period: 20s + knowledge: +- build: +- context: ./services/knowledge ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + restart: unless-stopped +- env_file: .env + environment: + APP_MODE: ${KB_APP_MODE:-editor} + DATA_DIR: /data/knowledge + BACKUP_DIR: /data/backups + STAGING_DIR: /data/staging + LISTEN_ADDR: :8080 ++ APP_TITLE: ${APP_TITLE:-} ++ APP_SUBTITLE: ${APP_SUBTITLE:-} ++ AUTO_RELOAD_INTERVAL: ${AUTO_RELOAD_INTERVAL:-} ++ BASIC_AUTH_USER: ${BASIC_AUTH_USER:?Set the Knowledge web user} ++ BASIC_AUTH_PASSWORD: ${BASIC_AUTH_PASSWORD:?Set the Knowledge web password} ++ AI_FALLBACK_ENABLED: ${AI_FALLBACK_ENABLED:-false} + OLLAMA_BASE_URL: http://ollama:11434 + OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3} ++ OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT:-10m} ++ OLLAMA_MAX_CONCURRENT: ${OLLAMA_MAX_CONCURRENT:-2} ++ OLLAMA_STAGING_AUTO_REPLY: ${OLLAMA_STAGING_AUTO_REPLY:-false} ++ OLLAMA_STAGING_MIN_SCORE: ${OLLAMA_STAGING_MIN_SCORE:-0.72} + BRAIN_ACTIVITY_URL: http://neuroforge:8080/api/v1/integrations/events +- BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_APP_API_KEY} +- KB_INTEGRATION_TOKEN: ${KB_INTEGRATION_TOKEN} ++ BRAIN_ACTIVITY_API_KEY: ${NEUROFORGE_INTEGRATION_TOKEN:?Set the NeuroForge integration token} ++ KB_INTEGRATION_TOKEN: ${KB_INTEGRATION_TOKEN:?Set the Knowledge integration token} + ports: +- - "127.0.0.1:${KNOWLEDGE_HOST_PORT:-8081}:8080" ++ - 127.0.0.1:${KNOWLEDGE_HOST_PORT:-8081}:8080 + volumes: +- - ${KB_DATA_PATH:-./knowledge}:/data/knowledge:${KB_DATA_MOUNT_MODE:-rw} +- - ${KB_STAGING_PATH:-./staging}:/data/staging:rw +- - ${KB_BACKUP_PATH:-./backups}:/data/backups:rw ++ - ${KB_DATA_PATH:-./knowledge}:/data/knowledge:${KB_DATA_MOUNT_MODE:-rw} ++ - ${KB_STAGING_PATH:-./staging}:/data/staging:rw ++ - ${KB_BACKUP_PATH:-./backups}:/data/backups:rw + depends_on: +- neuroforge: +- condition: service_healthy + ollama: + condition: service_started + read_only: true + tmpfs: +- - /tmp:size=32m,mode=1777 ++ - /tmp:size=32m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] +- ++ - no-new-privileges:true ++ cap_drop: ++ - ALL ++ stop_grace_period: 35s + control: +- build: +- context: ./services/control ++ image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0} + restart: unless-stopped + environment: + CONTROL_ADDR: :8070 ++ CONTROL_BASIC_AUTH_USER: ${CONTROL_BASIC_AUTH_USER:?Set the Control Center user} ++ CONTROL_BASIC_AUTH_PASSWORD: ${CONTROL_BASIC_AUTH_PASSWORD:?Set the Control Center password} + AGENT_URL: http://agent:8080 + KNOWLEDGE_URL: http://knowledge:8080 + NEUROFORGE_URL: http://neuroforge:8080 +- NEUROFORGE_API_KEY: ${NEUROFORGE_APP_API_KEY} +- CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN} ++ NEUROFORGE_API_KEY: ${NEUROFORGE_CONTROL_READ_TOKEN:?Set the NeuroForge control read token} ++ CONTROL_READ_TOKEN: ${CONTROL_READ_TOKEN:?Set the Agent control read token} + CODEBASE_MEMORY_URL: ${CODEBASE_MEMORY_URL:-} + PUBLIC_CODEBASE_MEMORY_URL: ${PUBLIC_CODEBASE_MEMORY_URL:-} + KNOWLEDGE_VECTOR_BACKEND: ${KNOWLEDGE_VECTOR_BACKEND:-dual} +@@ -225,25 +424,19 @@ services: + PUBLIC_KNOWLEDGE_URL: http://localhost:${KNOWLEDGE_HOST_PORT:-8081} + PUBLIC_NEUROFORGE_URL: http://localhost:${NEUROFORGE_HOST_PORT:-8090}/admin + ports: +- - "127.0.0.1:${CONTROL_HOST_PORT:-8070}:8070" +- depends_on: +- neuroforge: +- condition: service_healthy +- agent: +- condition: service_started +- knowledge: +- condition: service_started ++ - 127.0.0.1:${CONTROL_HOST_PORT:-8070}:8070 + read_only: true + tmpfs: +- - /tmp:size=16m,mode=1777 ++ - /tmp:size=16m,mode=1777 + security_opt: +- - no-new-privileges:true +- cap_drop: ["ALL"] ++ - no-new-privileges:true ++ cap_drop: ++ - ALL + extra_hosts: +- - "host.docker.internal:host-gateway" +- ++ - host.docker.internal:host-gateway ++ stop_grace_period: 25s + volumes: +- neuroforge-data: +- agent-data: +- ollama-data: +- searxng-cache: ++ neuroforge-data: null ++ agent-data: null ++ ollama-data: null ++ searxng-cache: null +diff --git a/docs/BACKUP-RESTORE-v1.5.0.md b/docs/BACKUP-RESTORE-v1.5.0.md +new file mode 100644 +index 0000000000000000000000000000000000000000..d139f0168645e5c7c56b67f3b8739375c637ea56 +--- /dev/null ++++ b/docs/BACKUP-RESTORE-v1.5.0.md +@@ -0,0 +1,23 @@ ++# Backup und Restore – v1.5.0 ++ ++Die Release-Skripte sichern die drei zustandsbehafteten Produktbereiche konsistent während eines kurzen Maintenance-Stopps: NeuroForge `/app/data`, Agent `/app/data` sowie Knowledge-Produktion, Staging und Knowledge-Backups auf dem Host. ++ ++## Backup ++ ++```sh ++./scripts/backup-data.sh /srv/glpi-neuroforge-backups ++``` ++ ++Das Skript führt zuerst den Produktions-Preflight aus, stoppt `neuroforge-worker`, `agent`, `neuroforge` und `knowledge`, kopiert die beiden Named Volumes sowie die drei Knowledge-Verzeichnisse und erzeugt `MANIFEST` plus `SHA256SUMS`. Danach werden die Services wieder gestartet. ++ ++## Restore ++ ++```sh ++./scripts/restore-data.sh /srv/glpi-neuroforge-backups/20260827T071500Z ++``` ++ ++Restore validiert zuerst alle SHA-256-Prüfsummen, stoppt dieselben Services, leert die beiden Daten-Volumes kontrolliert über `busybox:1.36`, stellt alle Datenbereiche wieder her und startet die Services anschließend. `BACKUP_HELPER_IMAGE` kann auf ein intern freigegebenes Helper-Image gesetzt werden. ++ ++## Pflichtprüfung nach Restore ++ ++Nach jedem Restore müssen `docker compose ps`, NeuroForge `/readyz`, Knowledge `/api/health`, Agent `/readyz` und ein read-only Control-Center-Aufruf geprüft werden. GLPI-Schreibautomation bleibt bis zum erfolgreichen Smoke-Test deaktiviert (`DRY_RUN=true`, `AUTO_REPLY=false`, `AUTO_PRIORITY=false`, `AUTO_ESCALATION=false`). +diff --git a/docs/CONTROL-MATRIX.md b/docs/CONTROL-MATRIX.md +index 13af5fa18339a12db81ba0e49a44eb3c909ee557..b3f38293a2a33f251069969f1ad50851245adc93 100644 +--- a/docs/CONTROL-MATRIX.md ++++ b/docs/CONTROL-MATRIX.md +@@ -8,11 +8,11 @@ + | Produktive KB schreiben | nein | ja | nein | nein | nein | + | KB-Staging schreiben | nein | ja | über getrennten KB Integration Token möglich | nein | nein | + | KB-Staging promoten | nein | ja | nein | nein | nein | +-| Knowledge-Vektoren upserten | ja, App Key | nein | ja | ja | nein | +-| Knowledge-Vektoren suchen | ja, App Key | nein | ja | ja | nein | ++| Knowledge-Vektoren upserten | ja, Integration Token | nein | ja | ja | nein | ++| Knowledge-Vektoren suchen | ja, Integration Token | nein | ja | ja | nein | + | NeuroForge Config ändern | nein | nein | nein | ja | nein | + | NeuroForge Secrets lesen/rotieren | nein | nein | nein | ja | nein | +-| Systemstatus lesen | eigene Readiness | eigene Health | Stats mit App Key | ja | aggregiert read-only | ++| Systemstatus lesen | eigene Readiness | eigene Health | Stats mit Control-Read-Token | ja | aggregiert read-only | + | Obsidian-Export | Live-Sicht inkl. GLPI-Relations | kanonische KB | nein | nein | verlinkt Ziel-UI | + | Human Outcome erfassen | ja, authentifizierter Techniker | nein | empfängt nur validated outcome | sichtbar/admin | Status read-only | + | Trusted Outcome-Source setzen | nein | nein | **serverseitig fest** | ja | nein | +@@ -22,11 +22,14 @@ + ## Credentials + + - `NEUROFORGE_ADMIN_TOKEN`: nur Betreiber/Admin. +-- `NEUROFORGE_APP_API_KEY`: Agent und read-only Control-Stats; keine Admin-Config. ++- `NEUROFORGE_APP_API_KEY`: allgemeine App-API; keine Admin-Config und keine Integration-Schreibpfade. ++- `NEUROFORGE_INTEGRATION_TOKEN`: ausschließlich Agent/Knowledge-Integration, Events und validierte Outcomes. ++- `NEUROFORGE_CONTROL_READ_TOKEN`: ausschließlich read-only NeuroForge-Stats/Graph für das Control Center. + - `NEUROFORGE_WORKER_TOKEN`: nur NeuroForge Worker. + - `NEUROFORGE_METRICS_TOKEN`: nur Metrics-Scraper. + - `KB_INTEGRATION_TOKEN`: ausschließlich maschineller Staging-Ingress. + - `BASIC_AUTH_USER/PASSWORD`: Knowledgebase-Editor. ++- `CONTROL_BASIC_AUTH_USER/PASSWORD`: Control Center; `/healthz` bleibt öffentlich. + - `WEB_USERNAME/PASSWORD`: Agent-Webzugang. + - `SEARXNG_SECRET`: nur optionaler SearXNG-Container/Betreiber. + - GLPI-Credentials: ausschließlich Agent. +@@ -65,19 +68,19 @@ Folgende Informationen bleiben absichtlich außerhalb des NeuroForge-Learnings: + + | Akteur | Outcome suchen | Outcome lernen | Outcome superseden | Quality Replay | Auto-Reply autorisieren | + |---|---:|---:|---:|---:|---:| +-| GLPI Agent App-Key | ja, nur aktives validated Outcome API | ja, accepted/corrected | indirekt nur über neue korrigierte Revision | nein über NeuroForge; eigener read-only Agent-Endpunkt | nur über bestehende Agent-Policies + freigegebene KB | ++| GLPI Agent Integration-Token | ja, nur aktives validated Outcome API | ja, accepted/corrected | indirekt nur über neue korrigierte Revision | nein über NeuroForge; eigener read-only Agent-Endpunkt | nur über bestehende Agent-Policies + freigegebene KB | + | Agent Web-Operator | indirekt sichtbar | explizit bestätigen/korrigieren | durch Korrektur | ja, authentifiziert/read-only | nicht durch Outcome allein | + | NeuroForge Admin | technische Brain-Administration | technisch ja | technisch ja | nein | nein | + | Control Center | Status/Konfiguration sichtbar | nein | nein | Verfügbarkeit sichtbar | nein | + | Research/SearXNG | nein | Research-Evidence, nicht trusted outcome | nein | nein | nein | + +-`POST /api/v1/integrations/outcomes/search` akzeptiert den NeuroForge App-Key und liefert ausschließlich aktive Memories der serverseitig festgelegten Outcome-Provenance. Es ist kein generischer Memory-Search-Endpunkt und gewährt keine Admin-Funktionen. ++`POST /api/v1/integrations/outcomes/search` akzeptiert ausschließlich den NeuroForge Integration-Token oder Admin-Token und liefert ausschließlich aktive Memories der serverseitig festgelegten Outcome-Provenance. Es ist kein generischer Memory-Search-Endpunkt und gewährt keine Admin-Funktionen. + + ### v1.4 graph scopes + + | Actor | Capability | Credential | Write authority | + |---|---|---|---| + | Control -> Agent | runs/evidence/learning graphs | `CONTROL_READ_TOKEN` | none | +-| Control -> NeuroForge | research/brain graph | app API key | none through graph endpoints | ++| Control -> NeuroForge | research/brain graph | `NEUROFORGE_CONTROL_READ_TOKEN` | none through graph endpoints | + | Control -> embedded Engineering Graph | structural read | none/internal | none | + | Optional Codebase Memory MCP | developer code analysis | local process / allowed root | none in platform | +diff --git a/docs/ENV-MIGRATION-FROM-LEGACY.md b/docs/ENV-MIGRATION-FROM-LEGACY.md +index b3ca7ade369d181b36c886b184bea5c52b98114f..20706f32938f6de4606c4545475fb03402bc45af 100644 +--- a/docs/ENV-MIGRATION-FROM-LEGACY.md ++++ b/docs/ENV-MIGRATION-FROM-LEGACY.md +@@ -2,7 +2,7 @@ + + ## Ergebnis der Prüfung + +-Die 110 Variablennamen der bisherigen Agent-Konfiguration werden im aktuellen v1.4.x-Agenten ++Die 110 Variablennamen der bisherigen Agent-Konfiguration werden im aktuellen v1.5.0-Agenten + weiterhin verwendet. Das Problem lag nicht in entfernten Agent-Funktionen, sondern in der zu kurzen + Top-Level-`.env.example` des Mega-Projekts: 86 dieser 110 bisherigen Variablen waren dort nicht + explizit dokumentiert. +@@ -20,7 +20,7 @@ Diese Werte sollten im Mega-Stack nicht zur Host-Konfiguration benutzt werden: + | `KNOWLEDGE_DIR` | `/app/knowledge` | `KB_DATA_PATH` | + | `OLLAMA_URL` | `http://ollama:11434` | Compose-Service `ollama` | + | `NEUROFORGE_URL` | `http://neuroforge:8080` | Compose-Service `neuroforge` | +-| `NEUROFORGE_API_KEY` | aus `NEUROFORGE_APP_API_KEY` | `NEUROFORGE_APP_API_KEY` | ++| `NEUROFORGE_API_KEY` | aus `NEUROFORGE_INTEGRATION_TOKEN` | `NEUROFORGE_INTEGRATION_TOKEN` | + | `BRAIN_ACTIVITY_URL` | NeuroForge Event API | intern verdrahtet | + + Compose überschreibt `HTTP_ADDR` jetzt explizit. Dadurch kann eine alte Standalone-Konfiguration +@@ -50,7 +50,7 @@ Diese Unterschiede sind nicht automatisch falsch, müssen aber bewusst entschied + + Neu gegenüber der bisherigen Agent-Only-Konfiguration sind insbesondere: + +-- NeuroForge Admin/App/Worker/Metrics Tokens ++- getrennte NeuroForge Admin/App/Integration/Control-Read/Worker/Metrics Tokens + - `KNOWLEDGE_VECTOR_BACKEND=local|dual|neuroforge` + - NeuroForge Namespace/Search/Failure Policy + - Controlled Learning +@@ -60,7 +60,7 @@ Neu gegenüber der bisherigen Agent-Only-Konfiguration sind insbesondere: + - getrennte Autonomy-Aktivierung + - Knowledge Integration Token + - scoped `CONTROL_READ_TOKEN` +-- Control Center / NeuroForge / Knowledge Host Ports ++- Control-Center-Basic-Auth sowie Control Center / NeuroForge / Knowledge Host Ports + - optionale Codebase-Memory-UI + + ## Empfohlene Migration +diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md +index 36d0c23e9e70d65158bfeb80d873285f210d144f..18ab4365c69d64e9728f937a0f4e65472915b432 100644 +--- a/docs/ENVIRONMENT.md ++++ b/docs/ENVIRONMENT.md +@@ -1,4 +1,4 @@ +-# Environment configuration (v1.4.x) ++# Environment configuration (v1.5.0) + + The repository-level `.env.example` is the canonical configuration template for the Mega stack. + It intentionally includes the complete GLPI Agent configuration plus NeuroForge, controlled-learning, +@@ -14,8 +14,8 @@ The old variables are still supported, but container-internal values are now own + - `KNOWLEDGE_DIR=/app/knowledge` + - `OLLAMA_URL=http://ollama:11434` + - `NEUROFORGE_URL=http://neuroforge:8080` +-- `NEUROFORGE_API_KEY` is derived from `NEUROFORGE_APP_API_KEY` +-- Brain-activity endpoints are wired internally by Compose ++- `NEUROFORGE_API_KEY` is derived from `NEUROFORGE_INTEGRATION_TOKEN` ++- Brain-activity endpoints are wired internally by Compose and use `NEUROFORGE_INTEGRATION_TOKEN` + + The host-facing ports are configured separately with `AGENT_HOST_PORT`, `KNOWLEDGE_HOST_PORT`, + `CONTROL_HOST_PORT`, `NEUROFORGE_HOST_PORT`, `OLLAMA_HOST_PORT` and `SEARXNG_HOST_PORT`. +@@ -64,7 +64,7 @@ Never commit `.env`. The tracked file must remain `.env.example` only. + If credentials were pasted into issue trackers, chats, CI logs, shell history or screenshots, + rotate them before production use. + +-## Research → Knowledge Staging (v1.4.5+) ++## Research → Knowledge Staging (v1.5.0+) + + The autonomous research bridge is controlled independently from Research and Goal Learning: + +@@ -79,3 +79,7 @@ NEUROFORGE_KB_STAGING_MAX_EVIDENCE=12 + `NEUROFORGE_KB_STAGING_URL` and `NEUROFORGE_KB_STAGING_TOKEN` are container-internal values owned by the root Compose file. The token is derived from the existing `KB_INTEGRATION_TOKEN`; do not duplicate it under a second operator-managed secret name. + + This bridge can only create/update **human-review staging**. The Knowledge service enforces `auto_reply=false` and does not expose production promotion through this integration token. ++ ++## Production secret isolation ++ ++The production Compose does not use `env_file`. Agent and Knowledge receive only explicit runtime variables. NeuroForge Admin/Worker/Metrics, Knowledge editor and Control Center credentials are therefore not broadly inherited by unrelated containers. Local source builds use the separate `docker-compose.dev.yml` override. +diff --git a/docs/GO-LIVE-v1.5.0.md b/docs/GO-LIVE-v1.5.0.md +new file mode 100644 +index 0000000000000000000000000000000000000000..08d33567c748afc87cbbe1aca37e614ff3778dba +--- /dev/null ++++ b/docs/GO-LIVE-v1.5.0.md +@@ -0,0 +1,27 @@ ++# Go-Live Gate – v1.5.0 ++ ++## Registry und Start ++ ++Auf dem Produktionshost wird **nicht gebaut**. Voraussetzung ist ein freigegebener, unveränderlicher `IMAGE_TAG` in `.env`. ++ ++```sh ++./scripts/preflight.sh ++docker compose pull ++docker compose up -d --remove-orphans ++docker compose ps ++``` ++ ++Alternativ führt `./scripts/go-live.sh` genau diesen Ablauf ohne `make` aus. `IMAGE_TAG=latest`, Placeholder-Secrets, wiederverwendete Trust-Boundary-Tokens, `build:` oder `env_file:` im Produktions-Compose brechen den Preflight ab. ++ ++## Pflicht-Smoke-Test auf dem echten Host ++ ++1. Ollama enthält Chat- **und** Embedding-Modell (`gemma3`/konfiguriert und `embeddinggemma`/konfiguriert); NeuroForge `/readyz` muss 200 liefern. ++2. Knowledge `/api/health` und Agent `/readyz` liefern 200; Control `/healthz` bleibt ohne Login erreichbar, alle Betriebs-/Graphseiten verlangen Control-Basic-Auth. ++3. Agent kann mit dem Integration-Token Knowledge-Integration/Outcome-Pfade nutzen; der Control-Read-Token kann diese Schreibpfade nicht nutzen. ++4. Einen Research-Goal manuell ausführen. Fortschritt und ein Staging-Draft müssen auch dann entstehen können, wenn `NEUROFORGE_GOAL_LEARNING_ENABLED=false` ist. ++5. Einen zweiten parallelen Cycle desselben Goals auslösen; er muss `409 Conflict` erhalten. ++6. Staging-Draft im Knowledge-Editor prüfen und promoten. Der Produktionsartikel erscheint genau einmal und der Draft wird archiviert. ++7. NeuroForge stoppen: Knowledge und Control müssen weiterlaufen; Agent verhält sich entsprechend `KNOWLEDGE_VECTOR_BACKEND`/`NEUROFORGE_FAIL_OPEN`. ++8. Vor GLPI-Schreibfreigabe einen vollständigen Ticketdurchlauf in `DRY_RUN=true` prüfen. Erst danach die gewünschten Automationen einzeln aktivieren. ++ ++Docker, eine echte GLPI-Instanz, SearXNG und Ollama stehen in der Build-/Review-Umgebung nicht zur Verfügung; dieser Host-Smoke-Test ist deshalb ein bewusstes externes Release-Gate und darf nicht als lokal bestanden markiert werden. +diff --git a/docs/MIGRATION-v1.4.5-to-v1.5.0.md b/docs/MIGRATION-v1.4.5-to-v1.5.0.md +new file mode 100644 +index 0000000000000000000000000000000000000000..b0e1f7a4c3aa6ce5f6225f8dec9b94908d83bc9a +--- /dev/null ++++ b/docs/MIGRATION-v1.4.5-to-v1.5.0.md +@@ -0,0 +1,45 @@ ++# Migration v1.4.5 → v1.5.0 ++ ++## 1. Back up v1.4.5 data ++ ++Take an existing host backup before replacing the checkout. For future v1.5.0 backups use `scripts/backup-data.sh`. ++ ++## 2. Replace/update configuration ++ ++Start from the v1.5.0 `.env.example`; do not simply keep the old broad Compose environment behavior. Preserve reviewed Agent/GLPI policy values and add: ++ ++```env ++IMAGE_TAG=1.5.0 ++NEUROFORGE_INTEGRATION_TOKEN= ++NEUROFORGE_CONTROL_READ_TOKEN= ++CONTROL_BASIC_AUTH_USER=admin ++CONTROL_BASIC_AUTH_PASSWORD= ++NEUROFORGE_READINESS_OLLAMA_LIVE=true ++AGENT_LEGACY_KNOWLEDGE_EDITOR_ENABLED=false ++``` ++ ++Keep the pre-existing Admin/App/Worker/Metrics, Knowledge integration and Agent Control-Read tokens unique. `./scripts/generate-secrets.sh` emits all required new secret values. ++ ++## 3. Understand credential remapping ++ ++- Agent `NEUROFORGE_API_KEY` and Brain Activity use `NEUROFORGE_INTEGRATION_TOKEN` internally. ++- Control Center uses `NEUROFORGE_CONTROL_READ_TOKEN` for NeuroForge and `CONTROL_READ_TOKEN` for Agent read-only endpoints. ++- The general `NEUROFORGE_APP_API_KEY` no longer authorizes `/api/v1/integrations/*` writes. ++ ++## 4. Deploy registry images only ++ ++```sh ++./scripts/preflight.sh ++docker compose pull ++docker compose up -d --remove-orphans ++``` ++ ++Do not run a production source build. Local development uses: ++ ++```sh ++docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build ++``` ++ ++## 5. Verify before enabling GLPI writes ++ ++Follow every gate in `docs/GO-LIVE-v1.5.0.md`. In particular verify both Ollama models, Research→Staging with goal-summary learning disabled, duplicate-cycle `409`, promotion, degraded NeuroForge behavior, and a complete `DRY_RUN=true` GLPI ticket flow. +diff --git a/docs/VALIDATION-v1.5.0.md b/docs/VALIDATION-v1.5.0.md +new file mode 100644 +index 0000000000000000000000000000000000000000..ea076105239d1ad8d85c37d12e7efc35701e7317 +--- /dev/null ++++ b/docs/VALIDATION-v1.5.0.md +@@ -0,0 +1,34 @@ ++# Validation – v1.5.0 ++ ++This file records reproducible release gates. It deliberately distinguishes checks possible in the source-review environment from host-only Docker/GLPI checks. ++ ++## Locally executed ++ ++- Four Go modules: `go test ./...` — PASS. ++- Four Go modules: `go vet ./...` — PASS. ++- Four Go modules: `go build ./...` — PASS. ++- Targeted race suites: NeuroForge Store/Brain/HTTP API, Agent state/knowledge/learning/core agent, Knowledge Store/Staging/Server, Control — PASS. ++- NeuroForge live Ollama readiness regression: missing embedding model → 503; both configured models → 200 — PASS. ++- Goal per-ID single-flight regression — PASS. ++- Scoped-token trust-boundary regression — PASS. ++- Environment-managed Admin-secret mutation regression — PASS. ++- Knowledge browser same-origin regression — PASS. ++- Knowledge promotion rollback regression — PASS. ++- Control Center Basic Auth regression — PASS. ++- Production Compose static gate: no project `build:`, no `env_file:`, exact six registry image families with `${IMAGE_TAG}` — PASS. ++- Agent production environment secret-isolation check — PASS. ++- Shell syntax (`sh -n`) and `git diff --check` — PASS. ++- Engineering graph regeneration/idempotence — PASS. ++ ++## Host-only, still mandatory ++ ++The review environment does not provide Docker/Compose, GLPI, SearXNG or a production Ollama. Therefore the following are release-host gates rather than local PASS claims: ++ ++- authenticated registry pull of the final immutable tag; ++- `docker compose config`, `pull`, `up` and health state using the real `.env`; ++- real Ollama model inventory and inference; ++- GLPI read/write smoke flow; ++- optional SearXNG research flow; ++- backup/restore rehearsal against actual Docker volumes and host paths. ++ ++Use `scripts/preflight.sh`, `scripts/go-live.sh` and `docs/GO-LIVE-v1.5.0.md` on the target host. +diff --git a/mega-project.json b/mega-project.json +index 4bbcbb4c834c1a2ee84aef04f34a223224bd1b2c..62b23f57728905388b5a1b86a8f301ef5f736de7 100644 +--- a/mega-project.json ++++ b/mega-project.json +@@ -28,7 +28,7 @@ + "schema": "Wiki/Schema.md", + "glpi_relations": "KnowbaseItem_Item when exposed by GLPI OpenAPI" + }, +- "version": "1.4.5", ++ "version": "1.5.0", + "controlled_learning": { + "raw_chat_auto_learning": false, + "validated_outcomes": [ +diff --git a/platform/neuroforge/Dockerfile b/platform/neuroforge/Dockerfile +index 033bf9164f528422e1299f4e829351e8616ab4c4..fb3d593894d8871643f6205fa7b953e952486479 100644 +--- a/platform/neuroforge/Dockerfile ++++ b/platform/neuroforge/Dockerfile +@@ -3,7 +3,9 @@ WORKDIR /src + COPY go.mod ./ + COPY cmd ./cmd + COPY internal ./internal +-RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \ ++RUN go test ./... && \ ++ go vet ./... && \ ++ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-worker ./cmd/worker && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-bench ./cmd/bench + +diff --git a/platform/neuroforge/cmd/server/main.go b/platform/neuroforge/cmd/server/main.go +index c0fa807546a57c8c88180f90d2e32d127889428b..c94a91a055810d424141871e58c5188c91c32938 100644 +--- a/platform/neuroforge/cmd/server/main.go ++++ b/platform/neuroforge/cmd/server/main.go +@@ -47,6 +47,41 @@ func envInt(name string) (int, bool) { + return v, true + } + ++func validateManagedSecret(name, value string, minLen int) error { ++ value = strings.TrimSpace(value) ++ if value == "" { ++ return nil ++ } ++ upper := strings.ToUpper(value) ++ if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") { ++ return fmt.Errorf("%s still contains a placeholder", name) ++ } ++ if len(value) < minLen { ++ return fmt.Errorf("%s must be at least %d characters", name, minLen) ++ } ++ return nil ++} ++ ++func validateManagedSecretsFromEnv() error { ++ for _, item := range []struct { ++ name string ++ min int ++ }{ ++ {"NEUROFORGE_ADMIN_TOKEN", 24}, ++ {"NEUROFORGE_APP_API_KEY", 24}, ++ {"NEUROFORGE_INTEGRATION_TOKEN", 24}, ++ {"NEUROFORGE_CONTROL_READ_TOKEN", 24}, ++ {"NEUROFORGE_WORKER_TOKEN", 24}, ++ {"NEUROFORGE_METRICS_TOKEN", 24}, ++ {"NEUROFORGE_CLUSTER_TOKEN", 24}, ++ } { ++ if err := validateManagedSecret(item.name, os.Getenv(item.name), item.min); err != nil { ++ return err ++ } ++ } ++ return nil ++} ++ + func maxIntMain(a, b int) int { + if a > b { + return a +@@ -66,6 +101,10 @@ func run() (retErr error) { + listen := flag.String("listen", "", "listen address override") + flag.Parse() + ++ if err := validateManagedSecretsFromEnv(); err != nil { ++ return err ++ } ++ + s, err := store.New(*data) + if err != nil { + return err +@@ -79,12 +118,14 @@ func run() (retErr error) { + sec := s.Secrets() + changed := false + for name, dst := range map[string]*string{ +- "OPENAI_API_KEY": &sec.OpenAIAPIKey, +- "NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken, +- "NEUROFORGE_APP_API_KEY": &sec.AppAPIKey, +- "NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken, +- "NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken, +- "NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken, ++ "OPENAI_API_KEY": &sec.OpenAIAPIKey, ++ "NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken, ++ "NEUROFORGE_APP_API_KEY": &sec.AppAPIKey, ++ "NEUROFORGE_INTEGRATION_TOKEN": &sec.IntegrationToken, ++ "NEUROFORGE_CONTROL_READ_TOKEN": &sec.ControlReadToken, ++ "NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken, ++ "NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken, ++ "NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken, + } { + if v := os.Getenv(name); v != "" { + *dst = v +@@ -230,6 +271,9 @@ func run() (retErr error) { + defer stopMaintenance() + go b.RunV6Maintenance(maintenanceCtx) + api := httpapi.New(s, b, r, c) ++ if v, ok := envBool("NEUROFORGE_READINESS_OLLAMA_LIVE"); ok { ++ api.SetReadinessOllamaLive(v) ++ } + cfg := s.Config() + addr := cfg.Listen + if *listen != "" { +diff --git a/platform/neuroforge/cmd/worker/main.go b/platform/neuroforge/cmd/worker/main.go +index ff2914daaea8536bfbfceba8eedc46dedb324e68..a6ffd1d7bd29d113dd06675143dc259627144c31 100644 +--- a/platform/neuroforge/cmd/worker/main.go ++++ b/platform/neuroforge/cmd/worker/main.go +@@ -38,17 +38,17 @@ type relinkResult struct { + + func main() { + server := flag.String("server", "http://localhost:8080", "NeuroForge server") +- token := flag.String("token", os.Getenv("NEUROFORGE_WORKER_TOKEN"), "worker token") + id := flag.String("id", hostname(), "worker id") + interval := flag.Duration("interval", 2*time.Second, "poll interval") + flag.Parse() +- if *token == "" { +- log.Fatal("worker token required (-token or NEUROFORGE_WORKER_TOKEN)") ++ token := strings.TrimSpace(os.Getenv("NEUROFORGE_WORKER_TOKEN")) ++ if token == "" { ++ log.Fatal("NEUROFORGE_WORKER_TOKEN is required") + } + client := &http.Client{Timeout: 180 * time.Second} + log.Printf("worker %s polling %s", *id, *server) + for { +- job, err := claim(client, *server, *token, *id) ++ job, err := claim(client, *server, token, *id) + if err != nil { + log.Printf("claim: %v", err) + time.Sleep(*interval) +@@ -59,7 +59,7 @@ func main() { + continue + } + res, jobErr := run(job) +- if err := complete(client, *server, *token, *id, job.ID, res, jobErr); err != nil { ++ if err := complete(client, *server, token, *id, job.ID, res, jobErr); err != nil { + log.Printf("complete %s: %v", job.ID, err) + } else { + log.Printf("job %s %s done", job.ID, job.Type) +diff --git a/platform/neuroforge/internal/brain/brain.go b/platform/neuroforge/internal/brain/brain.go +index b99977f63440a31fe528dc84cfa4166527e39bd1..e4add8a08d9a77317fcb5d6a1935ddaa8ef100aa 100644 +--- a/platform/neuroforge/internal/brain/brain.go ++++ b/platform/neuroforge/internal/brain/brain.go +@@ -34,10 +34,30 @@ type Engine struct { + electionRunning bool + stagingMu sync.RWMutex + staging StagingPublisherConfig ++ goalCycleMu sync.Mutex ++ goalCycles map[string]struct{} + } + ++var ErrGoalCycleInProgress = errors.New("goal cycle already in progress") ++ + func New(s *store.Store, r *provider.Router, c *cost.Manager) *Engine { +- return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}} ++ return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}, goalCycles: map[string]struct{}{}} ++} ++ ++func (e *Engine) beginGoalCycle(goalID string) bool { ++ e.goalCycleMu.Lock() ++ defer e.goalCycleMu.Unlock() ++ if _, ok := e.goalCycles[goalID]; ok { ++ return false ++ } ++ e.goalCycles[goalID] = struct{}{} ++ return true ++} ++ ++func (e *Engine) endGoalCycle(goalID string) { ++ e.goalCycleMu.Lock() ++ delete(e.goalCycles, goalID) ++ e.goalCycleMu.Unlock() + } + + type ChatRequest struct { +diff --git a/platform/neuroforge/internal/brain/goal_progress_test.go b/platform/neuroforge/internal/brain/goal_progress_test.go +index 5f41b1259765057a0c4f4f9a91d77b5f7b180aba..8caa1163593003aee96703d7474cd929bb0e826b 100644 +--- a/platform/neuroforge/internal/brain/goal_progress_test.go ++++ b/platform/neuroforge/internal/brain/goal_progress_test.go +@@ -150,3 +150,68 @@ func TestGoalProgressDoesNotRegressWhenResearchAuditRunsAreTrimmed(t *testing.T) + t.Fatalf("counters regressed: %#v", g) + } + } ++ ++func TestResearchProgressAndStagingRunWhenGoalSummaryLearningDisabled(t *testing.T) { ++ searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{ ++ "title": "Vendor evidence", "url": "https://example.com/vendor", "content": "A supported driver package resolves the documented device issue.", "engine": "test", "score": 0.9, ++ }}}) ++ })) ++ defer searx.Close() ++ stagingCalls := 0 ++ kb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ stagingCalls++ ++ if r.Header.Get("Authorization") != "Bearer staging-token-123456789012345678901234" { ++ t.Fatalf("bad staging auth") ++ } ++ _ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-STAGING-GOAL", "meta": map[string]any{"integration_action": "created"}}}) ++ })) ++ defer kb.Close() ++ ++ s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) { ++ if r.URL.Path == "/api/embed" { ++ _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}, "prompt_eval_count": 1}) ++ return ++ } ++ http.NotFound(w, r) ++ }) ++ cfg := s.Config() ++ cfg.Research.Enabled = true ++ cfg.Research.SearXNG.Enabled = true ++ cfg.Research.SearXNG.BaseURL = searx.URL ++ cfg.Research.Goal.Enabled = true ++ cfg.Research.WebFetch.Enabled = false ++ cfg.Brain.LearningPolicy.Enabled = true ++ cfg.Brain.LearningPolicy.LearnGoalCycles = false ++ cfg.Autonomy.UseLLM = false ++ cfg.Brain.ExternalRelinkWorker = false ++ if err := s.UpdateConfig(cfg); err != nil { ++ t.Fatal(err) ++ } ++ e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "staging-token-123456789012345678901234", MinEvidence: 1, MinSources: 1, MaxEvidence: 4}) ++ goal := core.Goal{Title: "Driver research", Description: "collect sourced driver evidence", Target: "1 quellengebundener Wissenseintrag", Status: core.GoalActive, Priority: 80, ResearchEnabled: true} ++ if err := s.UpsertGoal(&goal); err != nil { ++ t.Fatal(err) ++ } ++ ++ cycle, err := e.RunGoalCycle(context.Background(), goal.ID) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if cycle.MemoryID != "" { ++ t.Fatalf("goal-summary memory should be disabled, got %q", cycle.MemoryID) ++ } ++ updated, ok := s.GetGoal(goal.ID) ++ if !ok { ++ t.Fatal("goal missing") ++ } ++ if updated.ResearchEvidence < 1 || updated.Progress <= 0 { ++ t.Fatalf("research progress not updated: %#v", updated) ++ } ++ if stagingCalls < 1 || updated.LastStagingDraftID != "KB-STAGING-GOAL" { ++ t.Fatalf("staging not published: calls=%d goal=%#v", stagingCalls, updated) ++ } ++ if strings.Contains(strings.ToLower(updated.LastError), "learning policy") { ++ t.Fatalf("legacy learning-policy error survived: %q", updated.LastError) ++ } ++} +diff --git a/platform/neuroforge/internal/brain/staging.go b/platform/neuroforge/internal/brain/staging.go +index ad26547c75d936751bfb44d704167f5247503646..50a3ca7474b7a8d34fc8a0eb9ab08efd8dda3651 100644 +--- a/platform/neuroforge/internal/brain/staging.go ++++ b/platform/neuroforge/internal/brain/staging.go +@@ -169,7 +169,7 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide + if limit <= 0 { + limit = 12 + } +- runs := e.store.ResearchRunsSnapshot(goalID, 20) ++ runs := e.store.ResearchRunsSnapshot(goalID, 200) + ids := map[string]struct{}{} + out := make([]draftEvidence, 0, limit) + for _, run := range runs { +@@ -201,9 +201,60 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide + } + } + } ++ // Research-run telemetry is bounded. Supplement it with durable provenance so ++ // older source-backed evidence remains eligible after the run history window ++ // rolls over. Newest memories are preferred. ++ memories := e.store.MemoriesSnapshot() ++ for i := len(memories) - 1; i >= 0 && len(out) < limit; i-- { ++ m := memories[i] ++ if m.Status != core.MemoryActive || m.Provenance.GoalID != goalID || m.Provenance.Source == "goal-cycle" || m.Provenance.SourceID == "" { ++ continue ++ } ++ if _, ok := ids[m.ID]; ok { ++ continue ++ } ++ var src *core.KnowledgeSource ++ if source, ok := e.store.GetSource(m.Provenance.SourceID); ok { ++ src = source ++ } ++ if src == nil { ++ continue ++ } ++ ids[m.ID] = struct{}{} ++ out = append(out, draftEvidence{Memory: m, Source: src}) ++ } + return out + } + ++// CheckStagingPublisher verifies both reachability and the configured integration ++// credential without creating a draft. Knowledge exposes a dedicated auth-checked ++// health endpoint for this purpose. ++func (e *Engine) CheckStagingPublisher(ctx context.Context) error { ++ cfg := e.stagingConfig() ++ if !cfg.Enabled { ++ return nil ++ } ++ if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Token) == "" { ++ return errors.New("staging publisher enabled but URL/token is missing") ++ } ++ healthURL := strings.TrimRight(cfg.URL, "/") + "/health" ++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) ++ if err != nil { ++ return err ++ } ++ req.Header.Set("Authorization", "Bearer "+cfg.Token) ++ resp, err := e.http.Do(req) ++ if err != nil { ++ return err ++ } ++ defer resp.Body.Close() ++ raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) ++ if resp.StatusCode < 200 || resp.StatusCode >= 300 { ++ return fmt.Errorf("knowledge staging health HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) ++ } ++ return nil ++} ++ + func (e *Engine) synthesizeGoalDraft(ctx context.Context, goal *core.Goal, evidence []draftEvidence) (stagingDraftPayload, error) { + var b strings.Builder + for i, ev := range evidence { +diff --git a/platform/neuroforge/internal/brain/v3.go b/platform/neuroforge/internal/brain/v3.go +index c865a98f0a9aa0c6ebb65275991a672d188679ec..dabd5ac6a69c076cb9c156ebd0436b733e1c7ac1 100644 +--- a/platform/neuroforge/internal/brain/v3.go ++++ b/platform/neuroforge/internal/brain/v3.go +@@ -25,6 +25,11 @@ type AutonomyResult struct { + } + + func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.LearningCycle, error) { ++ if !e.beginGoalCycle(goalID) { ++ return core.LearningCycle{}, ErrGoalCycleInProgress ++ } ++ defer e.endGoalCycle(goalID) ++ + goal, ok := e.store.GetGoal(goalID) + if !ok { + return core.LearningCycle{}, errors.New("goal not found") +@@ -34,12 +39,66 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning + } + cfg := e.store.Config() + lp := cfg.Brain.LearningPolicy ++ ++ // Research, measurable progress and the human-review staging bridge are a ++ // governance path of their own. They must not be blocked by the policy that ++ // controls whether a semantic goal-summary memory may be learned. ++ researchResult := e.researchGoal(ctx, goal) ++ e.refreshGoalResearchProgress(goal, goal.LastEvaluation) ++ e.maybePublishGoalDraft(ctx, goal, researchResult) ++ ++ now := time.Now().UTC() ++ goal.LastCycleAt = now ++ interval := goal.IntervalMinutes ++ if interval <= 0 { ++ interval = cfg.Autonomy.DefaultGoalIntervalMinutes ++ } ++ if interval <= 0 { ++ interval = cfg.Autonomy.IntervalMinutes ++ } ++ goal.NextCycleAt = now.Add(time.Duration(maxIntV3(1, interval)) * time.Minute) ++ goal.ConsecutiveErrors = 0 ++ goal.LastError = "" ++ if err := e.store.UpsertGoal(goal); err != nil { ++ return core.LearningCycle{}, err ++ } ++ ++ researchQueries := []string{} ++ if strings.TrimSpace(researchResult.Query) != "" { ++ researchQueries = strings.Split(researchResult.Query, " | ") ++ } ++ cycle := core.LearningCycle{ ++ ID: store.NewID("cycle"), ++ GoalID: goal.ID, ++ CostUSD: researchResult.CostUSD, ++ CreatedAt: now, ++ ResearchRunID: researchResult.RunID, ++ ResearchQueries: researchQueries, ++ SourcesFound: len(researchResult.Results), ++ SourcesIngested: len(researchResult.Sources), ++ ResearchErrors: append([]string(nil), researchResult.Errors...), ++ } ++ + if !lp.Enabled || !lp.LearnGoalCycles { +- return core.LearningCycle{}, errors.New("goal learning is disabled by learning policy") ++ cycle.Observation = fmt.Sprintf("Research governance cycle completed: %s", goal.ProgressReason) ++ cycle.Prediction = deterministicPrediction(goal, nil, goal.LastEvaluation) ++ cycle.Evaluation = goal.LastEvaluation ++ cycle.Learning = "Goal-summary memory skipped by learning policy; research, progress and staging were processed independently." ++ goal.Prediction = cycle.Prediction ++ goal.NextAction = deterministicNextAction(goal, nil, goal.LastEvaluation) ++ if err := e.store.UpsertGoal(goal); err != nil { ++ return core.LearningCycle{}, err ++ } ++ if err := e.store.AddLearningCycle(cycle); err != nil { ++ return core.LearningCycle{}, err ++ } ++ _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "goal.researched", Summary: "Goal research/progress cycle completed without semantic summary learning", Reason: "goal-summary learning disabled by policy", Actor: "goal-research", Metadata: map[string]string{"goal_id": goal.ID, "research_sources": fmt.Sprint(len(researchResult.Sources)), "research_results": fmt.Sprint(len(researchResult.Results)), "staging_id": goal.LastStagingDraftID}}) ++ return cycle, nil + } +- researchResult := e.researchGoal(ctx, goal) ++ + query := strings.TrimSpace(goal.Title + "\n" + goal.Description + "\nTarget: " + goal.Target) + emb, embedCost, err := e.embed(ctx, query) ++ cycle.CostUSD += embedCost + if err != nil { + return core.LearningCycle{}, err + } +@@ -54,7 +113,7 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning + observation := summarizeObservation(evidenceHits, warnings) + prediction := deterministicPrediction(goal, evidenceHits, evaluation) + nextAction := deterministicNextAction(goal, evidenceHits, evaluation) +- costUSD := embedCost + researchResult.CostUSD ++ costUSD := cycle.CostUSD + if cfg.Autonomy.UseLLM { + prompt := fmt.Sprintf("GOAL: %s\nDESCRIPTION: %s\nTARGET: %s\nPROGRESS: %.3f\nOBSERVATIONS:\n%s", goal.Title, goal.Description, goal.Target, goal.Progress, observation) + route := roleRoute(cfg.Routing.Goal, cfg.Autonomy.Provider, cfg.Autonomy.Model) +@@ -89,29 +148,16 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning + goal.Prediction = prediction + goal.NextAction = nextAction + goal.LastEvaluation = evaluation +- goal.LastCycleAt = time.Now().UTC() +- interval := goal.IntervalMinutes +- if interval <= 0 { +- interval = cfg.Autonomy.DefaultGoalIntervalMinutes +- } +- if interval <= 0 { +- interval = cfg.Autonomy.IntervalMinutes +- } +- goal.NextCycleAt = goal.LastCycleAt.Add(time.Duration(maxIntV3(1, interval)) * time.Minute) +- goal.ConsecutiveErrors = 0 +- goal.LastError = "" + goal.MemoryIDs = appendUniqueV3(goal.MemoryIDs, mem.ID) +- // Human-review staging is a one-way governance boundary. Publishing failures +- // are visible on the goal but never invalidate the durable research/learning cycle. +- e.maybePublishGoalDraft(ctx, goal, researchResult) + if err := e.store.UpsertGoal(goal); err != nil { + return core.LearningCycle{}, err + } +- researchQueries := []string{} +- if strings.TrimSpace(researchResult.Query) != "" { +- researchQueries = strings.Split(researchResult.Query, " | ") +- } +- cycle := core.LearningCycle{ID: store.NewID("cycle"), GoalID: goal.ID, Observation: observation, Prediction: prediction, Evaluation: evaluation, Learning: learning, MemoryID: mem.ID, CostUSD: costUSD, CreatedAt: time.Now().UTC(), ResearchRunID: researchResult.RunID, ResearchQueries: researchQueries, SourcesFound: len(researchResult.Results), SourcesIngested: len(researchResult.Sources), ResearchErrors: append([]string(nil), researchResult.Errors...)} ++ cycle.Observation = observation ++ cycle.Prediction = prediction ++ cycle.Evaluation = evaluation ++ cycle.Learning = learning ++ cycle.MemoryID = mem.ID ++ cycle.CostUSD = costUSD + if err := e.store.AddLearningCycle(cycle); err != nil { + return core.LearningCycle{}, err + } +diff --git a/platform/neuroforge/internal/brain/v8_test.go b/platform/neuroforge/internal/brain/v8_test.go +index 64fcdf9ea50fb8d30ab17a851d70fab704e8cf74..eadbf5c42fdab625840f67c52d6098554e9b161e 100644 +--- a/platform/neuroforge/internal/brain/v8_test.go ++++ b/platform/neuroforge/internal/brain/v8_test.go +@@ -3,9 +3,11 @@ package brain + import ( + "context" + "encoding/json" ++ "errors" + "net/http" + "net/http/httptest" + "testing" ++ "time" + + "neuroforge/internal/core" + ) +@@ -112,3 +114,57 @@ func TestGoalResearchPersistsTransparentTrace(t *testing.T) { + } + } + } ++ ++func TestGoalCycleSingleFlightRejectsConcurrentRun(t *testing.T) { ++ started := make(chan struct{}, 1) ++ release := make(chan struct{}) ++ searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ select { ++ case started <- struct{}{}: ++ default: ++ } ++ <-release ++ _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}}) ++ })) ++ defer searx.Close() ++ ++ s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) { ++ if r.URL.Path == "/api/embed" { ++ _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}}) ++ return ++ } ++ http.NotFound(w, r) ++ }) ++ cfg := s.Config() ++ cfg.Research.Enabled = true ++ cfg.Research.SearXNG.Enabled = true ++ cfg.Research.SearXNG.BaseURL = searx.URL ++ cfg.Research.Goal.Enabled = true ++ cfg.Research.WebFetch.Enabled = false ++ cfg.Brain.LearningPolicy.LearnGoalCycles = false ++ if err := s.UpdateConfig(cfg); err != nil { ++ t.Fatal(err) ++ } ++ goal := core.Goal{Title: "single flight", Description: "concurrency guard", Status: core.GoalActive, Priority: 70, ResearchEnabled: true} ++ if err := s.UpsertGoal(&goal); err != nil { ++ t.Fatal(err) ++ } ++ ++ firstDone := make(chan error, 1) ++ go func() { ++ _, err := e.RunGoalCycle(context.Background(), goal.ID) ++ firstDone <- err ++ }() ++ select { ++ case <-started: ++ case <-time.After(2 * time.Second): ++ t.Fatal("first goal cycle did not enter research") ++ } ++ if _, err := e.RunGoalCycle(context.Background(), goal.ID); !errors.Is(err, ErrGoalCycleInProgress) { ++ t.Fatalf("second cycle error=%v, want %v", err, ErrGoalCycleInProgress) ++ } ++ close(release) ++ if err := <-firstDone; err != nil { ++ t.Fatalf("first cycle failed: %v", err) ++ } ++} +diff --git a/platform/neuroforge/internal/core/types.go b/platform/neuroforge/internal/core/types.go +index ae85e09cf1030627ed0af0d4ff84a1723251e4c5..ce7d6823dd5e23f385dd5b0d7b799a86a7d90f5d 100644 +--- a/platform/neuroforge/internal/core/types.go ++++ b/platform/neuroforge/internal/core/types.go +@@ -395,6 +395,8 @@ type Config struct { + type Secrets struct { + OpenAIAPIKey string `json:"openai_api_key"` + AppAPIKey string `json:"app_api_key"` ++ IntegrationToken string `json:"integration_token,omitempty"` ++ ControlReadToken string `json:"control_read_token,omitempty"` + WorkerToken string `json:"worker_token"` + AdminToken string `json:"admin_token"` + MetricsToken string `json:"metrics_token,omitempty"` +diff --git a/platform/neuroforge/internal/httpapi/httpapi.go b/platform/neuroforge/internal/httpapi/httpapi.go +index dce944f51aeec04fc79607ef84eb5c8b8c106f9e..5bfa388d334e0f12f70a88878e5f1301371f5a22 100644 +--- a/platform/neuroforge/internal/httpapi/httpapi.go ++++ b/platform/neuroforge/internal/httpapi/httpapi.go +@@ -6,9 +6,11 @@ import ( + "embed" + "encoding/json" + "errors" ++ "fmt" + "io" + "log" + "net/http" ++ "os" + "strconv" + "strings" + "sync/atomic" +@@ -25,13 +27,14 @@ import ( + var webFS embed.FS + + type Server struct { +- store *store.Store +- brain *brain.Engine +- router *provider.Router +- cost *cost.Manager +- mux *http.ServeMux +- metrics *metricsRegistry +- inflight atomic.Int64 ++ store *store.Store ++ brain *brain.Engine ++ router *provider.Router ++ cost *cost.Manager ++ mux *http.ServeMux ++ metrics *metricsRegistry ++ inflight atomic.Int64 ++ readinessOllamaLive bool + } + + func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *Server { +@@ -39,6 +42,8 @@ func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) * + x.routes() + return x + } ++ ++func (s *Server) SetReadinessOllamaLive(enabled bool) { s.readinessOllamaLive = enabled } + func (s *Server) Handler() http.Handler { + var h http.Handler = s.mux + h = s.requestLimits(h) +@@ -61,7 +66,7 @@ func (s *Server) routes() { + s.mux.Handle("POST /api/v1/search/vector", s.appAuth(http.HandlerFunc(s.searchVector))) + s.mux.Handle("POST /api/v1/memory/import", s.appAuth(http.HandlerFunc(s.importMemory))) + s.mux.Handle("POST /api/v1/feedback", s.appAuth(http.HandlerFunc(s.feedback))) +- s.mux.Handle("GET /api/v1/stats", s.appAuth(http.HandlerFunc(s.stats))) ++ s.mux.Handle("GET /api/v1/stats", s.controlReadAuth(http.HandlerFunc(s.stats))) + s.mux.Handle("GET /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsList))) + s.mux.Handle("POST /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsCreate))) + s.mux.Handle("GET /api/v1/goals/{id}", s.appAuth(http.HandlerFunc(s.goalsGet))) +@@ -79,14 +84,14 @@ func (s *Server) routes() { + s.mux.Handle("GET /api/v1/sources", s.appAuth(http.HandlerFunc(s.sourcesList))) + s.mux.Handle("GET /api/v1/sources/{id}", s.appAuth(http.HandlerFunc(s.sourceGet))) + s.mux.Handle("POST /api/v1/research", s.appAuth(http.HandlerFunc(s.researchSearch))) +- s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.appAuth(http.HandlerFunc(s.integrationKnowledgeUpsert))) +- s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.appAuth(http.HandlerFunc(s.integrationKnowledgeDelete))) +- s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.appAuth(http.HandlerFunc(s.integrationKnowledgeSearch))) +- s.mux.Handle("POST /api/v1/integrations/events", s.appAuth(http.HandlerFunc(s.integrationEvent))) +- s.mux.Handle("POST /api/v1/integrations/outcomes", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcome))) +- s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch))) +- s.mux.Handle("GET /api/v1/integrations/graph/research", s.appAuth(http.HandlerFunc(s.integrationResearchGraph))) +- s.mux.Handle("GET /api/v1/integrations/graph/brain", s.appAuth(http.HandlerFunc(s.integrationBrainGraph))) ++ s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeUpsert))) ++ s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeDelete))) ++ s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeSearch))) ++ s.mux.Handle("POST /api/v1/integrations/events", s.integrationAuth(http.HandlerFunc(s.integrationEvent))) ++ s.mux.Handle("POST /api/v1/integrations/outcomes", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcome))) ++ s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch))) ++ s.mux.Handle("GET /api/v1/integrations/graph/research", s.controlReadAuth(http.HandlerFunc(s.integrationResearchGraph))) ++ s.mux.Handle("GET /api/v1/integrations/graph/brain", s.controlReadAuth(http.HandlerFunc(s.integrationBrainGraph))) + + s.mux.Handle("POST /internal/v1/cluster/request-vote", s.clusterAuth(http.HandlerFunc(s.clusterRequestVote))) + s.mux.Handle("POST /internal/v1/cluster/heartbeat", s.clusterAuth(http.HandlerFunc(s.clusterHeartbeat))) +@@ -232,6 +237,33 @@ func (s *Server) appAuth(next http.Handler) http.Handler { + next.ServeHTTP(w, r) + }) + } ++func (s *Server) integrationAuth(next http.Handler) http.Handler { ++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ sec := s.store.Secrets() ++ adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken) ++ integrationOK := secureEqual(bearer(r), sec.IntegrationToken) ++ if !adminOK && !integrationOK { ++ s.err(w, http.StatusUnauthorized, errors.New("invalid integration token or admin token")) ++ return ++ } ++ next.ServeHTTP(w, r) ++ }) ++} ++ ++func (s *Server) controlReadAuth(next http.Handler) http.Handler { ++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ sec := s.store.Secrets() ++ adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken) ++ controlOK := secureEqual(bearer(r), sec.ControlReadToken) ++ appOK := secureEqual(bearer(r), sec.AppAPIKey) ++ if !adminOK && !controlOK && !appOK { ++ s.err(w, http.StatusUnauthorized, errors.New("invalid control/app read token or admin token")) ++ return ++ } ++ next.ServeHTTP(w, r) ++ }) ++} ++ + func (s *Server) workerAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !secureEqual(bearer(r), s.store.Secrets().WorkerToken) { +@@ -571,7 +603,7 @@ func (s *Server) adminPutModelRouting(w http.ResponseWriter, r *http.Request) { + + func (s *Server) adminSecretsStatus(w http.ResponseWriter, r *http.Request) { + sec := s.store.Secrets() +- s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""}) ++ s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""}) + } + func maskedSecret(v string) string { + if v == "" { +@@ -586,35 +618,61 @@ func (s *Server) adminGetSecrets(w http.ResponseWriter, r *http.Request) { + sec := s.store.Secrets() + reveal := r.URL.Query().Get("reveal") == "1" && s.store.Config().Security.AllowSecretReveal + if reveal { +- s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken}) ++ s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken}) + return + } + maskedShards := map[string]string{} + for k, v := range sec.ShardAPIToken { + maskedShards[k] = maskedSecret(v) + } +- s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)}) ++ s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)}) + } + func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) { + var q struct { +- OpenAIAPIKey string `json:"openai_api_key,omitempty"` +- AppAPIKey string `json:"app_api_key,omitempty"` +- WorkerToken string `json:"worker_token,omitempty"` +- MetricsToken string `json:"metrics_token,omitempty"` +- ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"` +- ClusterToken string `json:"cluster_token,omitempty"` ++ OpenAIAPIKey string `json:"openai_api_key,omitempty"` ++ AppAPIKey string `json:"app_api_key,omitempty"` ++ IntegrationToken string `json:"integration_token,omitempty"` ++ ControlReadToken string `json:"control_read_token,omitempty"` ++ WorkerToken string `json:"worker_token,omitempty"` ++ MetricsToken string `json:"metrics_token,omitempty"` ++ ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"` ++ ClusterToken string `json:"cluster_token,omitempty"` + } + if err := decode(r, &q); err != nil { + s.err(w, 400, err) + return + } + sec := s.store.Secrets() ++ envLocked := func(name string) bool { ++ _, ok := os.LookupEnv(name) ++ return ok && strings.TrimSpace(os.Getenv(name)) != "" ++ } ++ for name, value := range map[string]string{ ++ "OPENAI_API_KEY": q.OpenAIAPIKey, ++ "NEUROFORGE_APP_API_KEY": q.AppAPIKey, ++ "NEUROFORGE_INTEGRATION_TOKEN": q.IntegrationToken, ++ "NEUROFORGE_CONTROL_READ_TOKEN": q.ControlReadToken, ++ "NEUROFORGE_WORKER_TOKEN": q.WorkerToken, ++ "NEUROFORGE_METRICS_TOKEN": q.MetricsToken, ++ "NEUROFORGE_CLUSTER_TOKEN": q.ClusterToken, ++ } { ++ if value != "" && envLocked(name) { ++ s.err(w, http.StatusConflict, fmt.Errorf("%s is environment-managed and cannot be changed through the admin API", name)) ++ return ++ } ++ } + if q.OpenAIAPIKey != "" { + sec.OpenAIAPIKey = q.OpenAIAPIKey + } + if q.AppAPIKey != "" { + sec.AppAPIKey = q.AppAPIKey + } ++ if q.IntegrationToken != "" { ++ sec.IntegrationToken = q.IntegrationToken ++ } ++ if q.ControlReadToken != "" { ++ sec.ControlReadToken = q.ControlReadToken ++ } + if q.WorkerToken != "" { + sec.WorkerToken = q.WorkerToken + } +@@ -737,6 +795,67 @@ func (s *Server) livez(w http.ResponseWriter, r *http.Request) { + s.json(w, http.StatusOK, map[string]any{"ok": true, "status": "alive", "time": time.Now().UTC(), "version": "0.8.2"}) + } + ++func configuredModelAvailable(models map[string]bool, configured string) bool { ++ configured = strings.TrimSpace(configured) ++ if configured == "" { ++ return false ++ } ++ if models[configured] { ++ return true ++ } ++ if !strings.Contains(configured, ":") && models[configured+":latest"] { ++ return true ++ } ++ return false ++} ++ ++func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, any) { ++ type tagsResponse struct { ++ Models []struct { ++ Name string `json:"name"` ++ } `json:"models"` ++ } ++ details := map[string]any{} ++ anyEnabled := false ++ for _, node := range cfg.Ollama { ++ if !node.Enabled || strings.TrimSpace(node.BaseURL) == "" { ++ continue ++ } ++ anyEnabled = true ++ req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(node.BaseURL, "/")+"/api/tags", nil) ++ if err != nil { ++ details[node.ID] = err.Error() ++ continue ++ } ++ resp, err := http.DefaultClient.Do(req) ++ if err != nil { ++ details[node.ID] = err.Error() ++ continue ++ } ++ var tags tagsResponse ++ decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags) ++ resp.Body.Close() ++ if resp.StatusCode < 200 || resp.StatusCode >= 300 || decodeErr != nil { ++ details[node.ID] = fmt.Sprintf("HTTP %d / invalid tags response", resp.StatusCode) ++ continue ++ } ++ models := map[string]bool{} ++ for _, model := range tags.Models { ++ models[strings.TrimSpace(model.Name)] = true ++ } ++ chatOK := configuredModelAvailable(models, node.ChatModel) ++ embedOK := configuredModelAvailable(models, node.EmbeddingModel) ++ details[node.ID] = map[string]any{"reachable": true, "chat_model": node.ChatModel, "chat_present": chatOK, "embedding_model": node.EmbeddingModel, "embedding_present": embedOK} ++ if chatOK && embedOK { ++ return true, details ++ } ++ } ++ if !anyEnabled { ++ return false, map[string]any{"error": "no enabled Ollama node configured"} ++ } ++ return false, details ++} ++ + func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { + cfg := s.store.Config() + sec := s.store.Secrets() +@@ -764,7 +883,26 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { + components["embedding_route_configured"] = embedReady + clusterReady := !cfg.Cluster.Enabled || obs.ClusterLeaderID != "" + components["cluster"] = clusterReady +- ready := configOK && chatReady && embedReady && clusterReady && (!cfg.API.RequireKey || sec.AppAPIKey != "") ++ ollamaLiveReady := true ++ if s.readinessOllamaLive { ++ ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) ++ var detail any ++ ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg) ++ cancel() ++ components["ollama_live_models"] = detail ++ } ++ stagingReady := true ++ if s.brain != nil { ++ ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) ++ if err := s.brain.CheckStagingPublisher(ctx); err != nil { ++ stagingReady = false ++ components["kb_staging"] = err.Error() ++ } else { ++ components["kb_staging"] = true ++ } ++ cancel() ++ } ++ ready := configOK && chatReady && embedReady && clusterReady && ollamaLiveReady && stagingReady && (!cfg.API.RequireKey || sec.AppAPIKey != "") + status := http.StatusOK + if !ready { + status = http.StatusServiceUnavailable +diff --git a/platform/neuroforge/internal/httpapi/integration_api_test.go b/platform/neuroforge/internal/httpapi/integration_api_test.go +index 56b4dc8e06ee12134711a611511f3c1fd8d2e2ba..c52007913ee706520f99a19c92f4715899f4ed50 100644 +--- a/platform/neuroforge/internal/httpapi/integration_api_test.go ++++ b/platform/neuroforge/internal/httpapi/integration_api_test.go +@@ -24,7 +24,7 @@ func integrationRequest(t *testing.T, s *Server, method, path, token, body strin + + func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) { + s, _ := newMetricsTestServer(t) +- key := s.store.Secrets().AppAPIKey ++ key := s.store.Secrets().IntegrationToken + + unauth := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/knowledge/upsert", "", `{"namespace":"agent","document_id":"KB-1","chunks":[{"index":0,"text":"vpn","vector":[1,0],"content_hash":"a"}]}`) + if unauth.Code != http.StatusUnauthorized { +@@ -107,3 +107,27 @@ func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) { + t.Fatalf("deleted document still searchable: %s", search.Body.String()) + } + } ++ ++func TestScopedTokensCannotCrossTrustBoundaries(t *testing.T) { ++ s, _ := newMetricsTestServer(t) ++ sec := s.store.Secrets() ++ ++ // The general App key is deliberately insufficient for integration writes. ++ if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.AppAPIKey, `{"type":"test","source":"agent","message":"x"}`); rr.Code != http.StatusUnauthorized { ++ t.Fatalf("app key wrote integration event: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++ // Control is read-only and cannot write integration events or generic learn. ++ if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.ControlReadToken, `{"type":"test","source":"control","message":"x"}`); rr.Code != http.StatusUnauthorized { ++ t.Fatalf("control token wrote integration event: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++ if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.ControlReadToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized { ++ t.Fatalf("control token reached /learn: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++ // Integration credentials are not generic app credentials either. ++ if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.IntegrationToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized { ++ t.Fatalf("integration token reached /learn: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++ if rr := integrationRequest(t, s, http.MethodGet, "/api/v1/stats", sec.ControlReadToken, ""); rr.Code != http.StatusOK { ++ t.Fatalf("control token cannot read stats: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++} +diff --git a/platform/neuroforge/internal/httpapi/integration_graph_test.go b/platform/neuroforge/internal/httpapi/integration_graph_test.go +index bdc97c6ec7dd13a3d900fe2b8350fe4ec5e86c6f..a5c81197b9a41475cd0388a91b2c46390298200b 100644 +--- a/platform/neuroforge/internal/httpapi/integration_graph_test.go ++++ b/platform/neuroforge/internal/httpapi/integration_graph_test.go +@@ -11,16 +11,16 @@ import ( + "neuroforge/internal/core" + ) + +-func appGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { ++func controlGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) +- req.Header.Set("Authorization", "Bearer "+s.store.Secrets().AppAPIKey) ++ req.Header.Set("Authorization", "Bearer "+s.store.Secrets().ControlReadToken) + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + return rr + } + +-func TestIntegrationGraphEndpointsRequireAppKey(t *testing.T) { ++func TestIntegrationGraphEndpointsRequireControlReadAuth(t *testing.T) { + s, _ := newMetricsTestServer(t) + for _, path := range []string{"/api/v1/integrations/graph/brain", "/api/v1/integrations/graph/research"} { + req := httptest.NewRequest(http.MethodGet, path, nil) +@@ -38,7 +38,7 @@ func TestIntegrationBrainGraphIsBoundedAndRedacted(t *testing.T) { + if err := s.store.AddMemory(m); err != nil { + t.Fatal(err) + } +- rr := appGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50") ++ rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50") + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } +@@ -72,7 +72,7 @@ func TestIntegrationResearchGraphShowsProvenanceChain(t *testing.T) { + if _, err := s.store.FinishResearchRun(run.ID, "completed", ""); err != nil { + t.Fatal(err) + } +- rr := appGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50") ++ rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50") + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } +diff --git a/platform/neuroforge/internal/httpapi/knowledge_policy_test.go b/platform/neuroforge/internal/httpapi/knowledge_policy_test.go +index d58cb72f1c4d802d4de020c7d5ee588bf7494ed3..df50b03183bca363b03bc5d69bde7a8643f38c75 100644 +--- a/platform/neuroforge/internal/httpapi/knowledge_policy_test.go ++++ b/platform/neuroforge/internal/httpapi/knowledge_policy_test.go +@@ -68,3 +68,60 @@ func TestReadinessEndpoint(t *testing.T) { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + } ++ ++func TestReadinessLiveOllamaRequiresChatAndEmbeddingModels(t *testing.T) { ++ models := []string{"gemma3:latest"} ++ ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ if r.URL.Path != "/api/tags" { ++ http.NotFound(w, r) ++ return ++ } ++ items := make([]map[string]string, 0, len(models)) ++ for _, name := range models { ++ items = append(items, map[string]string{"name": name}) ++ } ++ _ = json.NewEncoder(w).Encode(map[string]any{"models": items}) ++ })) ++ defer ollama.Close() ++ ++ s, _ := newMetricsTestServer(t) ++ cfg := s.store.Config() ++ cfg.Ollama[0].BaseURL = ollama.URL ++ cfg.Ollama[0].ChatModel = "gemma3" ++ cfg.Ollama[0].EmbeddingModel = "embeddinggemma" ++ if err := s.store.UpdateConfig(cfg); err != nil { ++ t.Fatal(err) ++ } ++ s.SetReadinessOllamaLive(true) ++ ++ req := httptest.NewRequest(http.MethodGet, "/readyz", nil) ++ rr := httptest.NewRecorder() ++ s.Handler().ServeHTTP(rr, req) ++ if rr.Code != http.StatusServiceUnavailable { ++ t.Fatalf("missing embedding model: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++ if !strings.Contains(rr.Body.String(), `"embedding_present":false`) { ++ t.Fatalf("readiness does not expose missing embedding model: %s", rr.Body.String()) ++ } ++ ++ models = append(models, "embeddinggemma:latest") ++ rr = httptest.NewRecorder() ++ s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil)) ++ if rr.Code != http.StatusOK { ++ t.Fatalf("both models present: status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++} ++ ++func TestEnvironmentManagedSecretCannotBeRotatedThroughAdminAPI(t *testing.T) { ++ s, _ := newMetricsTestServer(t) ++ t.Setenv("NEUROFORGE_APP_API_KEY", "environment-owned-app-key-1234567890") ++ admin := s.store.Secrets().AdminToken ++ req := httptest.NewRequest(http.MethodPut, "/admin/api/secrets", strings.NewReader(`{"app_api_key":"different-runtime-value-1234567890"}`)) ++ req.Header.Set("X-Admin-Token", admin) ++ req.Header.Set("Content-Type", "application/json") ++ rr := httptest.NewRecorder() ++ s.Handler().ServeHTTP(rr, req) ++ if rr.Code != http.StatusConflict { ++ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++} +diff --git a/platform/neuroforge/internal/httpapi/outcomes_test.go b/platform/neuroforge/internal/httpapi/outcomes_test.go +index 9f41efcf85eba7d03a82b5d5c3fed1ace0d40733..f25c1477e0ab26a0b0cacbf2e57966a912004e60 100644 +--- a/platform/neuroforge/internal/httpapi/outcomes_test.go ++++ b/platform/neuroforge/internal/httpapi/outcomes_test.go +@@ -33,7 +33,7 @@ func TestValidatedOutcomeLearnsTrustedProvenance(t *testing.T) { + + body := `{"outcome_id":"out-1","run_id":"run-1","ticket_id":42,"decision":"accepted","ticket_input":"VPN verbindet nicht","proposed_reply":"VPN Client neu starten","confirmed_reply":"VPN Client neu starten","category_id":5,"category_name":"VPN","knowledge_id":"kb-vpn","actor":"tech-a"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body)) +- req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) ++ req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) +@@ -62,7 +62,7 @@ func TestValidatedOutcomeRejectsUnconfirmedDecision(t *testing.T) { + s, _ := newMetricsTestServer(t) + sec := s.store.Secrets() + req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(`{"outcome_id":"o","run_id":"r","ticket_id":1,"decision":"rejected","ticket_input":"x","confirmed_reply":"y","actor":"tech"}`)) +- req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) ++ req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) +@@ -101,7 +101,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev + sec := s.store.Secrets() + post := func(body string) map[string]any { + req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body)) +- req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) ++ req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) +@@ -139,7 +139,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev + } + + search := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes/search", strings.NewReader(`{"text":"Drucker korrigierte Loesung","k":10,"min_similarity":0}`)) +- search.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) ++ search.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) + search.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, search) +diff --git a/platform/neuroforge/internal/httpapi/v3.go b/platform/neuroforge/internal/httpapi/v3.go +index a05673c8e61290c48d2f2a9f42284e75f48aadd3..8c2b8b4f5edfd46f1e355a63bd81c622654f4cad 100644 +--- a/platform/neuroforge/internal/httpapi/v3.go ++++ b/platform/neuroforge/internal/httpapi/v3.go +@@ -7,6 +7,7 @@ import ( + "strconv" + "time" + ++ "neuroforge/internal/brain" + "neuroforge/internal/core" + ) + +@@ -91,7 +92,11 @@ func (s *Server) goalResume(w http.ResponseWriter, r *http.Request) { + func (s *Server) goalCycle(w http.ResponseWriter, r *http.Request) { + cycle, err := s.brain.RunGoalCycle(r.Context(), r.PathValue("id")) + if err != nil { +- s.err(w, 400, err) ++ if errors.Is(err, brain.ErrGoalCycleInProgress) { ++ s.err(w, http.StatusConflict, err) ++ return ++ } ++ s.err(w, http.StatusBadRequest, err) + return + } + s.json(w, 200, cycle) +diff --git a/platform/neuroforge/internal/store/store.go b/platform/neuroforge/internal/store/store.go +index ad9d7917c2214078d8072ce7ab78bdd34c8c415f..4cf756773004cce7fc0323ec81b39c6ee3fc6155 100644 +--- a/platform/neuroforge/internal/store/store.go ++++ b/platform/neuroforge/internal/store/store.go +@@ -177,6 +177,12 @@ func New(dir string) (*Store, error) { + if s.secrets.AppAPIKey == "" { + s.secrets.AppAPIKey = randomID(24) + } ++ if s.secrets.IntegrationToken == "" { ++ s.secrets.IntegrationToken = randomID(24) ++ } ++ if s.secrets.ControlReadToken == "" { ++ s.secrets.ControlReadToken = randomID(24) ++ } + if s.secrets.WorkerToken == "" { + s.secrets.WorkerToken = randomID(24) + } +diff --git a/scripts/backup-data.sh b/scripts/backup-data.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..8775933eee202fb2c37cc1dd790872dd41c02865 +--- /dev/null ++++ b/scripts/backup-data.sh +@@ -0,0 +1,33 @@ ++#!/bin/sh ++set -eu ++ENV_FILE=${ENV_FILE:-.env} ++DEST_ROOT=${1:-./release-backups} ++[ -f "$ENV_FILE" ] || { echo "backup: missing $ENV_FILE" >&2; exit 1; } ++getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r'; } ++tag=$(getv IMAGE_TAG) ++stamp=$(date -u +%Y%m%dT%H%M%SZ) ++dest="$DEST_ROOT/$stamp" ++mkdir -p "$dest" ++ ++./scripts/preflight.sh ++restart() { docker compose --env-file "$ENV_FILE" up -d knowledge neuroforge neuroforge-worker agent >/dev/null 2>&1 || true; } ++trap restart EXIT INT TERM ++ ++echo "backup: entering short maintenance stop" ++docker compose --env-file "$ENV_FILE" stop neuroforge-worker agent neuroforge knowledge ++docker compose --env-file "$ENV_FILE" create neuroforge agent >/dev/null ++mkdir -p "$dest/neuroforge-data" "$dest/agent-data" "$dest/knowledge" "$dest/staging" "$dest/knowledge-backups" ++docker compose --env-file "$ENV_FILE" cp neuroforge:/app/data/. "$dest/neuroforge-data/" ++docker compose --env-file "$ENV_FILE" cp agent:/app/data/. "$dest/agent-data/" ++ ++copy_host() { ++ src=$1; out=$2 ++ [ -d "$src" ] || return 0 ++ cp -a "$src"/. "$out"/ ++} ++copy_host "$(getv KB_DATA_PATH)" "$dest/knowledge" ++copy_host "$(getv KB_STAGING_PATH)" "$dest/staging" ++copy_host "$(getv KB_BACKUP_PATH)" "$dest/knowledge-backups" ++printf 'IMAGE_TAG=%s\nCREATED_AT=%s\n' "$tag" "$stamp" > "$dest/MANIFEST" ++( cd "$dest" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS ) ++echo "backup: verified snapshot written to $dest" +diff --git a/scripts/check-compose-env.py b/scripts/check-compose-env.py +new file mode 100755 +index 0000000000000000000000000000000000000000..5c9cd44e74b7dd2f8de1aac0c6ab0fee0951cad8 +--- /dev/null ++++ b/scripts/check-compose-env.py +@@ -0,0 +1,60 @@ ++#!/usr/bin/env python3 ++"""Fail if production Compose broadens Agent secrets or misses runtime env knobs.""" ++from __future__ import annotations ++import re ++import sys ++from pathlib import Path ++ ++try: ++ import yaml ++except Exception as exc: # PyYAML is intentionally only a release-dev dependency. ++ print(f"compose-env-check: PyYAML unavailable: {exc}", file=sys.stderr) ++ raise SystemExit(2) ++ ++root = Path(__file__).resolve().parents[1] ++compose = yaml.safe_load((root / "docker-compose.yml").read_text()) ++agent = compose["services"]["agent"] ++if "env_file" in agent: ++ raise SystemExit("compose-env-check: production Agent must not use env_file") ++actual = set((agent.get("environment") or {}).keys()) ++ ++# Canonical host-config variables are those documented in .env.example and ++# literally referenced by non-test Agent runtime source. Internal service values ++# are added separately below. ++documented: set[str] = set() ++for line in (root / ".env.example").read_text().splitlines(): ++ match = re.match(r"([A-Z][A-Z0-9_]+)=", line) ++ if match: ++ documented.add(match.group(1)) ++source = "\n".join( ++ p.read_text(errors="ignore") ++ for p in (root / "services/agent").rglob("*.go") ++ if not p.name.endswith("_test.go") ++) ++expected = {name for name in documented if re.search(rf'"{re.escape(name)}"', source)} ++expected.update({"NEUROFORGE_URL", "NEUROFORGE_API_KEY", "BRAIN_ACTIVITY_URL", "BRAIN_ACTIVITY_API_KEY"}) ++missing = sorted(expected - actual) ++if missing: ++ print("compose-env-check: Agent runtime variables missing from explicit environment:", file=sys.stderr) ++ print("\n".join(f" {name}" for name in missing), file=sys.stderr) ++ raise SystemExit(1) ++ ++forbidden = { ++ "NEUROFORGE_ADMIN_TOKEN", ++ "NEUROFORGE_WORKER_TOKEN", ++ "NEUROFORGE_METRICS_TOKEN", ++ "NEUROFORGE_CLUSTER_TOKEN", ++ "NEUROFORGE_CONTROL_READ_TOKEN", ++ "KB_INTEGRATION_TOKEN", ++ "BASIC_AUTH_PASSWORD", ++ "CONTROL_BASIC_AUTH_PASSWORD", ++} ++leaked = sorted(forbidden & actual) ++if leaked: ++ print("compose-env-check: unrelated privileged secrets exposed to Agent: " + ", ".join(leaked), file=sys.stderr) ++ raise SystemExit(1) ++ ++knowledge = compose["services"]["knowledge"] ++if "env_file" in knowledge: ++ raise SystemExit("compose-env-check: production Knowledge must not use env_file") ++print(f"compose-env-check: passed ({len(actual)} explicit Agent environment entries)") +diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh +index 0d36db04e9233144bc7e535bf350f119c6253e7c..1990de9ee85359948a705b0b88605e1c3bdb7001 100755 +--- a/scripts/generate-secrets.sh ++++ b/scripts/generate-secrets.sh +@@ -7,6 +7,8 @@ cat <&2; exit 1; } ++info() { echo "preflight: $*"; } ++ ++[ -f docker-compose.yml ] || fail "run from the repository root" ++ ++# Production compose must never silently fall back to source builds or broad .env injection. ++if grep -Eq '^[[:space:]]+build:' docker-compose.yml; then fail "production docker-compose.yml contains build:"; fi ++if grep -Eq '^[[:space:]]+env_file:' docker-compose.yml; then fail "production docker-compose.yml contains env_file:"; fi ++for image in neuroforge neuroforge-worker agent agent-data-init knowledge control; do ++ grep -Fq "git.send.nrw/sendnrw/glpi-neuroforge-mega-${image}:\${IMAGE_TAG:" docker-compose.yml || fail "registry image mapping missing for ${image}" ++done ++ ++if [ "$STATIC_ONLY" = true ]; then ++ if grep -Eq '^IMAGE_TAG[[:space:]]*=[[:space:]]*latest([[:space:]]|$)' .env.example; then fail ".env.example sets IMAGE_TAG=latest"; fi ++ info "static compose/source checks passed" ++ exit 0 ++fi ++ ++[ -f "$ENV_FILE" ] || fail "$ENV_FILE not found (copy .env.example and replace every placeholder)" ++getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } ++check_not_placeholder() { ++ name=$1; value=$(getv "$name") ++ [ -n "$value" ] || fail "$name is empty" ++ upper=$(printf '%s' "$value" | tr '[:lower:]' '[:upper:]') ++ case "$upper" in *CHANGE_ME*|*CHANGEME*|*PLACEHOLDER*) fail "$name still contains a placeholder";; esac ++} ++check_secret() { ++ name=$1; min=$2 ++ check_not_placeholder "$name" ++ value=$(getv "$name") ++ [ "${#value}" -ge "$min" ] || fail "$name must contain at least $min characters" ++} ++ ++tag=$(getv IMAGE_TAG) ++[ -n "$tag" ] || fail "IMAGE_TAG is empty" ++[ "$tag" != latest ] || fail "IMAGE_TAG=latest is forbidden for production" ++case "$tag" in *[!A-Za-z0-9._-]*) fail "IMAGE_TAG contains invalid characters";; esac ++ ++for spec in \ ++ NEUROFORGE_ADMIN_TOKEN:24 \ ++ NEUROFORGE_APP_API_KEY:24 \ ++ NEUROFORGE_INTEGRATION_TOKEN:24 \ ++ NEUROFORGE_CONTROL_READ_TOKEN:24 \ ++ NEUROFORGE_WORKER_TOKEN:24 \ ++ NEUROFORGE_METRICS_TOKEN:24 \ ++ KB_INTEGRATION_TOKEN:24 \ ++ CONTROL_READ_TOKEN:24 \ ++ BASIC_AUTH_PASSWORD:12 \ ++ CONTROL_BASIC_AUTH_PASSWORD:12 \ ++ WEB_PASSWORD:12; do ++ check_secret "${spec%%:*}" "${spec##*:}" ++done ++ ++for name in GLPI_URL GLPI_CLIENT_ID GLPI_CLIENT_SECRET GLPI_USERNAME GLPI_PASSWORD; do ++ check_not_placeholder "$name" ++done ++web_anon=$(printf '%s' "$(getv WEB_ALLOW_ANONYMOUS)" | tr '[:upper:]' '[:lower:]') ++if [ "$web_anon" != "true" ]; then ++ check_not_placeholder WEB_USERNAME ++fi ++research=$(printf '%s' "$(getv NEUROFORGE_SEARXNG_ENABLED)" | tr '[:upper:]' '[:lower:]') ++if [ "$research" = "true" ]; then ++ check_secret SEARXNG_SECRET 24 ++fi ++ ++# Trust-boundary tokens must not be reused across roles. ++seen='' ++for name in NEUROFORGE_ADMIN_TOKEN NEUROFORGE_APP_API_KEY NEUROFORGE_INTEGRATION_TOKEN NEUROFORGE_CONTROL_READ_TOKEN NEUROFORGE_WORKER_TOKEN NEUROFORGE_METRICS_TOKEN KB_INTEGRATION_TOKEN CONTROL_READ_TOKEN; do ++ value=$(getv "$name") ++ case "|$seen|" in *"|$value|"*) fail "$name reuses another service token";; esac ++ seen=${seen:+$seen|}$value ++done ++ ++command -v docker >/dev/null 2>&1 || fail "docker is not installed" ++docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is unavailable" ++docker compose --env-file "$ENV_FILE" config -q || fail "docker compose config validation failed" ++images=$(docker compose --env-file "$ENV_FILE" config --images) ++for image in neuroforge neuroforge-worker agent agent-data-init knowledge control; do ++ expected="git.send.nrw/sendnrw/glpi-neuroforge-mega-${image}:${tag}" ++ printf '%s\n' "$images" | grep -Fxq "$expected" || fail "resolved image missing: $expected" ++done ++printf '%s\n' "$images" | grep -E 'git\.send\.nrw/sendnrw/glpi-neuroforge-mega-.*:latest$' >/dev/null && fail "a project image resolved to latest" ++info "production preflight passed for IMAGE_TAG=$tag" +diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..e2c2cf9a99847b950b29a9557656016f196e8e71 +--- /dev/null ++++ b/scripts/release-gate.sh +@@ -0,0 +1,23 @@ ++#!/bin/sh ++set -eu ++ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) ++cd "$ROOT" ++./scripts/preflight.sh --static ++./scripts/check-compose-env.py ++./scripts/secret-scan.sh ++for module in platform/neuroforge services/agent services/knowledge services/control; do ++ echo "release-gate: $module: test" ++ (cd "$module" && go test ./...) ++ echo "release-gate: $module: vet" ++ (cd "$module" && go vet ./...) ++ echo "release-gate: $module: build" ++ (cd "$module" && go build ./...) ++done ++ ++echo "release-gate: targeted race checks" ++(cd platform/neuroforge && go test -race ./internal/store ./internal/brain ./internal/httpapi) ++(cd services/agent && go test -race ./internal/state ./internal/knowledge ./internal/learning ./internal/agent) ++(cd services/knowledge && go test -race ./internal/store ./internal/staging ./cmd/server) ++(cd services/control && go test -race .) ++ ++echo "release-gate: passed local source gates" +diff --git a/scripts/research-up.sh b/scripts/research-up.sh +index da3cf46781ea74ff91691daa4f1ec69e89c1fb33..0403c8a52defbdcd0f0e1186ef31acd5edd383ce 100755 +--- a/scripts/research-up.sh ++++ b/scripts/research-up.sh +@@ -15,5 +15,5 @@ export SEARXNG_SECRET=$secret + cd "$ROOT" + NEUROFORGE_RESEARCH_ENABLED=true \ + NEUROFORGE_SEARXNG_ENABLED=true \ +-docker compose --profile research up -d searxng neuroforge neuroforge-worker ++docker compose --profile research up -d ollama knowledge searxng neuroforge neuroforge-worker + printf '%s\n' 'SearXNG + NeuroForge research are running. Autonomy remains controlled by NEUROFORGE_AUTONOMY_ENABLED.' +diff --git a/scripts/restore-data.sh b/scripts/restore-data.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..8038c6ed9eb3dd016b0254fcc5909674f0092ac8 +--- /dev/null ++++ b/scripts/restore-data.sh +@@ -0,0 +1,42 @@ ++#!/bin/sh ++set -eu ++ENV_FILE=${ENV_FILE:-.env} ++SNAPSHOT=${1:-} ++HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-busybox:1.36} ++[ -n "$SNAPSHOT" ] && [ -d "$SNAPSHOT" ] || { echo "usage: $0 SNAPSHOT_DIR" >&2; exit 2; } ++[ -f "$ENV_FILE" ] || { echo "restore: missing $ENV_FILE" >&2; exit 1; } ++( cd "$SNAPSHOT" && sha256sum -c SHA256SUMS ) ++./scripts/preflight.sh ++getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r'; } ++ ++restart() { docker compose --env-file "$ENV_FILE" up -d knowledge neuroforge neuroforge-worker agent >/dev/null 2>&1 || true; } ++trap restart EXIT INT TERM ++ ++docker compose --env-file "$ENV_FILE" stop neuroforge-worker agent neuroforge knowledge ++docker compose --env-file "$ENV_FILE" create neuroforge agent >/dev/null ++volume_for() { ++ service=$1; destination=$2 ++ cid=$(docker compose --env-file "$ENV_FILE" ps -aq "$service") ++ [ -n "$cid" ] || return 1 ++ docker inspect -f "{{range .Mounts}}{{if eq .Destination \"$destination\"}}{{.Name}}{{end}}{{end}}" "$cid" ++} ++restore_volume() { ++ service=$1; destination=$2; source=$3 ++ volume=$(volume_for "$service" "$destination") ++ [ -n "$volume" ] || { echo "restore: volume for $service:$destination not found" >&2; exit 1; } ++ src=$(CDPATH= cd -- "$source" && pwd) ++ docker run --rm -v "$volume:/target" -v "$src:/source:ro" "$HELPER_IMAGE" sh -eu -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cp -a /source/. /target/' ++} ++restore_volume neuroforge /app/data "$SNAPSHOT/neuroforge-data" ++restore_volume agent /app/data "$SNAPSHOT/agent-data" ++restore_host() { ++ src=$1; dst=$2 ++ [ -d "$src" ] || return 0 ++ mkdir -p "$dst" ++ find "$dst" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + ++ cp -a "$src"/. "$dst"/ ++} ++restore_host "$SNAPSHOT/knowledge" "$(getv KB_DATA_PATH)" ++restore_host "$SNAPSHOT/staging" "$(getv KB_STAGING_PATH)" ++restore_host "$SNAPSHOT/knowledge-backups" "$(getv KB_BACKUP_PATH)" ++echo "restore: snapshot restored; services will be restarted" +diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh +new file mode 100755 +index 0000000000000000000000000000000000000000..f16be47d3c39ef8665dd699389459bde07c888b9 +--- /dev/null ++++ b/scripts/secret-scan.sh +@@ -0,0 +1,60 @@ ++#!/bin/sh ++set -eu ++fail=0 ++bad() { echo "secret-scan: ERROR: $*" >&2; fail=1; } ++ ++ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) ++cd "$ROOT" ++TMP_BASE=${TMPDIR:-/tmp}/neuroforge-secret-scan.$$ ++FILES="$TMP_BASE.files" ++KEYS="$TMP_BASE.keys" ++TOKENS="$TMP_BASE.tokens" ++trap 'rm -f "$FILES" "$KEYS" "$TOKENS"' EXIT HUP INT TERM ++ ++# Release archives intentionally do not contain .git. Use Git's tracked-file view ++# when available, otherwise scan every regular file in the extracted release. ++if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then ++ git ls-files > "$FILES" ++ HAVE_GIT=true ++else ++ find . -type f ! -path './.git/*' -print | sed 's#^\./##' | sort > "$FILES" ++ HAVE_GIT=false ++fi ++ ++# Private keys and common live-token shapes must not be committed. Placeholders in ++# templates/docs are intentionally allowed. ++if grep -E '(^|/)\.env$|\.pem$|\.p12$|\.pfx$|(^|/)id_rsa$|(^|/)id_ed25519$' "$FILES" >/dev/null; then ++ bad "private environment/key material found" ++fi ++ ++if [ "$HAVE_GIT" = true ]; then ++ if git grep -nE -- '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' -- ':!*.example' ':!*.md' >"$KEYS" 2>/dev/null; then ++ cat "$KEYS" >&2; bad "private key material found" ++ fi ++ if git grep -nE -- '(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{24,})' -- ':!*.example' >"$TOKENS" 2>/dev/null; then ++ cat "$TOKENS" >&2; bad "token-like credential found" ++ fi ++else ++ # Fallback for release ZIPs: recurse through extracted source while preserving ++ # the same exclusions as the Git-backed scan. ++ if grep -RInE --exclude='*.example' --exclude='*.md' --exclude-dir='.git' -- \ ++ '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' . >"$KEYS" 2>/dev/null; then ++ cat "$KEYS" >&2; bad "private key material found" ++ fi ++ if grep -RInE --exclude='*.example' --exclude-dir='.git' -- \ ++ '(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{24,})' . >"$TOKENS" 2>/dev/null; then ++ cat "$TOKENS" >&2; bad "token-like credential found" ++ fi ++fi ++ ++# Reject accidental binary blobs outside explicitly expected assets. ++while IFS= read -r f; do ++ [ -f "$f" ] || continue ++ case "$f" in *.png|*.jpg|*.jpeg|*.gif|*.ico|*.woff|*.woff2|*.pdf|*.zip) continue;; esac ++ if [ "$(LC_ALL=C grep -Il . "$f" 2>/dev/null || true)" = "" ] && [ -s "$f" ]; then ++ bad "unexpected binary file: $f" ++ fi ++done < "$FILES" ++ ++[ "$fail" -eq 0 ] || exit 1 ++echo "secret-scan: passed" +diff --git a/scripts/status.sh b/scripts/status.sh +index d5e0318f1e8d24b44939bb023f55442c0e5d8b4a..41a536a09e04383227da01b007cf96c82a8633f8 100755 +--- a/scripts/status.sh ++++ b/scripts/status.sh +@@ -1,4 +1,17 @@ + #!/bin/sh + set -eu +-PORT="${CONTROL_HOST_PORT:-8070}" +-curl -fsS "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool ++ENV_FILE=${ENV_FILE:-.env} ++getv() { ++ [ -f "$ENV_FILE" ] || return 0 ++ sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r' ++} ++PORT=${CONTROL_HOST_PORT:-$(getv CONTROL_HOST_PORT)} ++PORT=${PORT:-8070} ++USER_NAME=${CONTROL_BASIC_AUTH_USER:-$(getv CONTROL_BASIC_AUTH_USER)} ++PASSWORD=${CONTROL_BASIC_AUTH_PASSWORD:-$(getv CONTROL_BASIC_AUTH_PASSWORD)} ++if [ -n "$USER_NAME" ] || [ -n "$PASSWORD" ]; then ++ [ -n "$USER_NAME" ] && [ -n "$PASSWORD" ] || { echo "status: incomplete Control Basic Auth" >&2; exit 1; } ++ curl -fsS --user "$USER_NAME:$PASSWORD" "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool ++else ++ curl -fsS "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool ++fi +diff --git a/services/agent/Dockerfile b/services/agent/Dockerfile +index 1043322cdfcfd30739a9ad8fe6ee3c28587217cd..86aadcb9777b50aee9bd3ff9f85d4a9fce619225 100644 +--- a/services/agent/Dockerfile ++++ b/services/agent/Dockerfile +@@ -3,7 +3,9 @@ WORKDIR /src + COPY go.mod ./ + COPY cmd ./cmd + COPY internal ./internal +-RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai-agent ./cmd/agent ++RUN go test ./... && \ ++ go vet ./... && \ ++ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai-agent ./cmd/agent + + # One-shot helper used by docker compose to prepare the persistent volume for + # the distroless non-root runtime user (UID/GID 65532). +@@ -16,5 +18,6 @@ COPY --from=build /out/glpi-ai-agent /app/glpi-ai-agent + COPY knowledge /app/knowledge + VOLUME ["/app/data"] + EXPOSE 8080 ++HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=4 CMD ["/app/glpi-ai-agent", "healthcheck"] + USER 65532:65532 + ENTRYPOINT ["/app/glpi-ai-agent"] +diff --git a/services/agent/cmd/agent/main.go b/services/agent/cmd/agent/main.go +index ab5ce55c77dc979fdbd0ac7c41a322c29017f6d6..d413c74e9cc1c42f4c3055a8a23557dd50fea68f 100644 +--- a/services/agent/cmd/agent/main.go ++++ b/services/agent/cmd/agent/main.go +@@ -27,6 +27,10 @@ import ( + ) + + func main() { ++ if len(os.Args) == 2 && os.Args[1] == "healthcheck" { ++ runHealthcheck() ++ return ++ } + cfg, err := config.Load() + if err != nil { + slog.Error("configuration invalid", "error", err) +@@ -204,6 +208,22 @@ func main() { + slog.Info("shutdown complete") + } + ++func runHealthcheck() { ++ client := &http.Client{Timeout: 2 * time.Second} ++ req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/healthz", nil) ++ if err != nil { ++ os.Exit(1) ++ } ++ resp, err := client.Do(req) ++ if err != nil { ++ os.Exit(1) ++ } ++ defer resp.Body.Close() ++ if resp.StatusCode < 200 || resp.StatusCode >= 300 { ++ os.Exit(1) ++ } ++} ++ + func waitForOllamaPool(ctx context.Context, client *ollama.Client, retryInterval time.Duration) error { + if retryInterval < 2*time.Second { + retryInterval = 5 * time.Second +diff --git a/services/agent/internal/config/config.go b/services/agent/internal/config/config.go +index 38787218e90d85cd9804d59e4df49ee170bd1e59..d660a1a08dbd44ab7930dfd66439bb12df345a13 100644 +--- a/services/agent/internal/config/config.go ++++ b/services/agent/internal/config/config.go +@@ -325,6 +325,7 @@ func Load() (Config, error) { + CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"), + CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"), + CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"), ++ AIContentLabelEnabled: envBool("AI_CONTENT_LABEL_ENABLED", true), + AutoCategory: envBool("AUTO_CATEGORY", true), + AutoReply: envBool("AUTO_REPLY", false), + PriorityEnabled: envBool("PRIORITY_ENABLED", true), +@@ -651,6 +652,12 @@ func (c Config) Validate() error { + return fmt.Errorf("KNOWLEDGE_VECTOR_BACKEND must be one of: local, dual, neuroforge (got %q)", c.KnowledgeVectorBackend) + } + if c.KnowledgeVectorBackend == "dual" || c.KnowledgeVectorBackend == "neuroforge" { ++ if len(c.NeuroForgeAPIKey) < 24 { ++ return errors.New("NEUROFORGE_API_KEY must contain at least 24 characters for dual/neuroforge mode") ++ } ++ if isPlaceholder(c.NeuroForgeAPIKey) { ++ return errors.New("NEUROFORGE_API_KEY still contains a CHANGE_ME placeholder") ++ } + u, err := url.Parse(c.NeuroForgeURL) + if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return errors.New("NEUROFORGE_URL must be an absolute http(s) URL") +diff --git a/services/agent/internal/config/config_test.go b/services/agent/internal/config/config_test.go +index 666100eb7a5eb7dbb39399838fd95f78464c6d4e..2c5c63ef1ac147581e5e1341dd0c724b5c04e0c9 100644 +--- a/services/agent/internal/config/config_test.go ++++ b/services/agent/internal/config/config_test.go +@@ -515,3 +515,40 @@ func TestValidateGLPIKBAutoReplyUncategorizedRequiresArticleAllowlist(t *testing + t.Fatalf("expected uncategorized auto reply with explicit article allowlist to validate: %v", err) + } + } ++ ++func TestLoadReadsAIContentLabelEnabled(t *testing.T) { ++ for key, value := range map[string]string{ ++ "GLPI_URL": "https://glpi.internal.example", ++ "GLPI_CLIENT_ID": "client-id", ++ "GLPI_CLIENT_SECRET": "real-secret-value", ++ "GLPI_USERNAME": "svc-agent", ++ "GLPI_PASSWORD": "real-password-value", ++ "WEB_ALLOW_ANONYMOUS": "true", ++ "AI_CONTENT_LABEL_ENABLED": "false", ++ } { ++ t.Setenv(key, value) ++ } ++ c, err := Load() ++ if err != nil { ++ t.Fatal(err) ++ } ++ if c.AIContentLabelEnabled { ++ t.Fatal("AI_CONTENT_LABEL_ENABLED=false was not loaded") ++ } ++} ++ ++func TestValidateNeuroForgeAPIKeyForRemoteVectorBackend(t *testing.T) { ++ c := validConfig() ++ c.KnowledgeVectorBackend = "dual" ++ c.NeuroForgeURL = "http://neuroforge:8090" ++ c.NeuroForgeSearchK = 128 ++ c.NeuroForgeTimeout = 5 * time.Second ++ c.NeuroForgeAPIKey = "CHANGE_ME_NEUROFORGE_API_KEY" ++ if err := c.Validate(); err == nil { ++ t.Fatal("expected placeholder NEUROFORGE_API_KEY to be rejected") ++ } ++ c.NeuroForgeAPIKey = "integration-token-0123456789abcdef" ++ if err := c.Validate(); err != nil { ++ t.Fatalf("expected strong NEUROFORGE_API_KEY to validate: %v", err) ++ } ++} +diff --git a/services/control/Dockerfile b/services/control/Dockerfile +index 990ae61a40d7b02414e171466780585eb4ef9faa..ea3e9f6344e6b68decbe0e945c6978e8a90f84e3 100644 +--- a/services/control/Dockerfile ++++ b/services/control/Dockerfile +@@ -1,7 +1,9 @@ + FROM golang:1.23-alpine AS build + WORKDIR /src + COPY . . +-RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control . ++RUN go test ./... && \ ++ go vet ./... && \ ++ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control . + FROM alpine:3.21 + RUN addgroup -S app && adduser -S -G app app + COPY --from=build /out/control /usr/local/bin/control +diff --git a/services/control/engineering-graph.json b/services/control/engineering-graph.json +index c2a7447ae9269586b1583feed47b08ab1616d04f..858e6cdb0cf214ffb72410270e4347fc3cf50e9e 100644 +--- a/services/control/engineering-graph.json ++++ b/services/control/engineering-graph.json +@@ -1002,7 +1002,19 @@ + "community": "github.com/example/glpi-ai-agent/cmd/agent", + "meta": { + "exported": false, +- "line": 240, ++ "line": 260, ++ "path": "services/agent/cmd/agent/main.go" ++ } ++ }, ++ { ++ "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", ++ "kind": "function", ++ "label": "runHealthcheck", ++ "group": "engineering", ++ "community": "github.com/example/glpi-ai-agent/cmd/agent", ++ "meta": { ++ "exported": false, ++ "line": 211, + "path": "services/agent/cmd/agent/main.go" + } + }, +@@ -1014,7 +1026,7 @@ + "community": "github.com/example/glpi-ai-agent/cmd/agent", + "meta": { + "exported": false, +- "line": 207, ++ "line": 227, + "path": "services/agent/cmd/agent/main.go" + } + }, +@@ -2382,7 +2394,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": true, +- "line": 1056, ++ "line": 1063, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2394,7 +2406,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": true, +- "line": 439, ++ "line": 440, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2418,7 +2430,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1030, ++ "line": 1037, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2430,7 +2442,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1201, ++ "line": 1208, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2442,7 +2454,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1245, ++ "line": 1252, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2454,7 +2466,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1234, ++ "line": 1241, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2466,7 +2478,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1212, ++ "line": 1219, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2478,7 +2490,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1223, ++ "line": 1230, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2490,7 +2502,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1079, ++ "line": 1086, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2502,7 +2514,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1101, ++ "line": 1108, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2514,7 +2526,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1184, ++ "line": 1191, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2526,7 +2538,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1045, ++ "line": 1052, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2538,7 +2550,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1176, ++ "line": 1183, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2550,7 +2562,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1123, ++ "line": 1130, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2562,7 +2574,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1149, ++ "line": 1156, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2574,7 +2586,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1039, ++ "line": 1046, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2586,7 +2598,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1075, ++ "line": 1082, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2598,7 +2610,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1016, ++ "line": 1023, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2610,7 +2622,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 1011, ++ "line": 1018, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -2622,7 +2634,7 @@ + "community": "github.com/example/glpi-ai-agent/internal/config", + "meta": { + "exported": false, +- "line": 986, ++ "line": 993, + "path": "services/agent/internal/config/config.go" + } + }, +@@ -7326,7 +7338,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 175, ++ "line": 239, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7338,7 +7350,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 202, ++ "line": 227, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7350,7 +7362,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 526, ++ "line": 568, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7362,7 +7374,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 130, ++ "line": 155, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7374,7 +7386,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 165, ++ "line": 190, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7386,7 +7398,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 185, ++ "line": 210, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7398,7 +7410,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 110, ++ "line": 135, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7410,7 +7422,19 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 271, ++ "line": 309, ++ "path": "services/knowledge/cmd/server/app.go" ++ } ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "kind": "function", ++ "label": "app.handleIntegrationStagingHealth", ++ "group": "engineering", ++ "community": "kb-editor/cmd/server", ++ "meta": { ++ "exported": false, ++ "line": 293, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7422,7 +7446,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 134, ++ "line": 159, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7434,7 +7458,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 151, ++ "line": 176, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7446,7 +7470,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 497, ++ "line": 539, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7458,7 +7482,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 493, ++ "line": 535, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7470,7 +7494,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 551, ++ "line": 593, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7482,7 +7506,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 139, ++ "line": 164, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7494,7 +7518,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 422, ++ "line": 460, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7506,7 +7530,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 388, ++ "line": 426, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7518,7 +7542,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 341, ++ "line": 379, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7530,7 +7554,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 313, ++ "line": 351, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7542,7 +7566,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 405, ++ "line": 443, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7554,7 +7578,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 363, ++ "line": 401, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7566,7 +7590,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 477, ++ "line": 515, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7578,7 +7602,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 62, ++ "line": 63, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7590,7 +7614,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 51, ++ "line": 52, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7602,7 +7626,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 56, ++ "line": 57, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7614,10 +7638,22 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 125, ++ "line": 184, + "path": "services/knowledge/cmd/server/main.go" + } + }, ++ { ++ "id": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "kind": "function", ++ "label": "browserWriteSameOrigin", ++ "group": "engineering", ++ "community": "kb-editor/cmd/server", ++ "meta": { ++ "exported": false, ++ "line": 101, ++ "path": "services/knowledge/cmd/server/app.go" ++ } ++ }, + { + "id": "function:kb-editor/cmd/server:configFromEnv", + "kind": "function", +@@ -7626,7 +7662,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 103, ++ "line": 162, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7638,7 +7674,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 562, ++ "line": 604, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7650,7 +7686,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 228, ++ "line": 292, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7662,7 +7698,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 240, ++ "line": 304, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7674,7 +7710,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 254, ++ "line": 279, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7686,7 +7722,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 25, ++ "line": 29, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7698,7 +7734,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 281, ++ "line": 345, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7710,7 +7746,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 43, ++ "line": 44, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7722,7 +7758,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 247, ++ "line": 311, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7734,7 +7770,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 220, ++ "line": 284, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7746,7 +7782,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 170, ++ "line": 195, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7758,7 +7794,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 273, ++ "line": 337, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7770,7 +7806,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 99, ++ "line": 124, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7782,7 +7818,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 156, ++ "line": 220, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7794,7 +7830,19 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 146, ++ "line": 205, ++ "path": "services/knowledge/cmd/server/main.go" ++ } ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "kind": "function", ++ "label": "validateRuntimeSecrets", ++ "group": "engineering", ++ "community": "kb-editor/cmd/server", ++ "meta": { ++ "exported": false, ++ "line": 132, + "path": "services/knowledge/cmd/server/main.go" + } + }, +@@ -7806,7 +7854,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 584, ++ "line": 626, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -7818,7 +7866,7 @@ + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, +- "line": 578, ++ "line": 620, + "path": "services/knowledge/cmd/server/app.go" + } + }, +@@ -8370,7 +8418,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 403, ++ "line": 421, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8382,7 +8430,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 383, ++ "line": 401, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8394,7 +8442,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 411, ++ "line": 429, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8406,7 +8454,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 578, ++ "line": 605, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8418,7 +8466,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 587, ++ "line": 614, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8430,7 +8478,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 602, ++ "line": 629, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8442,7 +8490,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 527, ++ "line": 554, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8454,7 +8502,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 471, ++ "line": 498, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8466,7 +8514,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 511, ++ "line": 538, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8478,7 +8526,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 501, ++ "line": 528, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8490,7 +8538,19 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 439, ++ "line": 466, ++ "path": "services/knowledge/internal/staging/staging.go" ++ } ++ }, ++ { ++ "id": "function:kb-editor/internal/staging:syncDir", ++ "kind": "function", ++ "label": "syncDir", ++ "group": "engineering", ++ "community": "kb-editor/internal/staging", ++ "meta": { ++ "exported": false, ++ "line": 457, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8502,7 +8562,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 540, ++ "line": 567, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8514,7 +8574,7 @@ + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, +- "line": 557, ++ "line": 584, + "path": "services/knowledge/internal/staging/staging.go" + } + }, +@@ -8538,7 +8598,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": true, +- "line": 726, ++ "line": 771, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8586,7 +8646,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": true, +- "line": 1182, ++ "line": 1230, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8662,6 +8722,18 @@ + "path": "services/knowledge/internal/store/store.go" + } + }, ++ { ++ "id": "function:kb-editor/internal/store:Store.RollbackImported", ++ "kind": "function", ++ "label": "Store.RollbackImported", ++ "group": "engineering", ++ "community": "kb-editor/internal/store", ++ "meta": { ++ "exported": true, ++ "line": 707, ++ "path": "services/knowledge/internal/store/store.go" ++ } ++ }, + { + "id": "function:kb-editor/internal/store:Store.Save", + "kind": "function", +@@ -8694,7 +8766,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 962, ++ "line": 1010, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8706,7 +8778,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 953, ++ "line": 1001, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8730,7 +8802,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 993, ++ "line": 1041, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8742,7 +8814,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 912, ++ "line": 957, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8754,7 +8826,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 796, ++ "line": 841, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8766,7 +8838,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1033, ++ "line": 1081, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8790,7 +8862,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1043, ++ "line": 1091, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8802,7 +8874,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1136, ++ "line": 1184, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8826,7 +8898,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1095, ++ "line": 1143, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8850,7 +8922,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 881, ++ "line": 926, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8862,7 +8934,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1062, ++ "line": 1110, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8886,7 +8958,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 859, ++ "line": 904, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8898,7 +8970,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 700, ++ "line": 745, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8910,7 +8982,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1155, ++ "line": 1203, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8934,7 +9006,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1052, ++ "line": 1100, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8946,7 +9018,19 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1004, ++ "line": 1052, ++ "path": "services/knowledge/internal/store/store.go" ++ } ++ }, ++ { ++ "id": "function:kb-editor/internal/store:syncDir", ++ "kind": "function", ++ "label": "syncDir", ++ "group": "engineering", ++ "community": "kb-editor/internal/store", ++ "meta": { ++ "exported": false, ++ "line": 736, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8958,7 +9042,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1078, ++ "line": 1126, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -8994,7 +9078,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1161, ++ "line": 1209, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -9006,7 +9090,7 @@ + "community": "kb-editor/internal/store", + "meta": { + "exported": false, +- "line": 1142, ++ "line": 1190, + "path": "services/knowledge/internal/store/store.go" + } + }, +@@ -9214,6 +9298,18 @@ + "path": "services/control/graph.go" + } + }, ++ { ++ "id": "function:mega-control:controlBasicAuth", ++ "kind": "function", ++ "label": "controlBasicAuth", ++ "group": "engineering", ++ "community": "mega-control", ++ "meta": { ++ "exported": false, ++ "line": 178, ++ "path": "services/control/main.go" ++ } ++ }, + { + "id": "function:mega-control:csvSet", + "kind": "function", +@@ -9246,7 +9342,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 65, ++ "line": 69, + "path": "services/control/main.go" + } + }, +@@ -9294,7 +9390,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 72, ++ "line": 76, + "path": "services/control/main.go" + } + }, +@@ -9306,7 +9402,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 117, ++ "line": 201, + "path": "services/control/main.go" + } + }, +@@ -9318,7 +9414,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 160, ++ "line": 244, + "path": "services/control/main.go" + } + }, +@@ -9342,7 +9438,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 127, ++ "line": 211, + "path": "services/control/main.go" + } + }, +@@ -9426,7 +9522,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 131, ++ "line": 215, + "path": "services/control/main.go" + } + }, +@@ -9462,7 +9558,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 148, ++ "line": 232, + "path": "services/control/main.go" + } + }, +@@ -9490,6 +9586,18 @@ + "path": "services/control/graph.go" + } + }, ++ { ++ "id": "function:mega-control:validateControlSecrets", ++ "kind": "function", ++ "label": "validateControlSecrets", ++ "group": "engineering", ++ "community": "mega-control", ++ "meta": { ++ "exported": false, ++ "line": 147, ++ "path": "services/control/main.go" ++ } ++ }, + { + "id": "function:mega-control:writeJSON", + "kind": "function", +@@ -9498,7 +9606,7 @@ + "community": "mega-control", + "meta": { + "exported": false, +- "line": 195, ++ "line": 279, + "path": "services/control/main.go" + } + }, +@@ -9582,7 +9690,7 @@ + "community": "neuroforge/cmd/server", + "meta": { + "exported": false, +- "line": 57, ++ "line": 92, + "path": "platform/neuroforge/cmd/server/main.go" + } + }, +@@ -9594,7 +9702,7 @@ + "community": "neuroforge/cmd/server", + "meta": { + "exported": false, +- "line": 50, ++ "line": 85, + "path": "platform/neuroforge/cmd/server/main.go" + } + }, +@@ -9606,7 +9714,31 @@ + "community": "neuroforge/cmd/server", + "meta": { + "exported": false, +- "line": 64, ++ "line": 99, ++ "path": "platform/neuroforge/cmd/server/main.go" ++ } ++ }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecret", ++ "kind": "function", ++ "label": "validateManagedSecret", ++ "group": "engineering", ++ "community": "neuroforge/cmd/server", ++ "meta": { ++ "exported": false, ++ "line": 50, ++ "path": "platform/neuroforge/cmd/server/main.go" ++ } ++ }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", ++ "kind": "function", ++ "label": "validateManagedSecretsFromEnv", ++ "group": "engineering", ++ "community": "neuroforge/cmd/server", ++ "meta": { ++ "exported": false, ++ "line": 65, + "path": "platform/neuroforge/cmd/server/main.go" + } + }, +@@ -9678,7 +9810,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 988, ++ "line": 1008, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9690,10 +9822,22 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 183, ++ "line": 203, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "kind": "function", ++ "label": "Engine.CheckStagingPublisher", ++ "group": "engineering", ++ "community": "neuroforge/internal/brain", ++ "meta": { ++ "exported": true, ++ "line": 232, ++ "path": "platform/neuroforge/internal/brain/staging.go" ++ } ++ }, + { + "id": "function:neuroforge/internal/brain:Engine.ClusterAbort", + "kind": "function", +@@ -9786,7 +9930,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 729, ++ "line": 749, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9798,7 +9942,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 513, ++ "line": 533, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9810,7 +9954,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 437, ++ "line": 457, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9846,7 +9990,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 358, ++ "line": 378, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9858,7 +10002,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 296, ++ "line": 342, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -9906,7 +10050,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 123, ++ "line": 169, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -9930,7 +10074,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 915, ++ "line": 935, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -9942,7 +10086,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 438, ++ "line": 484, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -9990,7 +10134,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 490, ++ "line": 510, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10002,7 +10146,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 1009, ++ "line": 1029, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10014,7 +10158,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 503, ++ "line": 523, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10042,6 +10186,18 @@ + "path": "platform/neuroforge/internal/brain/v5.go" + } + }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.beginGoalCycle", ++ "kind": "function", ++ "label": "Engine.beginGoalCycle", ++ "group": "engineering", ++ "community": "neuroforge/internal/brain", ++ "meta": { ++ "exported": false, ++ "line": 47, ++ "path": "platform/neuroforge/internal/brain/brain.go" ++ } ++ }, + { + "id": "function:neuroforge/internal/brain:Engine.chatModel", + "kind": "function", +@@ -10050,7 +10206,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 107, ++ "line": 127, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10062,7 +10218,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 111, ++ "line": 131, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10074,7 +10230,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 123, ++ "line": 143, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10158,7 +10314,19 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 62, ++ "line": 82, ++ "path": "platform/neuroforge/internal/brain/brain.go" ++ } ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.endGoalCycle", ++ "kind": "function", ++ "label": "Engine.endGoalCycle", ++ "group": "engineering", ++ "community": "neuroforge/internal/brain", ++ "meta": { ++ "exported": false, ++ "line": 57, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10170,7 +10338,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 967, ++ "line": 987, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10182,7 +10350,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 532, ++ "line": 552, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10254,7 +10422,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 978, ++ "line": 998, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10314,7 +10482,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 331, ++ "line": 351, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10326,7 +10494,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 643, ++ "line": 663, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10338,7 +10506,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 676, ++ "line": 696, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10350,7 +10518,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 397, ++ "line": 443, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10386,7 +10554,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 574, ++ "line": 594, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10422,7 +10590,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 851, ++ "line": 871, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10434,7 +10602,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 207, ++ "line": 258, + "path": "platform/neuroforge/internal/brain/staging.go" + } + }, +@@ -10446,7 +10614,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, +- "line": 39, ++ "line": 43, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10494,7 +10662,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 279, ++ "line": 325, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10506,7 +10674,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 317, ++ "line": 337, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10566,7 +10734,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 870, ++ "line": 890, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10578,7 +10746,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 248, ++ "line": 299, + "path": "platform/neuroforge/internal/brain/staging.go" + } + }, +@@ -10590,7 +10758,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 239, ++ "line": 285, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10602,7 +10770,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 229, ++ "line": 275, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10614,7 +10782,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 434, ++ "line": 480, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10638,7 +10806,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 193, ++ "line": 239, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10674,7 +10842,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 266, ++ "line": 317, + "path": "platform/neuroforge/internal/brain/staging.go" + } + }, +@@ -10698,7 +10866,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 427, ++ "line": 473, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10710,7 +10878,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 420, ++ "line": 440, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10722,7 +10890,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 941, ++ "line": 961, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10734,7 +10902,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 509, ++ "line": 555, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10746,7 +10914,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 264, ++ "line": 310, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10794,7 +10962,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 378, ++ "line": 424, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10806,7 +10974,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 359, ++ "line": 405, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10854,7 +11022,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 173, ++ "line": 193, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10866,7 +11034,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 388, ++ "line": 434, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10890,7 +11058,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 500, ++ "line": 546, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10926,7 +11094,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 168, ++ "line": 214, + "path": "platform/neuroforge/internal/brain/v3.go" + } + }, +@@ -10938,7 +11106,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 433, ++ "line": 453, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10950,7 +11118,7 @@ + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, +- "line": 890, ++ "line": 910, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, +@@ -10962,7 +11130,7 @@ + "community": "neuroforge/internal/core", + "meta": { + "exported": true, +- "line": 673, ++ "line": 675, + "path": "platform/neuroforge/internal/core/types.go" + } + }, +@@ -11082,7 +11250,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, +- "line": 37, ++ "line": 40, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11094,7 +11262,19 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, +- "line": 42, ++ "line": 47, ++ "path": "platform/neuroforge/internal/httpapi/httpapi.go" ++ } ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive", ++ "kind": "function", ++ "label": "Server.SetReadinessOllamaLive", ++ "group": "engineering", ++ "community": "neuroforge/internal/httpapi", ++ "meta": { ++ "exported": true, ++ "line": 46, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11106,7 +11286,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 244, ++ "line": 276, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11118,7 +11298,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 121, ++ "line": 126, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11130,7 +11310,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 140, ++ "line": 145, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11166,7 +11346,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 679, ++ "line": 737, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11178,7 +11358,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 653, ++ "line": 711, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11214,7 +11394,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 675, ++ "line": 733, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11226,7 +11406,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 476, ++ "line": 508, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11250,7 +11430,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 531, ++ "line": 563, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11262,7 +11442,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 585, ++ "line": 617, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11346,7 +11526,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 642, ++ "line": 700, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11370,7 +11550,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 637, ++ "line": 695, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11382,7 +11562,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 479, ++ "line": 511, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11406,7 +11586,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 535, ++ "line": 567, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11418,7 +11598,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 598, ++ "line": 630, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11430,7 +11610,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 125, ++ "line": 130, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11478,7 +11658,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 152, ++ "line": 157, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11490,7 +11670,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 112, ++ "line": 117, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11502,7 +11682,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 572, ++ "line": 604, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11514,7 +11694,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 439, ++ "line": 471, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11538,7 +11718,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 665, ++ "line": 723, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11562,7 +11742,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 668, ++ "line": 726, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11574,7 +11754,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 148, ++ "line": 153, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11586,7 +11766,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 216, ++ "line": 221, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11598,7 +11778,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 280, ++ "line": 312, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11622,7 +11802,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 254, ++ "line": 286, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11718,10 +11898,22 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 108, ++ "line": 113, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "kind": "function", ++ "label": "Server.controlReadAuth", ++ "group": "engineering", ++ "community": "neuroforge/internal/httpapi", ++ "meta": { ++ "exported": false, ++ "line": 253, ++ "path": "platform/neuroforge/internal/httpapi/httpapi.go" ++ } ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.err", + "kind": "function", +@@ -11730,7 +11922,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 276, ++ "line": 308, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11742,7 +11934,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 373, ++ "line": 405, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11754,7 +11946,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 91, ++ "line": 92, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11766,7 +11958,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 71, ++ "line": 72, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11802,7 +11994,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 81, ++ "line": 82, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11814,7 +12006,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 17, ++ "line": 18, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11826,7 +12018,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 56, ++ "line": 57, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11838,7 +12030,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 30, ++ "line": 31, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11850,7 +12042,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 13, ++ "line": 14, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11862,7 +12054,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 39, ++ "line": 40, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -11874,7 +12066,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 359, ++ "line": 391, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11886,7 +12078,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 145, ++ "line": 150, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -11914,6 +12106,18 @@ + "path": "platform/neuroforge/internal/httpapi/v8.go" + } + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "kind": "function", ++ "label": "Server.integrationAuth", ++ "group": "engineering", ++ "community": "neuroforge/internal/httpapi", ++ "meta": { ++ "exported": false, ++ "line": 240, ++ "path": "platform/neuroforge/internal/httpapi/httpapi.go" ++ } ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph", + "kind": "function", +@@ -12018,7 +12222,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 271, ++ "line": 303, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12030,7 +12234,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 293, ++ "line": 325, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12042,7 +12246,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 100, ++ "line": 105, + "path": "platform/neuroforge/internal/httpapi/v3.go" + } + }, +@@ -12054,7 +12258,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 736, ++ "line": 794, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12066,7 +12270,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 186, ++ "line": 191, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12090,7 +12294,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 740, ++ "line": 859, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12102,7 +12306,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 711, ++ "line": 769, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12126,7 +12330,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 50, ++ "line": 55, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12138,7 +12342,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 306, ++ "line": 338, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12150,7 +12354,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 322, ++ "line": 354, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12162,7 +12366,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 689, ++ "line": 747, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12198,7 +12402,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 385, ++ "line": 417, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12210,7 +12414,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 235, ++ "line": 267, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12222,7 +12426,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 389, ++ "line": 421, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12234,7 +12438,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 416, ++ "line": 448, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12258,7 +12462,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 209, ++ "line": 214, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12274,6 +12478,30 @@ + "path": "platform/neuroforge/internal/httpapi/metrics.go" + } + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "kind": "function", ++ "label": "checkConfiguredOllamaModels", ++ "group": "engineering", ++ "community": "neuroforge/internal/httpapi", ++ "meta": { ++ "exported": false, ++ "line": 812, ++ "path": "platform/neuroforge/internal/httpapi/httpapi.go" ++ } ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:configuredModelAvailable", ++ "kind": "function", ++ "label": "configuredModelAvailable", ++ "group": "engineering", ++ "community": "neuroforge/internal/httpapi", ++ "meta": { ++ "exported": false, ++ "line": 798, ++ "path": "platform/neuroforge/internal/httpapi/httpapi.go" ++ } ++ }, + { + "id": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot", + "kind": "function", +@@ -12294,7 +12522,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 265, ++ "line": 297, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12390,7 +12618,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 576, ++ "line": 608, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12462,7 +12690,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 518, ++ "line": 550, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12522,7 +12750,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, +- "line": 202, ++ "line": 207, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12558,7 +12786,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, +- "line": 167, ++ "line": 172, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12570,7 +12798,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, +- "line": 177, ++ "line": 182, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -12582,7 +12810,7 @@ + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, +- "line": 169, ++ "line": 174, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, +@@ -13254,7 +13482,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 570, ++ "line": 576, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13566,7 +13794,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 783, ++ "line": 789, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13590,7 +13818,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1300, ++ "line": 1306, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13614,7 +13842,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1358, ++ "line": 1364, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13626,7 +13854,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 572, ++ "line": 578, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13710,7 +13938,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 588, ++ "line": 594, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13722,7 +13950,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1383, ++ "line": 1389, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13734,7 +13962,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 609, ++ "line": 615, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13758,7 +13986,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1145, ++ "line": 1151, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13770,7 +13998,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1098, ++ "line": 1104, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13806,7 +14034,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1405, ++ "line": 1411, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13854,7 +14082,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1345, ++ "line": 1351, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13866,7 +14094,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1425, ++ "line": 1431, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -13914,7 +14142,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 849, ++ "line": 855, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14058,7 +14286,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1240, ++ "line": 1246, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14070,7 +14298,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1198, ++ "line": 1204, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14082,7 +14310,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1217, ++ "line": 1223, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14202,7 +14430,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1332, ++ "line": 1338, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14226,7 +14454,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1062, ++ "line": 1068, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14298,7 +14526,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 906, ++ "line": 912, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14310,7 +14538,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1741, ++ "line": 1747, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14322,7 +14550,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1750, ++ "line": 1756, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14334,7 +14562,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 666, ++ "line": 672, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14346,7 +14574,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 600, ++ "line": 606, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14370,7 +14598,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1173, ++ "line": 1179, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14382,7 +14610,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1184, ++ "line": 1190, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14430,7 +14658,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1253, ++ "line": 1259, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14466,7 +14694,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1230, ++ "line": 1236, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14502,7 +14730,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1121, ++ "line": 1127, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14526,7 +14754,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 610, ++ "line": 616, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14538,7 +14766,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1246, ++ "line": 1252, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14550,7 +14778,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 673, ++ "line": 679, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14598,7 +14826,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1316, ++ "line": 1322, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14610,7 +14838,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": true, +- "line": 1443, ++ "line": 1449, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14802,7 +15030,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 860, ++ "line": 866, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14886,7 +15114,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 535, ++ "line": 541, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14934,7 +15162,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 691, ++ "line": 697, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14970,7 +15198,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 555, ++ "line": 561, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -14982,7 +15210,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 558, ++ "line": 564, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15006,7 +15234,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 696, ++ "line": 702, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15018,7 +15246,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 733, ++ "line": 739, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15078,7 +15306,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 912, ++ "line": 918, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15150,7 +15378,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 1449, ++ "line": 1455, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15390,7 +15618,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 208, ++ "line": 214, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15462,7 +15690,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 885, ++ "line": 891, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15498,7 +15726,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 683, ++ "line": 689, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15558,7 +15786,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 1055, ++ "line": 1061, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15666,7 +15894,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 522, ++ "line": 528, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15810,7 +16038,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 483, ++ "line": 489, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -15930,7 +16158,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 1091, ++ "line": 1097, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -16014,7 +16242,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 562, ++ "line": 568, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -16146,7 +16374,7 @@ + "community": "neuroforge/internal/store", + "meta": { + "exported": false, +- "line": 543, ++ "line": 549, + "path": "platform/neuroforge/internal/store/store.go" + } + }, +@@ -18094,6 +18322,16 @@ + "file": "services/knowledge/cmd/server/app.go" + } + }, ++ { ++ "id": "route:GET /api/integrations/staging/health", ++ "kind": "route", ++ "label": "GET /api/integrations/staging/health", ++ "group": "engineering", ++ "community": "services/knowledge", ++ "meta": { ++ "file": "services/knowledge/cmd/server/app.go" ++ } ++ }, + { + "id": "route:GET /api/items", + "kind": "route", +@@ -19040,7 +19278,10 @@ + "label": "agent", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:agent-data-init", +@@ -19048,7 +19289,10 @@ + "label": "agent-data-init", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:control", +@@ -19056,7 +19300,10 @@ + "label": "control", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:knowledge", +@@ -19064,7 +19311,10 @@ + "label": "knowledge", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:neuroforge", +@@ -19072,7 +19322,10 @@ + "label": "neuroforge", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:neuroforge-worker", +@@ -19080,7 +19333,10 @@ + "label": "neuroforge-worker", + "group": "runtime", + "community": "compose", +- "status": "configured" ++ "status": "configured", ++ "meta": { ++ "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" ++ } + }, + { + "id": "service:ollama", +@@ -19448,6 +19704,18 @@ + "to": "function:neuroforge/cmd/server:run", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/cmd/server/main.go-\u003efunction:neuroforge/cmd/server:validateManagedSecret:defines", ++ "from": "file:platform/neuroforge/cmd/server/main.go", ++ "to": "function:neuroforge/cmd/server:validateManagedSecret", ++ "kind": "defines" ++ }, ++ { ++ "id": "file:platform/neuroforge/cmd/server/main.go-\u003efunction:neuroforge/cmd/server:validateManagedSecretsFromEnv:defines", ++ "from": "file:platform/neuroforge/cmd/server/main.go", ++ "to": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/cmd/server/main.go-\u003epackage:context:imports", + "from": "file:platform/neuroforge/cmd/server/main.go", +@@ -19730,6 +19998,12 @@ + "to": "function:neuroforge/internal/brain:Engine.SearchVector", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.beginGoalCycle:defines", ++ "from": "file:platform/neuroforge/internal/brain/brain.go", ++ "to": "function:neuroforge/internal/brain:Engine.beginGoalCycle", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.chatModel:defines", + "from": "file:platform/neuroforge/internal/brain/brain.go", +@@ -19754,6 +20028,12 @@ + "to": "function:neuroforge/internal/brain:Engine.embed", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.endGoalCycle:defines", ++ "from": "file:platform/neuroforge/internal/brain/brain.go", ++ "to": "function:neuroforge/internal/brain:Engine.endGoalCycle", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.enqueueRelink:defines", + "from": "file:platform/neuroforge/internal/brain/brain.go", +@@ -20114,6 +20394,12 @@ + "to": "package:time", + "kind": "imports" + }, ++ { ++ "id": "file:platform/neuroforge/internal/brain/staging.go-\u003efunction:neuroforge/internal/brain:Engine.CheckStagingPublisher:defines", ++ "from": "file:platform/neuroforge/internal/brain/staging.go", ++ "to": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/brain/staging.go-\u003efunction:neuroforge/internal/brain:Engine.ConfigureStagingPublisher:defines", + "from": "file:platform/neuroforge/internal/brain/staging.go", +@@ -20984,6 +21270,12 @@ + "to": "function:neuroforge/internal/httpapi:Server.Handler", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive:defines", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "function:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.adminAuth:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21098,6 +21390,12 @@ + "to": "function:neuroforge/internal/httpapi:Server.clusterAuth", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.controlReadAuth:defines", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.err:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21122,6 +21420,12 @@ + "to": "function:neuroforge/internal/httpapi:Server.index", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.integrationAuth:defines", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.json:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21212,6 +21516,18 @@ + "to": "function:neuroforge/internal/httpapi:bearer", + "kind": "defines" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:checkConfiguredOllamaModels:defines", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "kind": "defines" ++ }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:configuredModelAvailable:defines", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "function:neuroforge/internal/httpapi:configuredModelAvailable", ++ "kind": "defines" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:decode:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21284,6 +21600,12 @@ + "to": "package:errors", + "kind": "imports" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:fmt:imports", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "package:fmt", ++ "kind": "imports" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:io:imports", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21332,6 +21654,12 @@ + "to": "package:neuroforge/internal/store", + "kind": "imports" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:os:imports", ++ "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", ++ "to": "package:os", ++ "kind": "imports" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:strconv:imports", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", +@@ -21938,6 +22266,12 @@ + "to": "package:net/http", + "kind": "imports" + }, ++ { ++ "id": "file:platform/neuroforge/internal/httpapi/v3.go-\u003epackage:neuroforge/internal/brain:imports", ++ "from": "file:platform/neuroforge/internal/httpapi/v3.go", ++ "to": "package:neuroforge/internal/brain", ++ "kind": "imports" ++ }, + { + "id": "file:platform/neuroforge/internal/httpapi/v3.go-\u003epackage:neuroforge/internal/core:imports", + "from": "file:platform/neuroforge/internal/httpapi/v3.go", +@@ -25628,6 +25962,12 @@ + "to": "function:github.com/example/glpi-ai-agent/cmd/agent:maxDuration", + "kind": "defines" + }, ++ { ++ "id": "file:services/agent/cmd/agent/main.go-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck:defines", ++ "from": "file:services/agent/cmd/agent/main.go", ++ "to": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", ++ "kind": "defines" ++ }, + { + "id": "file:services/agent/cmd/agent/main.go-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool:defines", + "from": "file:services/agent/cmd/agent/main.go", +@@ -31070,6 +31410,12 @@ + "to": "package:sync", + "kind": "imports" + }, ++ { ++ "id": "file:services/control/main.go-\u003efunction:mega-control:controlBasicAuth:defines", ++ "from": "file:services/control/main.go", ++ "to": "function:mega-control:controlBasicAuth", ++ "kind": "defines" ++ }, + { + "id": "file:services/control/main.go-\u003efunction:mega-control:env:defines", + "from": "file:services/control/main.go", +@@ -31112,6 +31458,12 @@ + "to": "function:mega-control:server.statusSnapshot", + "kind": "defines" + }, ++ { ++ "id": "file:services/control/main.go-\u003efunction:mega-control:validateControlSecrets:defines", ++ "from": "file:services/control/main.go", ++ "to": "function:mega-control:validateControlSecrets", ++ "kind": "defines" ++ }, + { + "id": "file:services/control/main.go-\u003efunction:mega-control:writeJSON:defines", + "from": "file:services/control/main.go", +@@ -31124,6 +31476,12 @@ + "to": "package:context", + "kind": "imports" + }, ++ { ++ "id": "file:services/control/main.go-\u003epackage:crypto/subtle:imports", ++ "from": "file:services/control/main.go", ++ "to": "package:crypto/subtle", ++ "kind": "imports" ++ }, + { + "id": "file:services/control/main.go-\u003epackage:embed:imports", + "from": "file:services/control/main.go", +@@ -31136,6 +31494,12 @@ + "to": "package:encoding/json", + "kind": "imports" + }, ++ { ++ "id": "file:services/control/main.go-\u003epackage:errors:imports", ++ "from": "file:services/control/main.go", ++ "to": "package:errors", ++ "kind": "imports" ++ }, + { + "id": "file:services/control/main.go-\u003epackage:fmt:imports", + "from": "file:services/control/main.go", +@@ -31160,6 +31524,12 @@ + "to": "package:net/http", + "kind": "imports" + }, ++ { ++ "id": "file:services/control/main.go-\u003epackage:os/signal:imports", ++ "from": "file:services/control/main.go", ++ "to": "package:os/signal", ++ "kind": "imports" ++ }, + { + "id": "file:services/control/main.go-\u003epackage:os:imports", + "from": "file:services/control/main.go", +@@ -31172,6 +31542,12 @@ + "to": "package:strings", + "kind": "imports" + }, ++ { ++ "id": "file:services/control/main.go-\u003epackage:syscall:imports", ++ "from": "file:services/control/main.go", ++ "to": "package:syscall", ++ "kind": "imports" ++ }, + { + "id": "file:services/control/main.go-\u003epackage:time:imports", + "from": "file:services/control/main.go", +@@ -31220,6 +31596,12 @@ + "to": "function:kb-editor/cmd/server:app.handleIntegrationStaging", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:app.handleIntegrationStagingHealth:defines", ++ "from": "file:services/knowledge/cmd/server/app.go", ++ "to": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "kind": "defines" ++ }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:app.handleList:defines", + "from": "file:services/knowledge/cmd/server/app.go", +@@ -31316,6 +31698,12 @@ + "to": "function:kb-editor/cmd/server:app.withStaging", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:browserWriteSameOrigin:defines", ++ "from": "file:services/knowledge/cmd/server/app.go", ++ "to": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "kind": "defines" ++ }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:decodeJSON:defines", + "from": "file:services/knowledge/cmd/server/app.go", +@@ -31436,6 +31824,12 @@ + "to": "package:net/http", + "kind": "imports" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/app.go-\u003epackage:net/url:imports", ++ "from": "file:services/knowledge/cmd/server/app.go", ++ "to": "package:net/url", ++ "kind": "imports" ++ }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003epackage:os:imports", + "from": "file:services/knowledge/cmd/server/app.go", +@@ -31532,6 +31926,18 @@ + "to": "function:kb-editor/cmd/server:startAutoReload", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/main.go-\u003efunction:kb-editor/cmd/server:validateRuntimeSecrets:defines", ++ "from": "file:services/knowledge/cmd/server/main.go", ++ "to": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "kind": "defines" ++ }, ++ { ++ "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:context:imports", ++ "from": "file:services/knowledge/cmd/server/main.go", ++ "to": "package:context", ++ "kind": "imports" ++ }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:crypto/subtle:imports", + "from": "file:services/knowledge/cmd/server/main.go", +@@ -31544,6 +31950,12 @@ + "to": "package:embed", + "kind": "imports" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:errors:imports", ++ "from": "file:services/knowledge/cmd/server/main.go", ++ "to": "package:errors", ++ "kind": "imports" ++ }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:flag:imports", + "from": "file:services/knowledge/cmd/server/main.go", +@@ -31592,6 +32004,12 @@ + "to": "package:net/http", + "kind": "imports" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:os/signal:imports", ++ "from": "file:services/knowledge/cmd/server/main.go", ++ "to": "package:os/signal", ++ "kind": "imports" ++ }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:os:imports", + "from": "file:services/knowledge/cmd/server/main.go", +@@ -31616,6 +32034,12 @@ + "to": "package:strings", + "kind": "imports" + }, ++ { ++ "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:syscall:imports", ++ "from": "file:services/knowledge/cmd/server/main.go", ++ "to": "package:syscall", ++ "kind": "imports" ++ }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:time:imports", + "from": "file:services/knowledge/cmd/server/main.go", +@@ -32126,6 +32550,12 @@ + "to": "function:kb-editor/internal/staging:summarize", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/internal/staging/staging.go-\u003efunction:kb-editor/internal/staging:syncDir:defines", ++ "from": "file:services/knowledge/internal/staging/staging.go", ++ "to": "function:kb-editor/internal/staging:syncDir", ++ "kind": "defines" ++ }, + { + "id": "file:services/knowledge/internal/staging/staging.go-\u003efunction:kb-editor/internal/staging:toStrings:defines", + "from": "file:services/knowledge/internal/staging/staging.go", +@@ -32294,6 +32724,12 @@ + "to": "function:kb-editor/internal/store:Store.Reload", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:Store.RollbackImported:defines", ++ "from": "file:services/knowledge/internal/store/store.go", ++ "to": "function:kb-editor/internal/store:Store.RollbackImported", ++ "kind": "defines" ++ }, + { + "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:Store.Save:defines", + "from": "file:services/knowledge/internal/store/store.go", +@@ -32438,6 +32874,12 @@ + "to": "function:kb-editor/internal/store:summarize", + "kind": "defines" + }, ++ { ++ "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:syncDir:defines", ++ "from": "file:services/knowledge/internal/store/store.go", ++ "to": "function:kb-editor/internal/store:syncDir", ++ "kind": "defines" ++ }, + { + "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:toStrings:defines", + "from": "file:services/knowledge/internal/store/store.go", +@@ -32570,6 +33012,12 @@ + "to": "function:github.com/example/glpi-ai-agent/cmd/agent:maxDuration", + "kind": "calls" + }, ++ { ++ "id": "function:github.com/example/glpi-ai-agent/cmd/agent:main-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck:calls", ++ "from": "function:github.com/example/glpi-ai-agent/cmd/agent:main", ++ "to": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", ++ "kind": "calls" ++ }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:main-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool:calls", + "from": "function:github.com/example/glpi-ai-agent/cmd/agent:main", +@@ -32709,6 +33157,20 @@ + "kind": "calls_package", + "label": "ToLower" + }, ++ { ++ "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck-\u003epackage:net/http:calls_package", ++ "from": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "NewRequest" ++ }, ++ { ++ "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck-\u003epackage:os:calls_package", ++ "from": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Exit" ++ }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool-\u003epackage:log/slog:calls_package", + "from": "function:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool", +@@ -42348,6 +42810,24 @@ + "kind": "calls_package", + "label": "TrimSpace" + }, ++ { ++ "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:integrationBearerAuthorized:calls", ++ "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "to": "function:kb-editor/cmd/server:integrationBearerAuthorized", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:writeError:calls", ++ "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "to": "function:kb-editor/cmd/server:writeError", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:writeJSON:calls", ++ "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "to": "function:kb-editor/cmd/server:writeJSON", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/cmd/server:app.handleList-\u003efunction:kb-editor/cmd/server:queryFromURL:calls", + "from": "function:kb-editor/cmd/server:app.handleList", +@@ -42612,6 +43092,12 @@ + "kind": "calls_package", + "label": "Errorf" + }, ++ { ++ "id": "function:kb-editor/cmd/server:app.routes-\u003efunction:kb-editor/cmd/server:browserWriteSameOrigin:calls", ++ "from": "function:kb-editor/cmd/server:app.routes", ++ "to": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/cmd/server:app.routes-\u003efunction:kb-editor/cmd/server:securityHeaders:calls", + "from": "function:kb-editor/cmd/server:app.routes", +@@ -42653,6 +43139,33 @@ + "kind": "calls_package", + "label": "ParseDuration" + }, ++ { ++ "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003efunction:kb-editor/cmd/server:writeError:calls", ++ "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "to": "function:kb-editor/cmd/server:writeError", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:net/http:calls_package", ++ "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "HandlerFunc" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:net/url:calls_package", ++ "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "to": "package:net/url", ++ "kind": "calls_package", ++ "label": "Parse" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:strings:calls_package", ++ "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "ToLower" ++ }, + { + "id": "function:kb-editor/cmd/server:configFromEnv-\u003efunction:kb-editor/cmd/server:envOr:calls", + "from": "function:kb-editor/cmd/server:configFromEnv", +@@ -42829,6 +43342,26 @@ + "to": "function:kb-editor/cmd/server:startAutoReload", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/cmd/server:main-\u003efunction:kb-editor/cmd/server:validateRuntimeSecrets:calls", ++ "from": "function:kb-editor/cmd/server:main", ++ "to": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:main-\u003epackage:context:calls_package", ++ "from": "function:kb-editor/cmd/server:main", ++ "to": "package:context", ++ "kind": "calls_package", ++ "label": "Background" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:main-\u003epackage:errors:calls_package", ++ "from": "function:kb-editor/cmd/server:main", ++ "to": "package:errors", ++ "kind": "calls_package", ++ "label": "Is" ++ }, + { + "id": "function:kb-editor/cmd/server:main-\u003epackage:flag:calls_package", + "from": "function:kb-editor/cmd/server:main", +@@ -42857,6 +43390,13 @@ + "kind": "calls_package", + "label": "Fatal" + }, ++ { ++ "id": "function:kb-editor/cmd/server:main-\u003epackage:os/signal:calls_package", ++ "from": "function:kb-editor/cmd/server:main", ++ "to": "package:os/signal", ++ "kind": "calls_package", ++ "label": "NotifyContext" ++ }, + { + "id": "function:kb-editor/cmd/server:main-\u003epackage:os:calls_package", + "from": "function:kb-editor/cmd/server:main", +@@ -43017,6 +43557,27 @@ + "kind": "calls_package", + "label": "NewTicker" + }, ++ { ++ "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:fmt:calls_package", ++ "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:os:calls_package", ++ "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Getenv" ++ }, ++ { ++ "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:strings:calls_package", ++ "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:kb-editor/cmd/server:writeError-\u003efunction:kb-editor/cmd/server:writeJSON:calls", + "from": "function:kb-editor/cmd/server:writeError", +@@ -43945,6 +44506,12 @@ + "to": "function:kb-editor/internal/staging:Store.pathForKey", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/staging:Store.archive-\u003efunction:kb-editor/internal/staging:syncDir:calls", ++ "from": "function:kb-editor/internal/staging:Store.archive", ++ "to": "function:kb-editor/internal/staging:syncDir", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/internal/staging:Store.archive-\u003epackage:fmt:calls_package", + "from": "function:kb-editor/internal/staging:Store.archive", +@@ -44080,6 +44647,12 @@ + "to": "function:kb-editor/internal/staging:Store.Dir", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/staging:atomicWrite-\u003efunction:kb-editor/internal/staging:syncDir:calls", ++ "from": "function:kb-editor/internal/staging:atomicWrite", ++ "to": "function:kb-editor/internal/staging:syncDir", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/internal/staging:atomicWrite-\u003epackage:os:calls_package", + "from": "function:kb-editor/internal/staging:atomicWrite", +@@ -44170,6 +44743,13 @@ + "to": "function:kb-editor/internal/staging:toStrings", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/staging:syncDir-\u003epackage:os:calls_package", ++ "from": "function:kb-editor/internal/staging:syncDir", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Open" ++ }, + { + "id": "function:kb-editor/internal/staging:uniqueStrings-\u003epackage:strings:calls_package", + "from": "function:kb-editor/internal/staging:uniqueStrings", +@@ -44375,6 +44955,12 @@ + "to": "function:kb-editor/internal/store:summarize", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/store:Store.ImportDocument-\u003efunction:kb-editor/internal/store:syncDir:calls", ++ "from": "function:kb-editor/internal/store:Store.ImportDocument", ++ "to": "function:kb-editor/internal/store:syncDir", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/internal/store:Store.ImportDocument-\u003epackage:errors:calls_package", + "from": "function:kb-editor/internal/store:Store.ImportDocument", +@@ -44474,6 +45060,26 @@ + "kind": "calls_package", + "label": "EqualFold" + }, ++ { ++ "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003efunction:kb-editor/internal/store:syncDir:calls", ++ "from": "function:kb-editor/internal/store:Store.RollbackImported", ++ "to": "function:kb-editor/internal/store:syncDir", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003epackage:fmt:calls_package", ++ "from": "function:kb-editor/internal/store:Store.RollbackImported", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003epackage:os:calls_package", ++ "from": "function:kb-editor/internal/store:Store.RollbackImported", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Remove" ++ }, + { + "id": "function:kb-editor/internal/store:Store.Save-\u003efunction:kb-editor/internal/store:New:calls", + "from": "function:kb-editor/internal/store:Store.Save", +@@ -44707,6 +45313,12 @@ + "to": "function:kb-editor/internal/store:marshalDocument", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/store:Store.writeRecord-\u003efunction:kb-editor/internal/store:syncDir:calls", ++ "from": "function:kb-editor/internal/store:Store.writeRecord", ++ "to": "function:kb-editor/internal/store:syncDir", ++ "kind": "calls" ++ }, + { + "id": "function:kb-editor/internal/store:Store.writeRecord-\u003epackage:os:calls_package", + "from": "function:kb-editor/internal/store:Store.writeRecord", +@@ -44966,6 +45578,13 @@ + "to": "function:kb-editor/internal/store:toStrings", + "kind": "calls" + }, ++ { ++ "id": "function:kb-editor/internal/store:syncDir-\u003epackage:os:calls_package", ++ "from": "function:kb-editor/internal/store:syncDir", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Open" ++ }, + { + "id": "function:kb-editor/internal/store:topFacets-\u003epackage:sort:calls_package", + "from": "function:kb-editor/internal/store:topFacets", +@@ -45292,6 +45911,34 @@ + "kind": "calls_package", + "label": "TrimSpace" + }, ++ { ++ "id": "function:mega-control:controlBasicAuth-\u003epackage:crypto/subtle:calls_package", ++ "from": "function:mega-control:controlBasicAuth", ++ "to": "package:crypto/subtle", ++ "kind": "calls_package", ++ "label": "ConstantTimeCompare" ++ }, ++ { ++ "id": "function:mega-control:controlBasicAuth-\u003epackage:net/http:calls_package", ++ "from": "function:mega-control:controlBasicAuth", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "HandlerFunc" ++ }, ++ { ++ "id": "function:mega-control:controlBasicAuth-\u003epackage:os:calls_package", ++ "from": "function:mega-control:controlBasicAuth", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Getenv" ++ }, ++ { ++ "id": "function:mega-control:controlBasicAuth-\u003epackage:strings:calls_package", ++ "from": "function:mega-control:controlBasicAuth", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:mega-control:csvSet-\u003epackage:strings:calls_package", + "from": "function:mega-control:csvSet", +@@ -45320,6 +45967,12 @@ + "kind": "calls_package", + "label": "Unmarshal" + }, ++ { ++ "id": "function:mega-control:main-\u003efunction:mega-control:controlBasicAuth:calls", ++ "from": "function:mega-control:main", ++ "to": "function:mega-control:controlBasicAuth", ++ "kind": "calls" ++ }, + { + "id": "function:mega-control:main-\u003efunction:mega-control:env:calls", + "from": "function:mega-control:main", +@@ -45332,18 +45985,38 @@ + "to": "function:mega-control:secure", + "kind": "calls" + }, ++ { ++ "id": "function:mega-control:main-\u003efunction:mega-control:validateControlSecrets:calls", ++ "from": "function:mega-control:main", ++ "to": "function:mega-control:validateControlSecrets", ++ "kind": "calls" ++ }, + { + "id": "function:mega-control:main-\u003efunction:mega-control:writeJSON:calls", + "from": "function:mega-control:main", + "to": "function:mega-control:writeJSON", + "kind": "calls" + }, ++ { ++ "id": "function:mega-control:main-\u003epackage:context:calls_package", ++ "from": "function:mega-control:main", ++ "to": "package:context", ++ "kind": "calls_package", ++ "label": "Background" ++ }, ++ { ++ "id": "function:mega-control:main-\u003epackage:errors:calls_package", ++ "from": "function:mega-control:main", ++ "to": "package:errors", ++ "kind": "calls_package", ++ "label": "Is" ++ }, + { + "id": "function:mega-control:main-\u003epackage:log:calls_package", + "from": "function:mega-control:main", + "to": "package:log", + "kind": "calls_package", +- "label": "Printf" ++ "label": "Fatal" + }, + { + "id": "function:mega-control:main-\u003epackage:net/http:calls_package", +@@ -45352,6 +46025,13 @@ + "kind": "calls_package", + "label": "NewServeMux" + }, ++ { ++ "id": "function:mega-control:main-\u003epackage:os/signal:calls_package", ++ "from": "function:mega-control:main", ++ "to": "package:os/signal", ++ "kind": "calls_package", ++ "label": "NotifyContext" ++ }, + { + "id": "function:mega-control:main-\u003epackage:os:calls_package", + "from": "function:mega-control:main", +@@ -45760,6 +46440,40 @@ + "kind": "calls_package", + "label": "NewReplacer" + }, ++ { ++ "id": "function:mega-control:validateControlSecrets-\u003efunction:mega-control:server.check:calls", ++ "from": "function:mega-control:validateControlSecrets", ++ "to": "function:mega-control:server.check", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:mega-control:validateControlSecrets-\u003epackage:errors:calls_package", ++ "from": "function:mega-control:validateControlSecrets", ++ "to": "package:errors", ++ "kind": "calls_package", ++ "label": "New" ++ }, ++ { ++ "id": "function:mega-control:validateControlSecrets-\u003epackage:fmt:calls_package", ++ "from": "function:mega-control:validateControlSecrets", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:mega-control:validateControlSecrets-\u003epackage:os:calls_package", ++ "from": "function:mega-control:validateControlSecrets", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Getenv" ++ }, ++ { ++ "id": "function:mega-control:validateControlSecrets-\u003epackage:strings:calls_package", ++ "from": "function:mega-control:validateControlSecrets", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:mega-control:writeJSON-\u003epackage:encoding/json:calls_package", + "from": "function:mega-control:writeJSON", +@@ -45935,6 +46649,12 @@ + "to": "function:neuroforge/cmd/server:maxIntMain", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/cmd/server:run-\u003efunction:neuroforge/cmd/server:validateManagedSecretsFromEnv:calls", ++ "from": "function:neuroforge/cmd/server:run", ++ "to": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", ++ "kind": "calls" ++ }, + { + "id": "function:neuroforge/cmd/server:run-\u003epackage:context:calls_package", + "from": "function:neuroforge/cmd/server:run", +@@ -46040,6 +46760,33 @@ + "kind": "calls_package", + "label": "Duration" + }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecret-\u003epackage:fmt:calls_package", ++ "from": "function:neuroforge/cmd/server:validateManagedSecret", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecret-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/cmd/server:validateManagedSecret", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv-\u003efunction:neuroforge/cmd/server:validateManagedSecret:calls", ++ "from": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", ++ "to": "function:neuroforge/cmd/server:validateManagedSecret", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv-\u003epackage:os:calls_package", ++ "from": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "Getenv" ++ }, + { + "id": "function:neuroforge/cmd/worker:claim-\u003epackage:bytes:calls_package", + "from": "function:neuroforge/cmd/worker:claim", +@@ -46176,6 +46923,13 @@ + "kind": "calls_package", + "label": "Getenv" + }, ++ { ++ "id": "function:neuroforge/cmd/worker:main-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/cmd/worker:main", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:neuroforge/cmd/worker:main-\u003epackage:time:calls_package", + "from": "function:neuroforge/cmd/worker:main", +@@ -46329,6 +47083,46 @@ + "kind": "calls_package", + "label": "TrimSpace" + }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003efunction:neuroforge/internal/brain:Engine.stagingConfig:calls", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "function:neuroforge/internal/brain:Engine.stagingConfig", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003efunction:neuroforge/internal/brain:New:calls", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "function:neuroforge/internal/brain:New", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:fmt:calls_package", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:io:calls_package", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "package:io", ++ "kind": "calls_package", ++ "label": "ReadAll" ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:net/http:calls_package", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "NewRequestWithContext" ++ }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:neuroforge/internal/brain:Engine.ClusterProposeMemory-\u003efunction:neuroforge/internal/brain:Engine.addMemory:calls", + "from": "function:neuroforge/internal/brain:Engine.ClusterProposeMemory", +@@ -46815,6 +47609,12 @@ + "to": "function:neuroforge/internal/brain:Engine.addMemory", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.beginGoalCycle:calls", ++ "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", ++ "to": "function:neuroforge/internal/brain:Engine.beginGoalCycle", ++ "kind": "calls" ++ }, + { + "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.chatModelLimitOn:calls", + "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", +@@ -46827,6 +47627,12 @@ + "to": "function:neuroforge/internal/brain:Engine.embed", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.endGoalCycle:calls", ++ "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", ++ "to": "function:neuroforge/internal/brain:Engine.endGoalCycle", ++ "kind": "calls" ++ }, + { + "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.maybePublishGoalDraft:calls", + "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", +@@ -48894,6 +49700,27 @@ + "to": "function:neuroforge/internal/httpapi:decode", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:fmt:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Errorf" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:os:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", ++ "to": "package:os", ++ "kind": "calls_package", ++ "label": "LookupEnv" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.adminRebalance-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", + "from": "function:neuroforge/internal/httpapi:Server.adminRebalance", +@@ -49331,6 +50158,37 @@ + "to": "function:neuroforge/internal/httpapi:Server.json", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:New:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "to": "function:neuroforge/internal/httpapi:New", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "to": "function:neuroforge/internal/httpapi:Server.err", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:bearer:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "to": "function:neuroforge/internal/httpapi:bearer", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:secureEqual:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "to": "function:neuroforge/internal/httpapi:secureEqual", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003epackage:net/http:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "HandlerFunc" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.err-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", + "from": "function:neuroforge/internal/httpapi:Server.err", +@@ -49367,6 +50225,13 @@ + "to": "function:neuroforge/internal/httpapi:Server.json", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.goalCycle-\u003epackage:errors:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.goalCycle", ++ "to": "package:errors", ++ "kind": "calls_package", ++ "label": "Is" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.goalPause-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", + "from": "function:neuroforge/internal/httpapi:Server.goalPause", +@@ -49590,6 +50455,37 @@ + "to": "function:neuroforge/internal/httpapi:decode", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:New:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "to": "function:neuroforge/internal/httpapi:New", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "to": "function:neuroforge/internal/httpapi:Server.err", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:bearer:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "to": "function:neuroforge/internal/httpapi:bearer", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:secureEqual:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "to": "function:neuroforge/internal/httpapi:secureEqual", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003epackage:net/http:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "HandlerFunc" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", + "from": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph", +@@ -50124,6 +51020,19 @@ + "to": "function:neuroforge/internal/httpapi:Server.json", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003efunction:neuroforge/internal/httpapi:checkConfiguredOllamaModels:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.readyz", ++ "to": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003epackage:context:calls_package", ++ "from": "function:neuroforge/internal/httpapi:Server.readyz", ++ "to": "package:context", ++ "kind": "calls_package", ++ "label": "WithTimeout" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003epackage:strings:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.readyz", +@@ -50193,6 +51102,18 @@ + "to": "function:neuroforge/internal/httpapi:Server.clusterAuth", + "kind": "calls" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.controlReadAuth:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.routes", ++ "to": "function:neuroforge/internal/httpapi:Server.controlReadAuth", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.integrationAuth:calls", ++ "from": "function:neuroforge/internal/httpapi:Server.routes", ++ "to": "function:neuroforge/internal/httpapi:Server.integrationAuth", ++ "kind": "calls" ++ }, + { + "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", + "from": "function:neuroforge/internal/httpapi:Server.routes", +@@ -50412,6 +51333,54 @@ + "kind": "calls_package", + "label": "HasPrefix" + }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003efunction:neuroforge/internal/httpapi:configuredModelAvailable:calls", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "function:neuroforge/internal/httpapi:configuredModelAvailable", ++ "kind": "calls" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:encoding/json:calls_package", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "package:encoding/json", ++ "kind": "calls_package", ++ "label": "NewDecoder" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:fmt:calls_package", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "package:fmt", ++ "kind": "calls_package", ++ "label": "Sprintf" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:io:calls_package", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "package:io", ++ "kind": "calls_package", ++ "label": "LimitReader" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:net/http:calls_package", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "package:net/http", ++ "kind": "calls_package", ++ "label": "NewRequestWithContext" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, ++ { ++ "id": "function:neuroforge/internal/httpapi:configuredModelAvailable-\u003epackage:strings:calls_package", ++ "from": "function:neuroforge/internal/httpapi:configuredModelAvailable", ++ "to": "package:strings", ++ "kind": "calls_package", ++ "label": "TrimSpace" ++ }, + { + "id": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot-\u003epackage:runtime:calls_package", + "from": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot", +@@ -58027,6 +58996,12 @@ + "to": "route:GET /api/health", + "kind": "defines_route" + }, ++ { ++ "id": "package:kb-editor/cmd/server-\u003eroute:GET /api/integrations/staging/health:defines_route", ++ "from": "package:kb-editor/cmd/server", ++ "to": "route:GET /api/integrations/staging/health", ++ "kind": "defines_route" ++ }, + { + "id": "package:kb-editor/cmd/server-\u003eroute:GET /api/items/{key}:defines_route", + "from": "package:kb-editor/cmd/server", +@@ -59395,6 +60370,12 @@ + "to": "function:kb-editor/cmd/server:app.handleHealth", + "kind": "handles" + }, ++ { ++ "id": "route:GET /api/integrations/staging/health-\u003efunction:kb-editor/cmd/server:app.handleIntegrationStagingHealth:handles", ++ "from": "route:GET /api/integrations/staging/health", ++ "to": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", ++ "kind": "handles" ++ }, + { + "id": "route:GET /api/items-\u003efunction:kb-editor/cmd/server:app.handleList:handles", + "from": "route:GET /api/items", +@@ -60019,42 +61000,12 @@ + "to": "service:agent-data-init", + "kind": "depends_on" + }, +- { +- "id": "service:agent-\u003eservice:neuroforge:depends_on", +- "from": "service:agent", +- "to": "service:neuroforge", +- "kind": "depends_on" +- }, + { + "id": "service:agent-\u003eservice:ollama:depends_on", + "from": "service:agent", + "to": "service:ollama", + "kind": "depends_on" + }, +- { +- "id": "service:control-\u003eservice:agent:depends_on", +- "from": "service:control", +- "to": "service:agent", +- "kind": "depends_on" +- }, +- { +- "id": "service:control-\u003eservice:knowledge:depends_on", +- "from": "service:control", +- "to": "service:knowledge", +- "kind": "depends_on" +- }, +- { +- "id": "service:control-\u003eservice:neuroforge:depends_on", +- "from": "service:control", +- "to": "service:neuroforge", +- "kind": "depends_on" +- }, +- { +- "id": "service:knowledge-\u003eservice:neuroforge:depends_on", +- "from": "service:knowledge", +- "to": "service:neuroforge", +- "kind": "depends_on" +- }, + { + "id": "service:knowledge-\u003eservice:ollama:depends_on", + "from": "service:knowledge", +@@ -60075,10 +61026,10 @@ + } + ], + "meta": { +- "edges": 6525, ++ "edges": 6633, + "format_version": 1, + "generator": "go-ast+compose", + "modules": 4, +- "nodes": 1669 ++ "nodes": 1689 + } + } +diff --git a/services/control/main.go b/services/control/main.go +index 10ab3e26b9c9f4841f48313cbc551047ed48f5b7..4b1dc0087c378020fdbbc8a95c8c1f145065308a 100644 +--- a/services/control/main.go ++++ b/services/control/main.go +@@ -2,14 +2,18 @@ package main + + import ( + "context" ++ "crypto/subtle" + "embed" + "encoding/json" ++ "errors" + "fmt" + "io" + "log" + "net/http" + "os" ++ "os/signal" + "strings" ++ "syscall" + "time" + ) + +@@ -70,6 +74,11 @@ func env(k, d string) string { + } + + func main() { ++ if err := validateControlSecrets(); err != nil { ++ log.Fatal(err) ++ } ++ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ++ defer stop() + agentURL := env("AGENT_URL", "http://agent:8080") + nfURL := env("NEUROFORGE_URL", "http://neuroforge:8080") + nfKeyRaw := strings.TrimSpace(os.Getenv("NEUROFORGE_API_KEY")) +@@ -109,9 +118,84 @@ func main() { + _, _ = w.Write(b) + }) + addr := env("CONTROL_ADDR", ":8070") +- srv := &http.Server{Addr: addr, Handler: secure(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} ++ srv := &http.Server{Addr: addr, Handler: secure(controlBasicAuth(mux)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} + log.Printf("mega control listening on %s", addr) +- log.Fatal(srv.ListenAndServe()) ++ errCh := make(chan error, 1) ++ go func() { ++ err := srv.ListenAndServe() ++ if errors.Is(err, http.ErrServerClosed) { ++ err = nil ++ } ++ errCh <- err ++ }() ++ select { ++ case err := <-errCh: ++ if err != nil { ++ log.Printf("control HTTP server stopped unexpectedly: %v", err) ++ } ++ case <-ctx.Done(): ++ log.Printf("control shutdown requested") ++ } ++ shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) ++ defer cancel() ++ if err := srv.Shutdown(shutdownCtx); err != nil { ++ log.Printf("control graceful shutdown failed: %v", err) ++ _ = srv.Close() ++ } ++} ++ ++func validateControlSecrets() error { ++ check := func(name string, min int) error { ++ v := strings.TrimSpace(os.Getenv(name)) ++ if v == "" { ++ return nil ++ } ++ u := strings.ToUpper(v) ++ if strings.Contains(u, "CHANGE_ME") || strings.Contains(u, "CHANGEME") || strings.Contains(u, "PLACEHOLDER") { ++ return fmt.Errorf("%s still contains a placeholder", name) ++ } ++ if len(v) < min { ++ return fmt.Errorf("%s must be at least %d characters", name, min) ++ } ++ return nil ++ } ++ if err := check("NEUROFORGE_API_KEY", 24); err != nil { ++ return err ++ } ++ if err := check("CONTROL_READ_TOKEN", 24); err != nil { ++ return err ++ } ++ if err := check("CONTROL_BASIC_AUTH_PASSWORD", 12); err != nil { ++ return err ++ } ++ u, p := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER")), strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD")) ++ if (u == "") != (p == "") { ++ return errors.New("CONTROL_BASIC_AUTH_USER and CONTROL_BASIC_AUTH_PASSWORD must both be set or both be empty") ++ } ++ return nil ++} ++ ++func controlBasicAuth(next http.Handler) http.Handler { ++ user := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER")) ++ pass := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD")) ++ if user == "" && pass == "" { ++ return next ++ } ++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ if r.URL.Path == "/healthz" { ++ next.ServeHTTP(w, r) ++ return ++ } ++ u, p, ok := r.BasicAuth() ++ userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 ++ passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 ++ if !ok || !userOK || !passOK { ++ w.Header().Set("WWW-Authenticate", `Basic realm="NeuroForge Control", charset="UTF-8"`) ++ http.Error(w, "authentication required", http.StatusUnauthorized) ++ return ++ } ++ next.ServeHTTP(w, r) ++ }) + } + + func secure(next http.Handler) http.Handler { +diff --git a/services/control/security_test.go b/services/control/security_test.go +new file mode 100644 +index 0000000000000000000000000000000000000000..0928e633b1e4e60737d55ce5813d9649ef0bb2b2 +--- /dev/null ++++ b/services/control/security_test.go +@@ -0,0 +1,43 @@ ++package main ++ ++import ( ++ "net/http" ++ "net/http/httptest" ++ "testing" ++) ++ ++func TestControlBasicAuthProtectsOperationsButNotHealth(t *testing.T) { ++ t.Setenv("CONTROL_BASIC_AUTH_USER", "operator") ++ t.Setenv("CONTROL_BASIC_AUTH_PASSWORD", "control-password-123456") ++ h := controlBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) ++ ++ health := httptest.NewRecorder() ++ h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil)) ++ if health.Code != http.StatusOK { ++ t.Fatalf("health status=%d", health.Code) ++ } ++ ++ unauth := httptest.NewRecorder() ++ h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/status", nil)) ++ if unauth.Code != http.StatusUnauthorized { ++ t.Fatalf("unauth status=%d", unauth.Code) ++ } ++ ++ req := httptest.NewRequest(http.MethodGet, "/api/status", nil) ++ req.SetBasicAuth("operator", "control-password-123456") ++ auth := httptest.NewRecorder() ++ h.ServeHTTP(auth, req) ++ if auth.Code != http.StatusOK { ++ t.Fatalf("auth status=%d", auth.Code) ++ } ++} ++ ++func TestControlSecretValidationRejectsPlaceholder(t *testing.T) { ++ t.Setenv("NEUROFORGE_API_KEY", "valid-neuroforge-read-token-123456") ++ t.Setenv("CONTROL_READ_TOKEN", "valid-agent-control-token-123456") ++ t.Setenv("CONTROL_BASIC_AUTH_USER", "operator") ++ t.Setenv("CONTROL_BASIC_AUTH_PASSWORD", "CHANGE_ME_CONTROL_PASSWORD") ++ if err := validateControlSecrets(); err == nil { ++ t.Fatal("placeholder password accepted") ++ } ++} +diff --git a/services/knowledge/Dockerfile b/services/knowledge/Dockerfile +index 25f96149e7736704cc18275350c82e1b078e9380..e10cd08d3b1921a062426818af2e0f0120eec441 100644 +--- a/services/knowledge/Dockerfile ++++ b/services/knowledge/Dockerfile +@@ -3,7 +3,9 @@ WORKDIR /src + COPY go.mod ./ + COPY cmd ./cmd + COPY internal ./internal +-RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/kb-helpdesk ./cmd/server ++RUN go test ./... && \ ++ go vet ./... && \ ++ CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/kb-helpdesk ./cmd/server + + FROM alpine:3.24 + RUN apk add --no-cache ca-certificates tzdata +diff --git a/services/knowledge/cmd/server/app.go b/services/knowledge/cmd/server/app.go +index 84ba9d89714a6ed8c17bdb2efb1afcb93b42dc2c..a70102009bcb622939b7363acf11b972f1a26de7 100644 +--- a/services/knowledge/cmd/server/app.go ++++ b/services/knowledge/cmd/server/app.go +@@ -9,6 +9,7 @@ import ( + "io" + "io/fs" + "net/http" ++ "net/url" + "os" + "strconv" + "strings" +@@ -72,6 +73,7 @@ func (a *app) routes() http.Handler { + mux.HandleFunc("GET /api/staging", a.handleStagingList) + mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet) + mux.HandleFunc("POST /api/integrations/staging", a.handleIntegrationStaging) ++ mux.HandleFunc("GET /api/integrations/staging/health", a.handleIntegrationStagingHealth) + + if a.config.Writable { + mux.HandleFunc("PUT /api/items/{key}", a.handlePut) +@@ -93,7 +95,30 @@ func (a *app) routes() http.Handler { + + static := http.FileServer(http.FS(a.web)) + mux.Handle("GET /", static) +- return securityHeaders(mux) ++ return securityHeaders(browserWriteSameOrigin(mux)) ++} ++ ++func browserWriteSameOrigin(next http.Handler) http.Handler { ++ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ++ if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions || r.URL.Path == "/api/integrations/staging" { ++ next.ServeHTTP(w, r) ++ return ++ } ++ fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site"))) ++ if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" { ++ writeError(w, http.StatusForbidden, "cross-origin browser write blocked") ++ return ++ } ++ origin := strings.TrimSpace(r.Header.Get("Origin")) ++ if origin != "" { ++ u, err := url.Parse(origin) ++ if err != nil || !strings.EqualFold(u.Host, r.Host) { ++ writeError(w, http.StatusForbidden, "cross-origin browser write blocked") ++ return ++ } ++ } ++ next.ServeHTTP(w, r) ++ }) + } + + func securityHeaders(next http.Handler) http.Handler { +@@ -265,6 +290,19 @@ func integrationBearerAuthorized(r *http.Request) (bool, bool) { + return true, subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 + } + ++func (a *app) handleIntegrationStagingHealth(w http.ResponseWriter, r *http.Request) { ++ enabled, authorized := integrationBearerAuthorized(r) ++ if !enabled || a.staging == nil { ++ writeError(w, http.StatusServiceUnavailable, "KB staging integration is disabled") ++ return ++ } ++ if !authorized { ++ writeError(w, http.StatusUnauthorized, "invalid integration token") ++ return ++ } ++ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "staging": true, "writable": a.config.Writable}) ++} ++ + // handleIntegrationStaging is a one-way governance boundary: machine-generated + // research may enter human review, but it cannot write production knowledge or + // enable automatic replies. +@@ -485,7 +523,11 @@ func (a *app) promoteStaging(key string) (map[string]any, error) { + } + archive, err := a.staging.ArchiveApproved(key) + if err != nil { +- return nil, fmt.Errorf("Produktivdatei wurde erstellt (%s), aber Staging konnte nicht als freigegeben archiviert werden: %w", summary.RelPath, err) ++ rollbackErr := a.store.RollbackImported(summary) ++ if rollbackErr != nil { ++ return nil, fmt.Errorf("staging archive failed after production import (%s); rollback also failed: archive=%v rollback=%v", summary.RelPath, err, rollbackErr) ++ } ++ return nil, fmt.Errorf("staging archive failed; production import %s was rolled back: %w", summary.RelPath, err) + } + return map[string]any{"ok": true, "production": summary, "staging_key": key, "staging_archive": archive}, nil + } +diff --git a/services/knowledge/cmd/server/app_test.go b/services/knowledge/cmd/server/app_test.go +index 7c801c0044f233fe292f54b6dea937eb3ccf54e8..0b33453983e832c0fbc5b487048770109e6256bc 100644 +--- a/services/knowledge/cmd/server/app_test.go ++++ b/services/knowledge/cmd/server/app_test.go +@@ -459,3 +459,93 @@ func TestIntegrationDraftWithStableKeyUpdatesInsteadOfDuplicating(t *testing.T) + t.Fatalf("draft not refreshed: %#v", got.Document) + } + } ++ ++func TestBrowserWriteSameOriginGuardRejectsCrossSiteWrite(t *testing.T) { ++ s, err := store.New(t.TempDir()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ web, err := fs.Sub(webFS, "web") ++ if err != nil { ++ t.Fatal(err) ++ } ++ h := newApp(s, web, appConfig{Mode: "editor", Writable: true}).routes() ++ req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewBufferString(`{"keys":[],"dry_run":true}`)) ++ req.Header.Set("Content-Type", "application/json") ++ req.Header.Set("Sec-Fetch-Site", "cross-site") ++ req.Header.Set("Origin", "https://evil.invalid") ++ rr := httptest.NewRecorder() ++ h.ServeHTTP(rr, req) ++ if rr.Code != http.StatusForbidden { ++ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++} ++ ++func TestPromotionRollsBackProductionWhenStagingArchiveFails(t *testing.T) { ++ knowledgeDir := t.TempDir() ++ stagingDir := t.TempDir() ++ t.Setenv("BACKUP_DIR", filepath.Join(t.TempDir(), "backups")) ++ s, err := store.New(knowledgeDir) ++ if err != nil { ++ t.Fatal(err) ++ } ++ st, err := staging.New(stagingDir) ++ if err != nil { ++ t.Fatal(err) ++ } ++ draft, err := st.Save("rollback", "test", staging.Draft{Title: "Rollback", Text: "Symptom", Answer: "Lösung"}, false, .8) ++ if err != nil { ++ t.Fatal(err) ++ } ++ // Force ArchiveApproved to fail after ImportDocument by occupying the archive ++ // directory path with a regular file. ++ if err := os.WriteFile(filepath.Join(stagingDir, ".approved"), []byte("block"), 0o644); err != nil { ++ t.Fatal(err) ++ } ++ web, err := fs.Sub(webFS, "web") ++ if err != nil { ++ t.Fatal(err) ++ } ++ a := newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st) ++ if _, err := a.promoteStaging(draft.Key); err == nil || !strings.Contains(err.Error(), "rolled back") { ++ t.Fatalf("promotion error=%v", err) ++ } ++ if s.Count() != 0 { ++ t.Fatalf("production count=%d, want rollback to zero", s.Count()) ++ } ++ if _, err := st.Get(draft.Key); err != nil { ++ t.Fatalf("staging draft should remain for retry: %v", err) ++ } ++} ++ ++func TestIntegrationStagingHealthBypassesUIBasicAuthButRequiresBearer(t *testing.T) { ++ t.Setenv("BASIC_AUTH_USER", "editor") ++ t.Setenv("BASIC_AUTH_PASSWORD", "knowledge-password-123456") ++ t.Setenv("KB_INTEGRATION_TOKEN", "integration-token-12345678901234567890") ++ s, err := store.New(t.TempDir()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ st, err := staging.New(t.TempDir()) ++ if err != nil { ++ t.Fatal(err) ++ } ++ web, err := fs.Sub(webFS, "web") ++ if err != nil { ++ t.Fatal(err) ++ } ++ h := optionalBasicAuth(newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st).routes()) ++ ++ unauth := httptest.NewRecorder() ++ h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil)) ++ if unauth.Code != http.StatusUnauthorized || strings.Contains(unauth.Body.String(), "authentication required") { ++ t.Fatalf("request should reach bearer guard, status=%d body=%s", unauth.Code, unauth.Body.String()) ++ } ++ req := httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil) ++ req.Header.Set("Authorization", "Bearer integration-token-12345678901234567890") ++ rr := httptest.NewRecorder() ++ h.ServeHTTP(rr, req) ++ if rr.Code != http.StatusOK { ++ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) ++ } ++} +diff --git a/services/knowledge/cmd/server/main.go b/services/knowledge/cmd/server/main.go +index 6ba204a41beee5b8b4546aae6d6994cb27be39a5..c2b3233c3b2bead4c6e10286b0855624a38c185f 100644 +--- a/services/knowledge/cmd/server/main.go ++++ b/services/knowledge/cmd/server/main.go +@@ -1,17 +1,21 @@ + package main + + import ( ++ "context" + "crypto/subtle" + "embed" ++ "errors" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" ++ "os/signal" + "path/filepath" + "strconv" + "strings" ++ "syscall" + "time" + + "kb-editor/internal/aifallback" +@@ -29,6 +33,12 @@ func main() { + flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address") + flag.Parse() + ++ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ++ defer stop() ++ if err := validateRuntimeSecrets(); err != nil { ++ log.Fatal(err) ++ } ++ + cfg, staticDir, err := configFromEnv() + if err != nil { + log.Fatal(err) +@@ -58,7 +68,7 @@ func main() { + log.Fatal(err) + } + if reloadInterval > 0 { +- go startAutoReload(s, reloadInterval) ++ go startAutoReload(ctx, s, reloadInterval) + } + + sub, err := fs.Sub(webFS, staticDir) +@@ -95,11 +105,60 @@ func main() { + if u := os.Getenv("BASIC_AUTH_USER"); u != "" { + log.Printf("Basic authentication enabled for user %q", u) + } +- if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { +- log.Fatal(err) ++ errCh := make(chan error, 1) ++ go func() { ++ err := srv.ListenAndServe() ++ if errors.Is(err, http.ErrServerClosed) { ++ err = nil ++ } ++ errCh <- err ++ }() ++ select { ++ case err := <-errCh: ++ if err != nil { ++ log.Printf("KB HTTP server stopped unexpectedly: %v", err) ++ } ++ case <-ctx.Done(): ++ log.Printf("KB shutdown requested") ++ } ++ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ++ defer cancel() ++ if err := srv.Shutdown(shutdownCtx); err != nil { ++ log.Printf("KB graceful shutdown failed: %v", err) ++ _ = srv.Close() + } + } + ++func validateRuntimeSecrets() error { ++ check := func(name string, min int) error { ++ v := strings.TrimSpace(os.Getenv(name)) ++ if v == "" { ++ return nil ++ } ++ upper := strings.ToUpper(v) ++ if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") { ++ return fmt.Errorf("%s still contains a placeholder", name) ++ } ++ if len(v) < min { ++ return fmt.Errorf("%s must be at least %d characters", name, min) ++ } ++ return nil ++ } ++ for _, item := range []struct { ++ name string ++ min int ++ }{ ++ {"KB_INTEGRATION_TOKEN", 24}, ++ {"BASIC_AUTH_PASSWORD", 12}, ++ {"BRAIN_ACTIVITY_API_KEY", 24}, ++ } { ++ if err := check(item.name, item.min); err != nil { ++ return err ++ } ++ } ++ return nil ++} ++ + func configFromEnv() (appConfig, string, error) { + mode := strings.ToLower(strings.TrimSpace(envOr("APP_MODE", "editor"))) + switch mode { +@@ -143,12 +202,17 @@ func autoReloadInterval(mode string) (time.Duration, error) { + return d, nil + } + +-func startAutoReload(s *store.Store, interval time.Duration) { ++func startAutoReload(ctx context.Context, s *store.Store, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() +- for range ticker.C { +- if err := s.Reload(); err != nil { +- log.Printf("automatic index reload failed: %v", err) ++ for { ++ select { ++ case <-ctx.Done(): ++ return ++ case <-ticker.C: ++ if err := s.Reload(); err != nil { ++ log.Printf("automatic index reload failed: %v", err) ++ } + } + } + } +@@ -254,7 +318,7 @@ func optionalBasicAuth(next http.Handler) http.Handler { + log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty") + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +- if (r.Method == http.MethodGet && r.URL.Path == "/api/health") || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") { ++ if (r.Method == http.MethodGet && (r.URL.Path == "/api/health" || r.URL.Path == "/api/integrations/staging/health")) || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") { + next.ServeHTTP(w, r) + return + } +diff --git a/services/knowledge/internal/staging/staging.go b/services/knowledge/internal/staging/staging.go +index 869ef82f49247afa5753469a385844eebb5e79a5..b42f179e059f044debcccc05e012a26609dfa02b 100644 +--- a/services/knowledge/internal/staging/staging.go ++++ b/services/knowledge/internal/staging/staging.go +@@ -377,6 +377,24 @@ func (s *Store) archive(key, bucket string) (string, error) { + if err := os.Rename(path, dst); err != nil { + return "", fmt.Errorf("move staging file to %s: %w", bucket, err) + } ++ rollbackRename := func(cause error) error { ++ if err := os.Rename(dst, path); err != nil { ++ return fmt.Errorf("%v; staging archive rollback failed: %w", cause, err) ++ } ++ if err := syncDir(s.dir); err != nil { ++ return fmt.Errorf("%v; staging rollback source sync failed: %w", cause, err) ++ } ++ if err := syncDir(archiveDir); err != nil { ++ return fmt.Errorf("%v; staging rollback archive sync failed: %w", cause, err) ++ } ++ return cause ++ } ++ if err := syncDir(s.dir); err != nil { ++ return "", rollbackRename(fmt.Errorf("sync staging directory: %w", err)) ++ } ++ if err := syncDir(archiveDir); err != nil { ++ return "", rollbackRename(fmt.Errorf("sync staging archive: %w", err)) ++ } + return dst, nil + } + +@@ -433,7 +451,16 @@ func atomicWrite(path string, payload []byte, mode os.FileMode) error { + if err := os.Rename(tmpName, path); err != nil { + return err + } +- return nil ++ return syncDir(filepath.Dir(path)) ++} ++ ++func syncDir(dir string) error { ++ f, err := os.Open(dir) ++ if err != nil { ++ return err ++ } ++ defer f.Close() ++ return f.Sync() + } + + func summarize(result Result) Summary { +diff --git a/services/knowledge/internal/store/store.go b/services/knowledge/internal/store/store.go +index abaa16fbe92640e512288ed62336d3f9a72cd1fa..3cbc654eed78326c9e856db3e206ef760306abd9 100644 +--- a/services/knowledge/internal/store/store.go ++++ b/services/knowledge/internal/store/store.go +@@ -686,6 +686,10 @@ func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summar + _ = os.Remove(tmpName) + return Summary{}, err + } ++ if err := syncDir(s.dataDir); err != nil { ++ _ = os.Remove(path) ++ return Summary{}, err ++ } + rec, err := s.readRecord(path) + if err != nil { + _ = os.Remove(path) +@@ -697,6 +701,47 @@ func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summar + return summarize(rec), nil + } + ++// RollbackImported removes exactly the production record created by ImportDocument. ++// The checksum guard prevents rollback from deleting a file that was changed after ++// import. It is used to keep staging promotion transactional. ++func (s *Store) RollbackImported(summary Summary) error { ++ s.mu.Lock() ++ defer s.mu.Unlock() ++ rec, ok := s.records[summary.Key] ++ if !ok { ++ return os.ErrNotExist ++ } ++ if summary.Checksum == "" || rec.Checksum != summary.Checksum { ++ return fmt.Errorf("refusing rollback: production record %s changed after import", summary.RelPath) ++ } ++ if err := os.Remove(rec.Path); err != nil { ++ return err ++ } ++ // The namespace mutation already happened once Remove succeeded. Reflect it ++ // in memory even when the durability fsync below reports an I/O error; ++ // otherwise this process would serve a record whose file no longer exists. ++ delete(s.records, summary.Key) ++ for i, key := range s.order { ++ if key == summary.Key { ++ s.order = append(s.order[:i], s.order[i+1:]...) ++ break ++ } ++ } ++ if err := syncDir(s.dataDir); err != nil { ++ return fmt.Errorf("sync production directory after rollback: %w", err) ++ } ++ return nil ++} ++ ++func syncDir(dir string) error { ++ f, err := os.Open(dir) ++ if err != nil { ++ return err ++ } ++ defer f.Close() ++ return f.Sync() ++} ++ + func safeFilenameBase(value string) string { + value = strings.TrimSpace(value) + if value == "" { +@@ -947,6 +992,9 @@ func (s *Store) writeRecord(rec *record, doc map[string]any) (*record, error) { + _ = os.Remove(tmpName) + return nil, err + } ++ if err := syncDir(filepath.Dir(rec.Path)); err != nil { ++ return nil, err ++ } + return s.readRecord(rec.Path) + } + diff --git a/platform/neuroforge/Dockerfile b/platform/neuroforge/Dockerfile index 033bf91..fb3d593 100644 --- a/platform/neuroforge/Dockerfile +++ b/platform/neuroforge/Dockerfile @@ -3,7 +3,9 @@ WORKDIR /src COPY go.mod ./ COPY cmd ./cmd COPY internal ./internal -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \ +RUN go test ./... && \ + go vet ./... && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge ./cmd/server && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-worker ./cmd/worker && \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/neuroforge-bench ./cmd/bench diff --git a/platform/neuroforge/cmd/server/main.go b/platform/neuroforge/cmd/server/main.go index c0fa807..c94a91a 100644 --- a/platform/neuroforge/cmd/server/main.go +++ b/platform/neuroforge/cmd/server/main.go @@ -47,6 +47,41 @@ func envInt(name string) (int, bool) { return v, true } +func validateManagedSecret(name, value string, minLen int) error { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + upper := strings.ToUpper(value) + if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") { + return fmt.Errorf("%s still contains a placeholder", name) + } + if len(value) < minLen { + return fmt.Errorf("%s must be at least %d characters", name, minLen) + } + return nil +} + +func validateManagedSecretsFromEnv() error { + for _, item := range []struct { + name string + min int + }{ + {"NEUROFORGE_ADMIN_TOKEN", 24}, + {"NEUROFORGE_APP_API_KEY", 24}, + {"NEUROFORGE_INTEGRATION_TOKEN", 24}, + {"NEUROFORGE_CONTROL_READ_TOKEN", 24}, + {"NEUROFORGE_WORKER_TOKEN", 24}, + {"NEUROFORGE_METRICS_TOKEN", 24}, + {"NEUROFORGE_CLUSTER_TOKEN", 24}, + } { + if err := validateManagedSecret(item.name, os.Getenv(item.name), item.min); err != nil { + return err + } + } + return nil +} + func maxIntMain(a, b int) int { if a > b { return a @@ -66,6 +101,10 @@ func run() (retErr error) { listen := flag.String("listen", "", "listen address override") flag.Parse() + if err := validateManagedSecretsFromEnv(); err != nil { + return err + } + s, err := store.New(*data) if err != nil { return err @@ -79,12 +118,14 @@ func run() (retErr error) { sec := s.Secrets() changed := false for name, dst := range map[string]*string{ - "OPENAI_API_KEY": &sec.OpenAIAPIKey, - "NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken, - "NEUROFORGE_APP_API_KEY": &sec.AppAPIKey, - "NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken, - "NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken, - "NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken, + "OPENAI_API_KEY": &sec.OpenAIAPIKey, + "NEUROFORGE_ADMIN_TOKEN": &sec.AdminToken, + "NEUROFORGE_APP_API_KEY": &sec.AppAPIKey, + "NEUROFORGE_INTEGRATION_TOKEN": &sec.IntegrationToken, + "NEUROFORGE_CONTROL_READ_TOKEN": &sec.ControlReadToken, + "NEUROFORGE_WORKER_TOKEN": &sec.WorkerToken, + "NEUROFORGE_METRICS_TOKEN": &sec.MetricsToken, + "NEUROFORGE_CLUSTER_TOKEN": &sec.ClusterToken, } { if v := os.Getenv(name); v != "" { *dst = v @@ -230,6 +271,9 @@ func run() (retErr error) { defer stopMaintenance() go b.RunV6Maintenance(maintenanceCtx) api := httpapi.New(s, b, r, c) + if v, ok := envBool("NEUROFORGE_READINESS_OLLAMA_LIVE"); ok { + api.SetReadinessOllamaLive(v) + } cfg := s.Config() addr := cfg.Listen if *listen != "" { diff --git a/platform/neuroforge/cmd/worker/main.go b/platform/neuroforge/cmd/worker/main.go index ff2914d..a6ffd1d 100644 --- a/platform/neuroforge/cmd/worker/main.go +++ b/platform/neuroforge/cmd/worker/main.go @@ -38,17 +38,17 @@ type relinkResult struct { func main() { server := flag.String("server", "http://localhost:8080", "NeuroForge server") - token := flag.String("token", os.Getenv("NEUROFORGE_WORKER_TOKEN"), "worker token") id := flag.String("id", hostname(), "worker id") interval := flag.Duration("interval", 2*time.Second, "poll interval") flag.Parse() - if *token == "" { - log.Fatal("worker token required (-token or NEUROFORGE_WORKER_TOKEN)") + token := strings.TrimSpace(os.Getenv("NEUROFORGE_WORKER_TOKEN")) + if token == "" { + log.Fatal("NEUROFORGE_WORKER_TOKEN is required") } client := &http.Client{Timeout: 180 * time.Second} log.Printf("worker %s polling %s", *id, *server) for { - job, err := claim(client, *server, *token, *id) + job, err := claim(client, *server, token, *id) if err != nil { log.Printf("claim: %v", err) time.Sleep(*interval) @@ -59,7 +59,7 @@ func main() { continue } res, jobErr := run(job) - if err := complete(client, *server, *token, *id, job.ID, res, jobErr); err != nil { + if err := complete(client, *server, token, *id, job.ID, res, jobErr); err != nil { log.Printf("complete %s: %v", job.ID, err) } else { log.Printf("job %s %s done", job.ID, job.Type) diff --git a/platform/neuroforge/internal/brain/brain.go b/platform/neuroforge/internal/brain/brain.go index b99977f..e4add8a 100644 --- a/platform/neuroforge/internal/brain/brain.go +++ b/platform/neuroforge/internal/brain/brain.go @@ -34,10 +34,30 @@ type Engine struct { electionRunning bool stagingMu sync.RWMutex staging StagingPublisherConfig + goalCycleMu sync.Mutex + goalCycles map[string]struct{} } +var ErrGoalCycleInProgress = errors.New("goal cycle already in progress") + func New(s *store.Store, r *provider.Router, c *cost.Manager) *Engine { - return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}} + return &Engine{store: s, router: r, cost: c, http: &http.Client{Timeout: 10 * time.Second}, goalCycles: map[string]struct{}{}} +} + +func (e *Engine) beginGoalCycle(goalID string) bool { + e.goalCycleMu.Lock() + defer e.goalCycleMu.Unlock() + if _, ok := e.goalCycles[goalID]; ok { + return false + } + e.goalCycles[goalID] = struct{}{} + return true +} + +func (e *Engine) endGoalCycle(goalID string) { + e.goalCycleMu.Lock() + delete(e.goalCycles, goalID) + e.goalCycleMu.Unlock() } type ChatRequest struct { diff --git a/platform/neuroforge/internal/brain/goal_progress_test.go b/platform/neuroforge/internal/brain/goal_progress_test.go index 5f41b12..8caa116 100644 --- a/platform/neuroforge/internal/brain/goal_progress_test.go +++ b/platform/neuroforge/internal/brain/goal_progress_test.go @@ -150,3 +150,68 @@ func TestGoalProgressDoesNotRegressWhenResearchAuditRunsAreTrimmed(t *testing.T) t.Fatalf("counters regressed: %#v", g) } } + +func TestResearchProgressAndStagingRunWhenGoalSummaryLearningDisabled(t *testing.T) { + searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{{ + "title": "Vendor evidence", "url": "https://example.com/vendor", "content": "A supported driver package resolves the documented device issue.", "engine": "test", "score": 0.9, + }}}) + })) + defer searx.Close() + stagingCalls := 0 + kb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + stagingCalls++ + if r.Header.Get("Authorization") != "Bearer staging-token-123456789012345678901234" { + t.Fatalf("bad staging auth") + } + _ = json.NewEncoder(w).Encode(map[string]any{"staging": map[string]any{"key": "KB-STAGING-GOAL", "meta": map[string]any{"integration_action": "created"}}}) + })) + defer kb.Close() + + s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/embed" { + _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}, "prompt_eval_count": 1}) + return + } + http.NotFound(w, r) + }) + cfg := s.Config() + cfg.Research.Enabled = true + cfg.Research.SearXNG.Enabled = true + cfg.Research.SearXNG.BaseURL = searx.URL + cfg.Research.Goal.Enabled = true + cfg.Research.WebFetch.Enabled = false + cfg.Brain.LearningPolicy.Enabled = true + cfg.Brain.LearningPolicy.LearnGoalCycles = false + cfg.Autonomy.UseLLM = false + cfg.Brain.ExternalRelinkWorker = false + if err := s.UpdateConfig(cfg); err != nil { + t.Fatal(err) + } + e.ConfigureStagingPublisher(StagingPublisherConfig{Enabled: true, URL: kb.URL, Token: "staging-token-123456789012345678901234", MinEvidence: 1, MinSources: 1, MaxEvidence: 4}) + goal := core.Goal{Title: "Driver research", Description: "collect sourced driver evidence", Target: "1 quellengebundener Wissenseintrag", Status: core.GoalActive, Priority: 80, ResearchEnabled: true} + if err := s.UpsertGoal(&goal); err != nil { + t.Fatal(err) + } + + cycle, err := e.RunGoalCycle(context.Background(), goal.ID) + if err != nil { + t.Fatal(err) + } + if cycle.MemoryID != "" { + t.Fatalf("goal-summary memory should be disabled, got %q", cycle.MemoryID) + } + updated, ok := s.GetGoal(goal.ID) + if !ok { + t.Fatal("goal missing") + } + if updated.ResearchEvidence < 1 || updated.Progress <= 0 { + t.Fatalf("research progress not updated: %#v", updated) + } + if stagingCalls < 1 || updated.LastStagingDraftID != "KB-STAGING-GOAL" { + t.Fatalf("staging not published: calls=%d goal=%#v", stagingCalls, updated) + } + if strings.Contains(strings.ToLower(updated.LastError), "learning policy") { + t.Fatalf("legacy learning-policy error survived: %q", updated.LastError) + } +} diff --git a/platform/neuroforge/internal/brain/staging.go b/platform/neuroforge/internal/brain/staging.go index ad26547..50a3ca7 100644 --- a/platform/neuroforge/internal/brain/staging.go +++ b/platform/neuroforge/internal/brain/staging.go @@ -169,7 +169,7 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide if limit <= 0 { limit = 12 } - runs := e.store.ResearchRunsSnapshot(goalID, 20) + runs := e.store.ResearchRunsSnapshot(goalID, 200) ids := map[string]struct{}{} out := make([]draftEvidence, 0, limit) for _, run := range runs { @@ -201,9 +201,60 @@ func (e *Engine) collectGoalDraftEvidence(goalID string, limit int) []draftEvide } } } + // Research-run telemetry is bounded. Supplement it with durable provenance so + // older source-backed evidence remains eligible after the run history window + // rolls over. Newest memories are preferred. + memories := e.store.MemoriesSnapshot() + for i := len(memories) - 1; i >= 0 && len(out) < limit; i-- { + m := memories[i] + if m.Status != core.MemoryActive || m.Provenance.GoalID != goalID || m.Provenance.Source == "goal-cycle" || m.Provenance.SourceID == "" { + continue + } + if _, ok := ids[m.ID]; ok { + continue + } + var src *core.KnowledgeSource + if source, ok := e.store.GetSource(m.Provenance.SourceID); ok { + src = source + } + if src == nil { + continue + } + ids[m.ID] = struct{}{} + out = append(out, draftEvidence{Memory: m, Source: src}) + } return out } +// CheckStagingPublisher verifies both reachability and the configured integration +// credential without creating a draft. Knowledge exposes a dedicated auth-checked +// health endpoint for this purpose. +func (e *Engine) CheckStagingPublisher(ctx context.Context) error { + cfg := e.stagingConfig() + if !cfg.Enabled { + return nil + } + if strings.TrimSpace(cfg.URL) == "" || strings.TrimSpace(cfg.Token) == "" { + return errors.New("staging publisher enabled but URL/token is missing") + } + healthURL := strings.TrimRight(cfg.URL, "/") + "/health" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+cfg.Token) + resp, err := e.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("knowledge staging health HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + return nil +} + func (e *Engine) synthesizeGoalDraft(ctx context.Context, goal *core.Goal, evidence []draftEvidence) (stagingDraftPayload, error) { var b strings.Builder for i, ev := range evidence { diff --git a/platform/neuroforge/internal/brain/v3.go b/platform/neuroforge/internal/brain/v3.go index c865a98..dabd5ac 100644 --- a/platform/neuroforge/internal/brain/v3.go +++ b/platform/neuroforge/internal/brain/v3.go @@ -25,6 +25,11 @@ type AutonomyResult struct { } func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.LearningCycle, error) { + if !e.beginGoalCycle(goalID) { + return core.LearningCycle{}, ErrGoalCycleInProgress + } + defer e.endGoalCycle(goalID) + goal, ok := e.store.GetGoal(goalID) if !ok { return core.LearningCycle{}, errors.New("goal not found") @@ -34,12 +39,66 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning } cfg := e.store.Config() lp := cfg.Brain.LearningPolicy - if !lp.Enabled || !lp.LearnGoalCycles { - return core.LearningCycle{}, errors.New("goal learning is disabled by learning policy") - } + + // Research, measurable progress and the human-review staging bridge are a + // governance path of their own. They must not be blocked by the policy that + // controls whether a semantic goal-summary memory may be learned. researchResult := e.researchGoal(ctx, goal) + e.refreshGoalResearchProgress(goal, goal.LastEvaluation) + e.maybePublishGoalDraft(ctx, goal, researchResult) + + now := time.Now().UTC() + goal.LastCycleAt = now + interval := goal.IntervalMinutes + if interval <= 0 { + interval = cfg.Autonomy.DefaultGoalIntervalMinutes + } + if interval <= 0 { + interval = cfg.Autonomy.IntervalMinutes + } + goal.NextCycleAt = now.Add(time.Duration(maxIntV3(1, interval)) * time.Minute) + goal.ConsecutiveErrors = 0 + goal.LastError = "" + if err := e.store.UpsertGoal(goal); err != nil { + return core.LearningCycle{}, err + } + + researchQueries := []string{} + if strings.TrimSpace(researchResult.Query) != "" { + researchQueries = strings.Split(researchResult.Query, " | ") + } + cycle := core.LearningCycle{ + ID: store.NewID("cycle"), + GoalID: goal.ID, + CostUSD: researchResult.CostUSD, + CreatedAt: now, + ResearchRunID: researchResult.RunID, + ResearchQueries: researchQueries, + SourcesFound: len(researchResult.Results), + SourcesIngested: len(researchResult.Sources), + ResearchErrors: append([]string(nil), researchResult.Errors...), + } + + if !lp.Enabled || !lp.LearnGoalCycles { + cycle.Observation = fmt.Sprintf("Research governance cycle completed: %s", goal.ProgressReason) + cycle.Prediction = deterministicPrediction(goal, nil, goal.LastEvaluation) + cycle.Evaluation = goal.LastEvaluation + cycle.Learning = "Goal-summary memory skipped by learning policy; research, progress and staging were processed independently." + goal.Prediction = cycle.Prediction + goal.NextAction = deterministicNextAction(goal, nil, goal.LastEvaluation) + if err := e.store.UpsertGoal(goal); err != nil { + return core.LearningCycle{}, err + } + if err := e.store.AddLearningCycle(cycle); err != nil { + return core.LearningCycle{}, err + } + _ = e.store.AddKnowledgeEvent(core.KnowledgeEvent{Type: "goal.researched", Summary: "Goal research/progress cycle completed without semantic summary learning", Reason: "goal-summary learning disabled by policy", Actor: "goal-research", Metadata: map[string]string{"goal_id": goal.ID, "research_sources": fmt.Sprint(len(researchResult.Sources)), "research_results": fmt.Sprint(len(researchResult.Results)), "staging_id": goal.LastStagingDraftID}}) + return cycle, nil + } + query := strings.TrimSpace(goal.Title + "\n" + goal.Description + "\nTarget: " + goal.Target) emb, embedCost, err := e.embed(ctx, query) + cycle.CostUSD += embedCost if err != nil { return core.LearningCycle{}, err } @@ -54,7 +113,7 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning observation := summarizeObservation(evidenceHits, warnings) prediction := deterministicPrediction(goal, evidenceHits, evaluation) nextAction := deterministicNextAction(goal, evidenceHits, evaluation) - costUSD := embedCost + researchResult.CostUSD + costUSD := cycle.CostUSD if cfg.Autonomy.UseLLM { prompt := fmt.Sprintf("GOAL: %s\nDESCRIPTION: %s\nTARGET: %s\nPROGRESS: %.3f\nOBSERVATIONS:\n%s", goal.Title, goal.Description, goal.Target, goal.Progress, observation) route := roleRoute(cfg.Routing.Goal, cfg.Autonomy.Provider, cfg.Autonomy.Model) @@ -89,29 +148,16 @@ func (e *Engine) RunGoalCycle(ctx context.Context, goalID string) (core.Learning goal.Prediction = prediction goal.NextAction = nextAction goal.LastEvaluation = evaluation - goal.LastCycleAt = time.Now().UTC() - interval := goal.IntervalMinutes - if interval <= 0 { - interval = cfg.Autonomy.DefaultGoalIntervalMinutes - } - if interval <= 0 { - interval = cfg.Autonomy.IntervalMinutes - } - goal.NextCycleAt = goal.LastCycleAt.Add(time.Duration(maxIntV3(1, interval)) * time.Minute) - goal.ConsecutiveErrors = 0 - goal.LastError = "" goal.MemoryIDs = appendUniqueV3(goal.MemoryIDs, mem.ID) - // Human-review staging is a one-way governance boundary. Publishing failures - // are visible on the goal but never invalidate the durable research/learning cycle. - e.maybePublishGoalDraft(ctx, goal, researchResult) if err := e.store.UpsertGoal(goal); err != nil { return core.LearningCycle{}, err } - researchQueries := []string{} - if strings.TrimSpace(researchResult.Query) != "" { - researchQueries = strings.Split(researchResult.Query, " | ") - } - cycle := core.LearningCycle{ID: store.NewID("cycle"), GoalID: goal.ID, Observation: observation, Prediction: prediction, Evaluation: evaluation, Learning: learning, MemoryID: mem.ID, CostUSD: costUSD, CreatedAt: time.Now().UTC(), ResearchRunID: researchResult.RunID, ResearchQueries: researchQueries, SourcesFound: len(researchResult.Results), SourcesIngested: len(researchResult.Sources), ResearchErrors: append([]string(nil), researchResult.Errors...)} + cycle.Observation = observation + cycle.Prediction = prediction + cycle.Evaluation = evaluation + cycle.Learning = learning + cycle.MemoryID = mem.ID + cycle.CostUSD = costUSD if err := e.store.AddLearningCycle(cycle); err != nil { return core.LearningCycle{}, err } diff --git a/platform/neuroforge/internal/brain/v8_test.go b/platform/neuroforge/internal/brain/v8_test.go index 64fcdf9..eadbf5c 100644 --- a/platform/neuroforge/internal/brain/v8_test.go +++ b/platform/neuroforge/internal/brain/v8_test.go @@ -3,9 +3,11 @@ package brain import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" + "time" "neuroforge/internal/core" ) @@ -112,3 +114,57 @@ func TestGoalResearchPersistsTransparentTrace(t *testing.T) { } } } + +func TestGoalCycleSingleFlightRejectsConcurrentRun(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case started <- struct{}{}: + default: + } + <-release + _ = json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{}}) + })) + defer searx.Close() + + s, e := policyTestEngine(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/embed" { + _ = json.NewEncoder(w).Encode(map[string]any{"embeddings": [][]float32{{1, 0, 0, 0}}}) + return + } + http.NotFound(w, r) + }) + cfg := s.Config() + cfg.Research.Enabled = true + cfg.Research.SearXNG.Enabled = true + cfg.Research.SearXNG.BaseURL = searx.URL + cfg.Research.Goal.Enabled = true + cfg.Research.WebFetch.Enabled = false + cfg.Brain.LearningPolicy.LearnGoalCycles = false + if err := s.UpdateConfig(cfg); err != nil { + t.Fatal(err) + } + goal := core.Goal{Title: "single flight", Description: "concurrency guard", Status: core.GoalActive, Priority: 70, ResearchEnabled: true} + if err := s.UpsertGoal(&goal); err != nil { + t.Fatal(err) + } + + firstDone := make(chan error, 1) + go func() { + _, err := e.RunGoalCycle(context.Background(), goal.ID) + firstDone <- err + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("first goal cycle did not enter research") + } + if _, err := e.RunGoalCycle(context.Background(), goal.ID); !errors.Is(err, ErrGoalCycleInProgress) { + t.Fatalf("second cycle error=%v, want %v", err, ErrGoalCycleInProgress) + } + close(release) + if err := <-firstDone; err != nil { + t.Fatalf("first cycle failed: %v", err) + } +} diff --git a/platform/neuroforge/internal/core/types.go b/platform/neuroforge/internal/core/types.go index ae85e09..ce7d682 100644 --- a/platform/neuroforge/internal/core/types.go +++ b/platform/neuroforge/internal/core/types.go @@ -395,6 +395,8 @@ type Config struct { type Secrets struct { OpenAIAPIKey string `json:"openai_api_key"` AppAPIKey string `json:"app_api_key"` + IntegrationToken string `json:"integration_token,omitempty"` + ControlReadToken string `json:"control_read_token,omitempty"` WorkerToken string `json:"worker_token"` AdminToken string `json:"admin_token"` MetricsToken string `json:"metrics_token,omitempty"` diff --git a/platform/neuroforge/internal/httpapi/httpapi.go b/platform/neuroforge/internal/httpapi/httpapi.go index dce944f..5bfa388 100644 --- a/platform/neuroforge/internal/httpapi/httpapi.go +++ b/platform/neuroforge/internal/httpapi/httpapi.go @@ -6,9 +6,11 @@ import ( "embed" "encoding/json" "errors" + "fmt" "io" "log" "net/http" + "os" "strconv" "strings" "sync/atomic" @@ -25,13 +27,14 @@ import ( var webFS embed.FS type Server struct { - store *store.Store - brain *brain.Engine - router *provider.Router - cost *cost.Manager - mux *http.ServeMux - metrics *metricsRegistry - inflight atomic.Int64 + store *store.Store + brain *brain.Engine + router *provider.Router + cost *cost.Manager + mux *http.ServeMux + metrics *metricsRegistry + inflight atomic.Int64 + readinessOllamaLive bool } func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) *Server { @@ -39,6 +42,8 @@ func New(s *store.Store, b *brain.Engine, r *provider.Router, c *cost.Manager) * x.routes() return x } + +func (s *Server) SetReadinessOllamaLive(enabled bool) { s.readinessOllamaLive = enabled } func (s *Server) Handler() http.Handler { var h http.Handler = s.mux h = s.requestLimits(h) @@ -61,7 +66,7 @@ func (s *Server) routes() { s.mux.Handle("POST /api/v1/search/vector", s.appAuth(http.HandlerFunc(s.searchVector))) s.mux.Handle("POST /api/v1/memory/import", s.appAuth(http.HandlerFunc(s.importMemory))) s.mux.Handle("POST /api/v1/feedback", s.appAuth(http.HandlerFunc(s.feedback))) - s.mux.Handle("GET /api/v1/stats", s.appAuth(http.HandlerFunc(s.stats))) + s.mux.Handle("GET /api/v1/stats", s.controlReadAuth(http.HandlerFunc(s.stats))) s.mux.Handle("GET /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsList))) s.mux.Handle("POST /api/v1/goals", s.appAuth(http.HandlerFunc(s.goalsCreate))) s.mux.Handle("GET /api/v1/goals/{id}", s.appAuth(http.HandlerFunc(s.goalsGet))) @@ -79,14 +84,14 @@ func (s *Server) routes() { s.mux.Handle("GET /api/v1/sources", s.appAuth(http.HandlerFunc(s.sourcesList))) s.mux.Handle("GET /api/v1/sources/{id}", s.appAuth(http.HandlerFunc(s.sourceGet))) s.mux.Handle("POST /api/v1/research", s.appAuth(http.HandlerFunc(s.researchSearch))) - s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.appAuth(http.HandlerFunc(s.integrationKnowledgeUpsert))) - s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.appAuth(http.HandlerFunc(s.integrationKnowledgeDelete))) - s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.appAuth(http.HandlerFunc(s.integrationKnowledgeSearch))) - s.mux.Handle("POST /api/v1/integrations/events", s.appAuth(http.HandlerFunc(s.integrationEvent))) - s.mux.Handle("POST /api/v1/integrations/outcomes", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcome))) - s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.appAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch))) - s.mux.Handle("GET /api/v1/integrations/graph/research", s.appAuth(http.HandlerFunc(s.integrationResearchGraph))) - s.mux.Handle("GET /api/v1/integrations/graph/brain", s.appAuth(http.HandlerFunc(s.integrationBrainGraph))) + s.mux.Handle("POST /api/v1/integrations/knowledge/upsert", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeUpsert))) + s.mux.Handle("DELETE /api/v1/integrations/knowledge/{namespace}/{document_id}", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeDelete))) + s.mux.Handle("POST /api/v1/integrations/knowledge/search", s.integrationAuth(http.HandlerFunc(s.integrationKnowledgeSearch))) + s.mux.Handle("POST /api/v1/integrations/events", s.integrationAuth(http.HandlerFunc(s.integrationEvent))) + s.mux.Handle("POST /api/v1/integrations/outcomes", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcome))) + s.mux.Handle("POST /api/v1/integrations/outcomes/search", s.integrationAuth(http.HandlerFunc(s.integrationValidatedOutcomeSearch))) + s.mux.Handle("GET /api/v1/integrations/graph/research", s.controlReadAuth(http.HandlerFunc(s.integrationResearchGraph))) + s.mux.Handle("GET /api/v1/integrations/graph/brain", s.controlReadAuth(http.HandlerFunc(s.integrationBrainGraph))) s.mux.Handle("POST /internal/v1/cluster/request-vote", s.clusterAuth(http.HandlerFunc(s.clusterRequestVote))) s.mux.Handle("POST /internal/v1/cluster/heartbeat", s.clusterAuth(http.HandlerFunc(s.clusterHeartbeat))) @@ -232,6 +237,33 @@ func (s *Server) appAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } +func (s *Server) integrationAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sec := s.store.Secrets() + adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken) + integrationOK := secureEqual(bearer(r), sec.IntegrationToken) + if !adminOK && !integrationOK { + s.err(w, http.StatusUnauthorized, errors.New("invalid integration token or admin token")) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) controlReadAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sec := s.store.Secrets() + adminOK := secureEqual(r.Header.Get("X-Admin-Token"), sec.AdminToken) + controlOK := secureEqual(bearer(r), sec.ControlReadToken) + appOK := secureEqual(bearer(r), sec.AppAPIKey) + if !adminOK && !controlOK && !appOK { + s.err(w, http.StatusUnauthorized, errors.New("invalid control/app read token or admin token")) + return + } + next.ServeHTTP(w, r) + }) +} + func (s *Server) workerAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !secureEqual(bearer(r), s.store.Secrets().WorkerToken) { @@ -571,7 +603,7 @@ func (s *Server) adminPutModelRouting(w http.ResponseWriter, r *http.Request) { func (s *Server) adminSecretsStatus(w http.ResponseWriter, r *http.Request) { sec := s.store.Secrets() - s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""}) + s.json(w, 200, map[string]any{"openai_configured": sec.OpenAIAPIKey != "", "app_key_configured": sec.AppAPIKey != "", "integration_token_configured": sec.IntegrationToken != "", "control_read_token_configured": sec.ControlReadToken != "", "worker_token_configured": sec.WorkerToken != "", "metrics_token_configured": sec.MetricsToken != "", "shard_tokens": len(sec.ShardAPIToken), "cluster_token_configured": sec.ClusterToken != ""}) } func maskedSecret(v string) string { if v == "" { @@ -586,35 +618,61 @@ func (s *Server) adminGetSecrets(w http.ResponseWriter, r *http.Request) { sec := s.store.Secrets() reveal := r.URL.Query().Get("reveal") == "1" && s.store.Config().Security.AllowSecretReveal if reveal { - s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken}) + s.json(w, 200, map[string]any{"revealed": true, "app_api_key": sec.AppAPIKey, "integration_token": sec.IntegrationToken, "control_read_token": sec.ControlReadToken, "worker_token": sec.WorkerToken, "metrics_token": sec.MetricsToken, "shard_api_tokens": sec.ShardAPIToken, "cluster_token": sec.ClusterToken}) return } maskedShards := map[string]string{} for k, v := range sec.ShardAPIToken { maskedShards[k] = maskedSecret(v) } - s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)}) + s.json(w, 200, map[string]any{"revealed": false, "reveal_allowed": s.store.Config().Security.AllowSecretReveal, "app_api_key": maskedSecret(sec.AppAPIKey), "integration_token": maskedSecret(sec.IntegrationToken), "control_read_token": maskedSecret(sec.ControlReadToken), "worker_token": maskedSecret(sec.WorkerToken), "metrics_token": maskedSecret(sec.MetricsToken), "shard_api_tokens": maskedShards, "cluster_token": maskedSecret(sec.ClusterToken)}) } func (s *Server) adminPutSecrets(w http.ResponseWriter, r *http.Request) { var q struct { - OpenAIAPIKey string `json:"openai_api_key,omitempty"` - AppAPIKey string `json:"app_api_key,omitempty"` - WorkerToken string `json:"worker_token,omitempty"` - MetricsToken string `json:"metrics_token,omitempty"` - ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"` - ClusterToken string `json:"cluster_token,omitempty"` + OpenAIAPIKey string `json:"openai_api_key,omitempty"` + AppAPIKey string `json:"app_api_key,omitempty"` + IntegrationToken string `json:"integration_token,omitempty"` + ControlReadToken string `json:"control_read_token,omitempty"` + WorkerToken string `json:"worker_token,omitempty"` + MetricsToken string `json:"metrics_token,omitempty"` + ShardAPIToken map[string]string `json:"shard_api_tokens,omitempty"` + ClusterToken string `json:"cluster_token,omitempty"` } if err := decode(r, &q); err != nil { s.err(w, 400, err) return } sec := s.store.Secrets() + envLocked := func(name string) bool { + _, ok := os.LookupEnv(name) + return ok && strings.TrimSpace(os.Getenv(name)) != "" + } + for name, value := range map[string]string{ + "OPENAI_API_KEY": q.OpenAIAPIKey, + "NEUROFORGE_APP_API_KEY": q.AppAPIKey, + "NEUROFORGE_INTEGRATION_TOKEN": q.IntegrationToken, + "NEUROFORGE_CONTROL_READ_TOKEN": q.ControlReadToken, + "NEUROFORGE_WORKER_TOKEN": q.WorkerToken, + "NEUROFORGE_METRICS_TOKEN": q.MetricsToken, + "NEUROFORGE_CLUSTER_TOKEN": q.ClusterToken, + } { + if value != "" && envLocked(name) { + s.err(w, http.StatusConflict, fmt.Errorf("%s is environment-managed and cannot be changed through the admin API", name)) + return + } + } if q.OpenAIAPIKey != "" { sec.OpenAIAPIKey = q.OpenAIAPIKey } if q.AppAPIKey != "" { sec.AppAPIKey = q.AppAPIKey } + if q.IntegrationToken != "" { + sec.IntegrationToken = q.IntegrationToken + } + if q.ControlReadToken != "" { + sec.ControlReadToken = q.ControlReadToken + } if q.WorkerToken != "" { sec.WorkerToken = q.WorkerToken } @@ -737,6 +795,67 @@ func (s *Server) livez(w http.ResponseWriter, r *http.Request) { s.json(w, http.StatusOK, map[string]any{"ok": true, "status": "alive", "time": time.Now().UTC(), "version": "0.8.2"}) } +func configuredModelAvailable(models map[string]bool, configured string) bool { + configured = strings.TrimSpace(configured) + if configured == "" { + return false + } + if models[configured] { + return true + } + if !strings.Contains(configured, ":") && models[configured+":latest"] { + return true + } + return false +} + +func checkConfiguredOllamaModels(ctx context.Context, cfg core.Config) (bool, any) { + type tagsResponse struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + details := map[string]any{} + anyEnabled := false + for _, node := range cfg.Ollama { + if !node.Enabled || strings.TrimSpace(node.BaseURL) == "" { + continue + } + anyEnabled = true + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(node.BaseURL, "/")+"/api/tags", nil) + if err != nil { + details[node.ID] = err.Error() + continue + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + details[node.ID] = err.Error() + continue + } + var tags tagsResponse + decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tags) + resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 || decodeErr != nil { + details[node.ID] = fmt.Sprintf("HTTP %d / invalid tags response", resp.StatusCode) + continue + } + models := map[string]bool{} + for _, model := range tags.Models { + models[strings.TrimSpace(model.Name)] = true + } + chatOK := configuredModelAvailable(models, node.ChatModel) + embedOK := configuredModelAvailable(models, node.EmbeddingModel) + details[node.ID] = map[string]any{"reachable": true, "chat_model": node.ChatModel, "chat_present": chatOK, "embedding_model": node.EmbeddingModel, "embedding_present": embedOK} + if chatOK && embedOK { + return true, details + } + } + if !anyEnabled { + return false, map[string]any{"error": "no enabled Ollama node configured"} + } + return false, details +} + func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { cfg := s.store.Config() sec := s.store.Secrets() @@ -764,7 +883,26 @@ func (s *Server) readyz(w http.ResponseWriter, r *http.Request) { components["embedding_route_configured"] = embedReady clusterReady := !cfg.Cluster.Enabled || obs.ClusterLeaderID != "" components["cluster"] = clusterReady - ready := configOK && chatReady && embedReady && clusterReady && (!cfg.API.RequireKey || sec.AppAPIKey != "") + ollamaLiveReady := true + if s.readinessOllamaLive { + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) + var detail any + ollamaLiveReady, detail = checkConfiguredOllamaModels(ctx, cfg) + cancel() + components["ollama_live_models"] = detail + } + stagingReady := true + if s.brain != nil { + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) + if err := s.brain.CheckStagingPublisher(ctx); err != nil { + stagingReady = false + components["kb_staging"] = err.Error() + } else { + components["kb_staging"] = true + } + cancel() + } + ready := configOK && chatReady && embedReady && clusterReady && ollamaLiveReady && stagingReady && (!cfg.API.RequireKey || sec.AppAPIKey != "") status := http.StatusOK if !ready { status = http.StatusServiceUnavailable diff --git a/platform/neuroforge/internal/httpapi/integration_api_test.go b/platform/neuroforge/internal/httpapi/integration_api_test.go index 56b4dc8..c520079 100644 --- a/platform/neuroforge/internal/httpapi/integration_api_test.go +++ b/platform/neuroforge/internal/httpapi/integration_api_test.go @@ -24,7 +24,7 @@ func integrationRequest(t *testing.T, s *Server, method, path, token, body strin func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) { s, _ := newMetricsTestServer(t) - key := s.store.Secrets().AppAPIKey + key := s.store.Secrets().IntegrationToken unauth := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/knowledge/upsert", "", `{"namespace":"agent","document_id":"KB-1","chunks":[{"index":0,"text":"vpn","vector":[1,0],"content_hash":"a"}]}`) if unauth.Code != http.StatusUnauthorized { @@ -107,3 +107,27 @@ func TestIntegrationKnowledgeLifecycleAndNamespaceIsolation(t *testing.T) { t.Fatalf("deleted document still searchable: %s", search.Body.String()) } } + +func TestScopedTokensCannotCrossTrustBoundaries(t *testing.T) { + s, _ := newMetricsTestServer(t) + sec := s.store.Secrets() + + // The general App key is deliberately insufficient for integration writes. + if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.AppAPIKey, `{"type":"test","source":"agent","message":"x"}`); rr.Code != http.StatusUnauthorized { + t.Fatalf("app key wrote integration event: status=%d body=%s", rr.Code, rr.Body.String()) + } + // Control is read-only and cannot write integration events or generic learn. + if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/integrations/events", sec.ControlReadToken, `{"type":"test","source":"control","message":"x"}`); rr.Code != http.StatusUnauthorized { + t.Fatalf("control token wrote integration event: status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.ControlReadToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized { + t.Fatalf("control token reached /learn: status=%d body=%s", rr.Code, rr.Body.String()) + } + // Integration credentials are not generic app credentials either. + if rr := integrationRequest(t, s, http.MethodPost, "/api/v1/learn", sec.IntegrationToken, `{"text":"must not learn"}`); rr.Code != http.StatusUnauthorized { + t.Fatalf("integration token reached /learn: status=%d body=%s", rr.Code, rr.Body.String()) + } + if rr := integrationRequest(t, s, http.MethodGet, "/api/v1/stats", sec.ControlReadToken, ""); rr.Code != http.StatusOK { + t.Fatalf("control token cannot read stats: status=%d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/platform/neuroforge/internal/httpapi/integration_graph_test.go b/platform/neuroforge/internal/httpapi/integration_graph_test.go index bdc97c6..a5c8119 100644 --- a/platform/neuroforge/internal/httpapi/integration_graph_test.go +++ b/platform/neuroforge/internal/httpapi/integration_graph_test.go @@ -11,16 +11,16 @@ import ( "neuroforge/internal/core" ) -func appGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { +func controlGraphRequest(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) - req.Header.Set("Authorization", "Bearer "+s.store.Secrets().AppAPIKey) + req.Header.Set("Authorization", "Bearer "+s.store.Secrets().ControlReadToken) rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, req) return rr } -func TestIntegrationGraphEndpointsRequireAppKey(t *testing.T) { +func TestIntegrationGraphEndpointsRequireControlReadAuth(t *testing.T) { s, _ := newMetricsTestServer(t) for _, path := range []string{"/api/v1/integrations/graph/brain", "/api/v1/integrations/graph/research"} { req := httptest.NewRequest(http.MethodGet, path, nil) @@ -38,7 +38,7 @@ func TestIntegrationBrainGraphIsBoundedAndRedacted(t *testing.T) { if err := s.store.AddMemory(m); err != nil { t.Fatal(err) } - rr := appGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50") + rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/brain?max_nodes=50") if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } @@ -72,7 +72,7 @@ func TestIntegrationResearchGraphShowsProvenanceChain(t *testing.T) { if _, err := s.store.FinishResearchRun(run.ID, "completed", ""); err != nil { t.Fatal(err) } - rr := appGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50") + rr := controlGraphRequest(t, s, "/api/v1/integrations/graph/research?runs=2&max_events=50") if rr.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } diff --git a/platform/neuroforge/internal/httpapi/knowledge_policy_test.go b/platform/neuroforge/internal/httpapi/knowledge_policy_test.go index d58cb72..df50b03 100644 --- a/platform/neuroforge/internal/httpapi/knowledge_policy_test.go +++ b/platform/neuroforge/internal/httpapi/knowledge_policy_test.go @@ -68,3 +68,60 @@ func TestReadinessEndpoint(t *testing.T) { t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) } } + +func TestReadinessLiveOllamaRequiresChatAndEmbeddingModels(t *testing.T) { + models := []string{"gemma3:latest"} + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/tags" { + http.NotFound(w, r) + return + } + items := make([]map[string]string, 0, len(models)) + for _, name := range models { + items = append(items, map[string]string{"name": name}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"models": items}) + })) + defer ollama.Close() + + s, _ := newMetricsTestServer(t) + cfg := s.store.Config() + cfg.Ollama[0].BaseURL = ollama.URL + cfg.Ollama[0].ChatModel = "gemma3" + cfg.Ollama[0].EmbeddingModel = "embeddinggemma" + if err := s.store.UpdateConfig(cfg); err != nil { + t.Fatal(err) + } + s.SetReadinessOllamaLive(true) + + req := httptest.NewRequest(http.MethodGet, "/readyz", nil) + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("missing embedding model: status=%d body=%s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"embedding_present":false`) { + t.Fatalf("readiness does not expose missing embedding model: %s", rr.Body.String()) + } + + models = append(models, "embeddinggemma:latest") + rr = httptest.NewRecorder() + s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("both models present: status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestEnvironmentManagedSecretCannotBeRotatedThroughAdminAPI(t *testing.T) { + s, _ := newMetricsTestServer(t) + t.Setenv("NEUROFORGE_APP_API_KEY", "environment-owned-app-key-1234567890") + admin := s.store.Secrets().AdminToken + req := httptest.NewRequest(http.MethodPut, "/admin/api/secrets", strings.NewReader(`{"app_api_key":"different-runtime-value-1234567890"}`)) + req.Header.Set("X-Admin-Token", admin) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + s.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/platform/neuroforge/internal/httpapi/outcomes_test.go b/platform/neuroforge/internal/httpapi/outcomes_test.go index 9f41efc..f25c147 100644 --- a/platform/neuroforge/internal/httpapi/outcomes_test.go +++ b/platform/neuroforge/internal/httpapi/outcomes_test.go @@ -33,7 +33,7 @@ func TestValidatedOutcomeLearnsTrustedProvenance(t *testing.T) { body := `{"outcome_id":"out-1","run_id":"run-1","ticket_id":42,"decision":"accepted","ticket_input":"VPN verbindet nicht","proposed_reply":"VPN Client neu starten","confirmed_reply":"VPN Client neu starten","category_id":5,"category_name":"VPN","knowledge_id":"kb-vpn","actor":"tech-a"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) + req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, req) @@ -62,7 +62,7 @@ func TestValidatedOutcomeRejectsUnconfirmedDecision(t *testing.T) { s, _ := newMetricsTestServer(t) sec := s.store.Secrets() req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(`{"outcome_id":"o","run_id":"r","ticket_id":1,"decision":"rejected","ticket_input":"x","confirmed_reply":"y","actor":"tech"}`)) - req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) + req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, req) @@ -101,7 +101,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev sec := s.store.Secrets() post := func(body string) map[string]any { req := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) + req.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, req) @@ -139,7 +139,7 @@ func TestValidatedOutcomeCorrectionSupersedesPriorMemoryAndSearchesOnlyActiveRev } search := httptest.NewRequest(http.MethodPost, "/api/v1/integrations/outcomes/search", strings.NewReader(`{"text":"Drucker korrigierte Loesung","k":10,"min_similarity":0}`)) - search.Header.Set("Authorization", "Bearer "+sec.AppAPIKey) + search.Header.Set("Authorization", "Bearer "+sec.IntegrationToken) search.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() s.Handler().ServeHTTP(rr, search) diff --git a/platform/neuroforge/internal/httpapi/v3.go b/platform/neuroforge/internal/httpapi/v3.go index a05673c..8c2b8b4 100644 --- a/platform/neuroforge/internal/httpapi/v3.go +++ b/platform/neuroforge/internal/httpapi/v3.go @@ -7,6 +7,7 @@ import ( "strconv" "time" + "neuroforge/internal/brain" "neuroforge/internal/core" ) @@ -91,7 +92,11 @@ func (s *Server) goalResume(w http.ResponseWriter, r *http.Request) { func (s *Server) goalCycle(w http.ResponseWriter, r *http.Request) { cycle, err := s.brain.RunGoalCycle(r.Context(), r.PathValue("id")) if err != nil { - s.err(w, 400, err) + if errors.Is(err, brain.ErrGoalCycleInProgress) { + s.err(w, http.StatusConflict, err) + return + } + s.err(w, http.StatusBadRequest, err) return } s.json(w, 200, cycle) diff --git a/platform/neuroforge/internal/store/store.go b/platform/neuroforge/internal/store/store.go index ad9d791..4cf7567 100644 --- a/platform/neuroforge/internal/store/store.go +++ b/platform/neuroforge/internal/store/store.go @@ -177,6 +177,12 @@ func New(dir string) (*Store, error) { if s.secrets.AppAPIKey == "" { s.secrets.AppAPIKey = randomID(24) } + if s.secrets.IntegrationToken == "" { + s.secrets.IntegrationToken = randomID(24) + } + if s.secrets.ControlReadToken == "" { + s.secrets.ControlReadToken = randomID(24) + } if s.secrets.WorkerToken == "" { s.secrets.WorkerToken = randomID(24) } diff --git a/scripts/backup-data.sh b/scripts/backup-data.sh new file mode 100644 index 0000000..8775933 --- /dev/null +++ b/scripts/backup-data.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu +ENV_FILE=${ENV_FILE:-.env} +DEST_ROOT=${1:-./release-backups} +[ -f "$ENV_FILE" ] || { echo "backup: missing $ENV_FILE" >&2; exit 1; } +getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r'; } +tag=$(getv IMAGE_TAG) +stamp=$(date -u +%Y%m%dT%H%M%SZ) +dest="$DEST_ROOT/$stamp" +mkdir -p "$dest" + +./scripts/preflight.sh +restart() { docker compose --env-file "$ENV_FILE" up -d knowledge neuroforge neuroforge-worker agent >/dev/null 2>&1 || true; } +trap restart EXIT INT TERM + +echo "backup: entering short maintenance stop" +docker compose --env-file "$ENV_FILE" stop neuroforge-worker agent neuroforge knowledge +docker compose --env-file "$ENV_FILE" create neuroforge agent >/dev/null +mkdir -p "$dest/neuroforge-data" "$dest/agent-data" "$dest/knowledge" "$dest/staging" "$dest/knowledge-backups" +docker compose --env-file "$ENV_FILE" cp neuroforge:/app/data/. "$dest/neuroforge-data/" +docker compose --env-file "$ENV_FILE" cp agent:/app/data/. "$dest/agent-data/" + +copy_host() { + src=$1; out=$2 + [ -d "$src" ] || return 0 + cp -a "$src"/. "$out"/ +} +copy_host "$(getv KB_DATA_PATH)" "$dest/knowledge" +copy_host "$(getv KB_STAGING_PATH)" "$dest/staging" +copy_host "$(getv KB_BACKUP_PATH)" "$dest/knowledge-backups" +printf 'IMAGE_TAG=%s\nCREATED_AT=%s\n' "$tag" "$stamp" > "$dest/MANIFEST" +( cd "$dest" && find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS ) +echo "backup: verified snapshot written to $dest" diff --git a/scripts/check-compose-env.py b/scripts/check-compose-env.py new file mode 100644 index 0000000..5c9cd44 --- /dev/null +++ b/scripts/check-compose-env.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Fail if production Compose broadens Agent secrets or misses runtime env knobs.""" +from __future__ import annotations +import re +import sys +from pathlib import Path + +try: + import yaml +except Exception as exc: # PyYAML is intentionally only a release-dev dependency. + print(f"compose-env-check: PyYAML unavailable: {exc}", file=sys.stderr) + raise SystemExit(2) + +root = Path(__file__).resolve().parents[1] +compose = yaml.safe_load((root / "docker-compose.yml").read_text()) +agent = compose["services"]["agent"] +if "env_file" in agent: + raise SystemExit("compose-env-check: production Agent must not use env_file") +actual = set((agent.get("environment") or {}).keys()) + +# Canonical host-config variables are those documented in .env.example and +# literally referenced by non-test Agent runtime source. Internal service values +# are added separately below. +documented: set[str] = set() +for line in (root / ".env.example").read_text().splitlines(): + match = re.match(r"([A-Z][A-Z0-9_]+)=", line) + if match: + documented.add(match.group(1)) +source = "\n".join( + p.read_text(errors="ignore") + for p in (root / "services/agent").rglob("*.go") + if not p.name.endswith("_test.go") +) +expected = {name for name in documented if re.search(rf'"{re.escape(name)}"', source)} +expected.update({"NEUROFORGE_URL", "NEUROFORGE_API_KEY", "BRAIN_ACTIVITY_URL", "BRAIN_ACTIVITY_API_KEY"}) +missing = sorted(expected - actual) +if missing: + print("compose-env-check: Agent runtime variables missing from explicit environment:", file=sys.stderr) + print("\n".join(f" {name}" for name in missing), file=sys.stderr) + raise SystemExit(1) + +forbidden = { + "NEUROFORGE_ADMIN_TOKEN", + "NEUROFORGE_WORKER_TOKEN", + "NEUROFORGE_METRICS_TOKEN", + "NEUROFORGE_CLUSTER_TOKEN", + "NEUROFORGE_CONTROL_READ_TOKEN", + "KB_INTEGRATION_TOKEN", + "BASIC_AUTH_PASSWORD", + "CONTROL_BASIC_AUTH_PASSWORD", +} +leaked = sorted(forbidden & actual) +if leaked: + print("compose-env-check: unrelated privileged secrets exposed to Agent: " + ", ".join(leaked), file=sys.stderr) + raise SystemExit(1) + +knowledge = compose["services"]["knowledge"] +if "env_file" in knowledge: + raise SystemExit("compose-env-check: production Knowledge must not use env_file") +print(f"compose-env-check: passed ({len(actual)} explicit Agent environment entries)") diff --git a/scripts/generate-secrets.sh b/scripts/generate-secrets.sh index 0d36db0..1990de9 100644 --- a/scripts/generate-secrets.sh +++ b/scripts/generate-secrets.sh @@ -7,6 +7,8 @@ cat <&2; exit 1; } +info() { echo "preflight: $*"; } + +[ -f docker-compose.yml ] || fail "run from the repository root" + +# Production compose must never silently fall back to source builds or broad .env injection. +if grep -Eq '^[[:space:]]+build:' docker-compose.yml; then fail "production docker-compose.yml contains build:"; fi +if grep -Eq '^[[:space:]]+env_file:' docker-compose.yml; then fail "production docker-compose.yml contains env_file:"; fi +for image in neuroforge neuroforge-worker agent agent-data-init knowledge control; do + grep -Fq "git.send.nrw/sendnrw/glpi-neuroforge-mega-${image}:\${IMAGE_TAG:" docker-compose.yml || fail "registry image mapping missing for ${image}" +done + +if [ "$STATIC_ONLY" = true ]; then + if grep -Eq '^IMAGE_TAG[[:space:]]*=[[:space:]]*latest([[:space:]]|$)' .env.example; then fail ".env.example sets IMAGE_TAG=latest"; fi + info "static compose/source checks passed" + exit 0 +fi + +[ -f "$ENV_FILE" ] || fail "$ENV_FILE not found (copy .env.example and replace every placeholder)" +getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } +check_not_placeholder() { + name=$1; value=$(getv "$name") + [ -n "$value" ] || fail "$name is empty" + upper=$(printf '%s' "$value" | tr '[:lower:]' '[:upper:]') + case "$upper" in *CHANGE_ME*|*CHANGEME*|*PLACEHOLDER*) fail "$name still contains a placeholder";; esac +} +check_secret() { + name=$1; min=$2 + check_not_placeholder "$name" + value=$(getv "$name") + [ "${#value}" -ge "$min" ] || fail "$name must contain at least $min characters" +} + +tag=$(getv IMAGE_TAG) +[ -n "$tag" ] || fail "IMAGE_TAG is empty" +[ "$tag" != latest ] || fail "IMAGE_TAG=latest is forbidden for production" +case "$tag" in *[!A-Za-z0-9._-]*) fail "IMAGE_TAG contains invalid characters";; esac + +for spec in \ + NEUROFORGE_ADMIN_TOKEN:24 \ + NEUROFORGE_APP_API_KEY:24 \ + NEUROFORGE_INTEGRATION_TOKEN:24 \ + NEUROFORGE_CONTROL_READ_TOKEN:24 \ + NEUROFORGE_WORKER_TOKEN:24 \ + NEUROFORGE_METRICS_TOKEN:24 \ + KB_INTEGRATION_TOKEN:24 \ + CONTROL_READ_TOKEN:24 \ + BASIC_AUTH_PASSWORD:12 \ + CONTROL_BASIC_AUTH_PASSWORD:12 \ + WEB_PASSWORD:12; do + check_secret "${spec%%:*}" "${spec##*:}" +done + +for name in GLPI_URL GLPI_CLIENT_ID GLPI_CLIENT_SECRET GLPI_USERNAME GLPI_PASSWORD; do + check_not_placeholder "$name" +done +web_anon=$(printf '%s' "$(getv WEB_ALLOW_ANONYMOUS)" | tr '[:upper:]' '[:lower:]') +if [ "$web_anon" != "true" ]; then + check_not_placeholder WEB_USERNAME +fi +research=$(printf '%s' "$(getv NEUROFORGE_SEARXNG_ENABLED)" | tr '[:upper:]' '[:lower:]') +if [ "$research" = "true" ]; then + check_secret SEARXNG_SECRET 24 +fi + +# Trust-boundary tokens must not be reused across roles. +seen='' +for name in NEUROFORGE_ADMIN_TOKEN NEUROFORGE_APP_API_KEY NEUROFORGE_INTEGRATION_TOKEN NEUROFORGE_CONTROL_READ_TOKEN NEUROFORGE_WORKER_TOKEN NEUROFORGE_METRICS_TOKEN KB_INTEGRATION_TOKEN CONTROL_READ_TOKEN; do + value=$(getv "$name") + case "|$seen|" in *"|$value|"*) fail "$name reuses another service token";; esac + seen=${seen:+$seen|}$value +done + +command -v docker >/dev/null 2>&1 || fail "docker is not installed" +docker compose version >/dev/null 2>&1 || fail "Docker Compose v2 is unavailable" +docker compose --env-file "$ENV_FILE" config -q || fail "docker compose config validation failed" +images=$(docker compose --env-file "$ENV_FILE" config --images) +for image in neuroforge neuroforge-worker agent agent-data-init knowledge control; do + expected="git.send.nrw/sendnrw/glpi-neuroforge-mega-${image}:${tag}" + printf '%s\n' "$images" | grep -Fxq "$expected" || fail "resolved image missing: $expected" +done +printf '%s\n' "$images" | grep -E 'git\.send\.nrw/sendnrw/glpi-neuroforge-mega-.*:latest$' >/dev/null && fail "a project image resolved to latest" +info "production preflight passed for IMAGE_TAG=$tag" diff --git a/scripts/release-gate.sh b/scripts/release-gate.sh new file mode 100644 index 0000000..e2c2cf9 --- /dev/null +++ b/scripts/release-gate.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -eu +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +cd "$ROOT" +./scripts/preflight.sh --static +./scripts/check-compose-env.py +./scripts/secret-scan.sh +for module in platform/neuroforge services/agent services/knowledge services/control; do + echo "release-gate: $module: test" + (cd "$module" && go test ./...) + echo "release-gate: $module: vet" + (cd "$module" && go vet ./...) + echo "release-gate: $module: build" + (cd "$module" && go build ./...) +done + +echo "release-gate: targeted race checks" +(cd platform/neuroforge && go test -race ./internal/store ./internal/brain ./internal/httpapi) +(cd services/agent && go test -race ./internal/state ./internal/knowledge ./internal/learning ./internal/agent) +(cd services/knowledge && go test -race ./internal/store ./internal/staging ./cmd/server) +(cd services/control && go test -race .) + +echo "release-gate: passed local source gates" diff --git a/scripts/research-up.sh b/scripts/research-up.sh index da3cf46..0403c8a 100644 --- a/scripts/research-up.sh +++ b/scripts/research-up.sh @@ -15,5 +15,5 @@ export SEARXNG_SECRET=$secret cd "$ROOT" NEUROFORGE_RESEARCH_ENABLED=true \ NEUROFORGE_SEARXNG_ENABLED=true \ -docker compose --profile research up -d searxng neuroforge neuroforge-worker +docker compose --profile research up -d ollama knowledge searxng neuroforge neuroforge-worker printf '%s\n' 'SearXNG + NeuroForge research are running. Autonomy remains controlled by NEUROFORGE_AUTONOMY_ENABLED.' diff --git a/scripts/restore-data.sh b/scripts/restore-data.sh new file mode 100644 index 0000000..8038c6e --- /dev/null +++ b/scripts/restore-data.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu +ENV_FILE=${ENV_FILE:-.env} +SNAPSHOT=${1:-} +HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-busybox:1.36} +[ -n "$SNAPSHOT" ] && [ -d "$SNAPSHOT" ] || { echo "usage: $0 SNAPSHOT_DIR" >&2; exit 2; } +[ -f "$ENV_FILE" ] || { echo "restore: missing $ENV_FILE" >&2; exit 1; } +( cd "$SNAPSHOT" && sha256sum -c SHA256SUMS ) +./scripts/preflight.sh +getv() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r'; } + +restart() { docker compose --env-file "$ENV_FILE" up -d knowledge neuroforge neuroforge-worker agent >/dev/null 2>&1 || true; } +trap restart EXIT INT TERM + +docker compose --env-file "$ENV_FILE" stop neuroforge-worker agent neuroforge knowledge +docker compose --env-file "$ENV_FILE" create neuroforge agent >/dev/null +volume_for() { + service=$1; destination=$2 + cid=$(docker compose --env-file "$ENV_FILE" ps -aq "$service") + [ -n "$cid" ] || return 1 + docker inspect -f "{{range .Mounts}}{{if eq .Destination \"$destination\"}}{{.Name}}{{end}}{{end}}" "$cid" +} +restore_volume() { + service=$1; destination=$2; source=$3 + volume=$(volume_for "$service" "$destination") + [ -n "$volume" ] || { echo "restore: volume for $service:$destination not found" >&2; exit 1; } + src=$(CDPATH= cd -- "$source" && pwd) + docker run --rm -v "$volume:/target" -v "$src:/source:ro" "$HELPER_IMAGE" sh -eu -c 'rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cp -a /source/. /target/' +} +restore_volume neuroforge /app/data "$SNAPSHOT/neuroforge-data" +restore_volume agent /app/data "$SNAPSHOT/agent-data" +restore_host() { + src=$1; dst=$2 + [ -d "$src" ] || return 0 + mkdir -p "$dst" + find "$dst" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + + cp -a "$src"/. "$dst"/ +} +restore_host "$SNAPSHOT/knowledge" "$(getv KB_DATA_PATH)" +restore_host "$SNAPSHOT/staging" "$(getv KB_STAGING_PATH)" +restore_host "$SNAPSHOT/knowledge-backups" "$(getv KB_BACKUP_PATH)" +echo "restore: snapshot restored; services will be restarted" diff --git a/scripts/secret-scan.sh b/scripts/secret-scan.sh new file mode 100644 index 0000000..f16be47 --- /dev/null +++ b/scripts/secret-scan.sh @@ -0,0 +1,60 @@ +#!/bin/sh +set -eu +fail=0 +bad() { echo "secret-scan: ERROR: $*" >&2; fail=1; } + +ROOT=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +cd "$ROOT" +TMP_BASE=${TMPDIR:-/tmp}/neuroforge-secret-scan.$$ +FILES="$TMP_BASE.files" +KEYS="$TMP_BASE.keys" +TOKENS="$TMP_BASE.tokens" +trap 'rm -f "$FILES" "$KEYS" "$TOKENS"' EXIT HUP INT TERM + +# Release archives intentionally do not contain .git. Use Git's tracked-file view +# when available, otherwise scan every regular file in the extracted release. +if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git ls-files > "$FILES" + HAVE_GIT=true +else + find . -type f ! -path './.git/*' -print | sed 's#^\./##' | sort > "$FILES" + HAVE_GIT=false +fi + +# Private keys and common live-token shapes must not be committed. Placeholders in +# templates/docs are intentionally allowed. +if grep -E '(^|/)\.env$|\.pem$|\.p12$|\.pfx$|(^|/)id_rsa$|(^|/)id_ed25519$' "$FILES" >/dev/null; then + bad "private environment/key material found" +fi + +if [ "$HAVE_GIT" = true ]; then + if git grep -nE -- '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' -- ':!*.example' ':!*.md' >"$KEYS" 2>/dev/null; then + cat "$KEYS" >&2; bad "private key material found" + fi + if git grep -nE -- '(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{24,})' -- ':!*.example' >"$TOKENS" 2>/dev/null; then + cat "$TOKENS" >&2; bad "token-like credential found" + fi +else + # Fallback for release ZIPs: recurse through extracted source while preserving + # the same exclusions as the Git-backed scan. + if grep -RInE --exclude='*.example' --exclude='*.md' --exclude-dir='.git' -- \ + '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----' . >"$KEYS" 2>/dev/null; then + cat "$KEYS" >&2; bad "private key material found" + fi + if grep -RInE --exclude='*.example' --exclude-dir='.git' -- \ + '(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|sk-[A-Za-z0-9_-]{24,})' . >"$TOKENS" 2>/dev/null; then + cat "$TOKENS" >&2; bad "token-like credential found" + fi +fi + +# Reject accidental binary blobs outside explicitly expected assets. +while IFS= read -r f; do + [ -f "$f" ] || continue + case "$f" in *.png|*.jpg|*.jpeg|*.gif|*.ico|*.woff|*.woff2|*.pdf|*.zip) continue;; esac + if [ "$(LC_ALL=C grep -Il . "$f" 2>/dev/null || true)" = "" ] && [ -s "$f" ]; then + bad "unexpected binary file: $f" + fi +done < "$FILES" + +[ "$fail" -eq 0 ] || exit 1 +echo "secret-scan: passed" diff --git a/scripts/status.sh b/scripts/status.sh index d5e0318..41a536a 100644 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -1,4 +1,17 @@ #!/bin/sh set -eu -PORT="${CONTROL_HOST_PORT:-8070}" -curl -fsS "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool +ENV_FILE=${ENV_FILE:-.env} +getv() { + [ -f "$ENV_FILE" ] || return 0 + sed -n "s/^$1=//p" "$ENV_FILE" | tail -n1 | tr -d '\r' +} +PORT=${CONTROL_HOST_PORT:-$(getv CONTROL_HOST_PORT)} +PORT=${PORT:-8070} +USER_NAME=${CONTROL_BASIC_AUTH_USER:-$(getv CONTROL_BASIC_AUTH_USER)} +PASSWORD=${CONTROL_BASIC_AUTH_PASSWORD:-$(getv CONTROL_BASIC_AUTH_PASSWORD)} +if [ -n "$USER_NAME" ] || [ -n "$PASSWORD" ]; then + [ -n "$USER_NAME" ] && [ -n "$PASSWORD" ] || { echo "status: incomplete Control Basic Auth" >&2; exit 1; } + curl -fsS --user "$USER_NAME:$PASSWORD" "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool +else + curl -fsS "http://127.0.0.1:${PORT}/api/status" | python3 -m json.tool +fi diff --git a/services/agent/Dockerfile b/services/agent/Dockerfile index 1043322..86aadcb 100644 --- a/services/agent/Dockerfile +++ b/services/agent/Dockerfile @@ -3,7 +3,9 @@ WORKDIR /src COPY go.mod ./ COPY cmd ./cmd COPY internal ./internal -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai-agent ./cmd/agent +RUN go test ./... && \ + go vet ./... && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai-agent ./cmd/agent # One-shot helper used by docker compose to prepare the persistent volume for # the distroless non-root runtime user (UID/GID 65532). @@ -16,5 +18,6 @@ COPY --from=build /out/glpi-ai-agent /app/glpi-ai-agent COPY knowledge /app/knowledge VOLUME ["/app/data"] EXPOSE 8080 +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=4 CMD ["/app/glpi-ai-agent", "healthcheck"] USER 65532:65532 ENTRYPOINT ["/app/glpi-ai-agent"] diff --git a/services/agent/cmd/agent/main.go b/services/agent/cmd/agent/main.go index ab5ce55..d413c74 100644 --- a/services/agent/cmd/agent/main.go +++ b/services/agent/cmd/agent/main.go @@ -27,6 +27,10 @@ import ( ) func main() { + if len(os.Args) == 2 && os.Args[1] == "healthcheck" { + runHealthcheck() + return + } cfg, err := config.Load() if err != nil { slog.Error("configuration invalid", "error", err) @@ -204,6 +208,22 @@ func main() { slog.Info("shutdown complete") } +func runHealthcheck() { + client := &http.Client{Timeout: 2 * time.Second} + req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8080/healthz", nil) + if err != nil { + os.Exit(1) + } + resp, err := client.Do(req) + if err != nil { + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + os.Exit(1) + } +} + func waitForOllamaPool(ctx context.Context, client *ollama.Client, retryInterval time.Duration) error { if retryInterval < 2*time.Second { retryInterval = 5 * time.Second diff --git a/services/agent/internal/config/config.go b/services/agent/internal/config/config.go index 3878721..d660a1a 100644 --- a/services/agent/internal/config/config.go +++ b/services/agent/internal/config/config.go @@ -325,6 +325,7 @@ func Load() (Config, error) { CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"), CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"), CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"), + AIContentLabelEnabled: envBool("AI_CONTENT_LABEL_ENABLED", true), AutoCategory: envBool("AUTO_CATEGORY", true), AutoReply: envBool("AUTO_REPLY", false), PriorityEnabled: envBool("PRIORITY_ENABLED", true), @@ -651,6 +652,12 @@ func (c Config) Validate() error { return fmt.Errorf("KNOWLEDGE_VECTOR_BACKEND must be one of: local, dual, neuroforge (got %q)", c.KnowledgeVectorBackend) } if c.KnowledgeVectorBackend == "dual" || c.KnowledgeVectorBackend == "neuroforge" { + if len(c.NeuroForgeAPIKey) < 24 { + return errors.New("NEUROFORGE_API_KEY must contain at least 24 characters for dual/neuroforge mode") + } + if isPlaceholder(c.NeuroForgeAPIKey) { + return errors.New("NEUROFORGE_API_KEY still contains a CHANGE_ME placeholder") + } u, err := url.Parse(c.NeuroForgeURL) if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return errors.New("NEUROFORGE_URL must be an absolute http(s) URL") diff --git a/services/agent/internal/config/config_test.go b/services/agent/internal/config/config_test.go index 666100e..2c5c63e 100644 --- a/services/agent/internal/config/config_test.go +++ b/services/agent/internal/config/config_test.go @@ -515,3 +515,40 @@ func TestValidateGLPIKBAutoReplyUncategorizedRequiresArticleAllowlist(t *testing t.Fatalf("expected uncategorized auto reply with explicit article allowlist to validate: %v", err) } } + +func TestLoadReadsAIContentLabelEnabled(t *testing.T) { + for key, value := range map[string]string{ + "GLPI_URL": "https://glpi.internal.example", + "GLPI_CLIENT_ID": "client-id", + "GLPI_CLIENT_SECRET": "real-secret-value", + "GLPI_USERNAME": "svc-agent", + "GLPI_PASSWORD": "real-password-value", + "WEB_ALLOW_ANONYMOUS": "true", + "AI_CONTENT_LABEL_ENABLED": "false", + } { + t.Setenv(key, value) + } + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.AIContentLabelEnabled { + t.Fatal("AI_CONTENT_LABEL_ENABLED=false was not loaded") + } +} + +func TestValidateNeuroForgeAPIKeyForRemoteVectorBackend(t *testing.T) { + c := validConfig() + c.KnowledgeVectorBackend = "dual" + c.NeuroForgeURL = "http://neuroforge:8090" + c.NeuroForgeSearchK = 128 + c.NeuroForgeTimeout = 5 * time.Second + c.NeuroForgeAPIKey = "CHANGE_ME_NEUROFORGE_API_KEY" + if err := c.Validate(); err == nil { + t.Fatal("expected placeholder NEUROFORGE_API_KEY to be rejected") + } + c.NeuroForgeAPIKey = "integration-token-0123456789abcdef" + if err := c.Validate(); err != nil { + t.Fatalf("expected strong NEUROFORGE_API_KEY to validate: %v", err) + } +} diff --git a/services/control/Dockerfile b/services/control/Dockerfile index 990ae61..ea3e9f6 100644 --- a/services/control/Dockerfile +++ b/services/control/Dockerfile @@ -1,7 +1,9 @@ FROM golang:1.23-alpine AS build WORKDIR /src COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control . +RUN go test ./... && \ + go vet ./... && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/control . FROM alpine:3.21 RUN addgroup -S app && adduser -S -G app app COPY --from=build /out/control /usr/local/bin/control diff --git a/services/control/engineering-graph.json b/services/control/engineering-graph.json index c2a7447..858e6cd 100644 --- a/services/control/engineering-graph.json +++ b/services/control/engineering-graph.json @@ -1002,7 +1002,19 @@ "community": "github.com/example/glpi-ai-agent/cmd/agent", "meta": { "exported": false, - "line": 240, + "line": 260, + "path": "services/agent/cmd/agent/main.go" + } + }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", + "kind": "function", + "label": "runHealthcheck", + "group": "engineering", + "community": "github.com/example/glpi-ai-agent/cmd/agent", + "meta": { + "exported": false, + "line": 211, "path": "services/agent/cmd/agent/main.go" } }, @@ -1014,7 +1026,7 @@ "community": "github.com/example/glpi-ai-agent/cmd/agent", "meta": { "exported": false, - "line": 207, + "line": 227, "path": "services/agent/cmd/agent/main.go" } }, @@ -2382,7 +2394,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": true, - "line": 1056, + "line": 1063, "path": "services/agent/internal/config/config.go" } }, @@ -2394,7 +2406,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": true, - "line": 439, + "line": 440, "path": "services/agent/internal/config/config.go" } }, @@ -2418,7 +2430,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1030, + "line": 1037, "path": "services/agent/internal/config/config.go" } }, @@ -2430,7 +2442,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1201, + "line": 1208, "path": "services/agent/internal/config/config.go" } }, @@ -2442,7 +2454,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1245, + "line": 1252, "path": "services/agent/internal/config/config.go" } }, @@ -2454,7 +2466,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1234, + "line": 1241, "path": "services/agent/internal/config/config.go" } }, @@ -2466,7 +2478,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1212, + "line": 1219, "path": "services/agent/internal/config/config.go" } }, @@ -2478,7 +2490,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1223, + "line": 1230, "path": "services/agent/internal/config/config.go" } }, @@ -2490,7 +2502,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1079, + "line": 1086, "path": "services/agent/internal/config/config.go" } }, @@ -2502,7 +2514,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1101, + "line": 1108, "path": "services/agent/internal/config/config.go" } }, @@ -2514,7 +2526,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1184, + "line": 1191, "path": "services/agent/internal/config/config.go" } }, @@ -2526,7 +2538,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1045, + "line": 1052, "path": "services/agent/internal/config/config.go" } }, @@ -2538,7 +2550,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1176, + "line": 1183, "path": "services/agent/internal/config/config.go" } }, @@ -2550,7 +2562,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1123, + "line": 1130, "path": "services/agent/internal/config/config.go" } }, @@ -2562,7 +2574,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1149, + "line": 1156, "path": "services/agent/internal/config/config.go" } }, @@ -2574,7 +2586,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1039, + "line": 1046, "path": "services/agent/internal/config/config.go" } }, @@ -2586,7 +2598,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1075, + "line": 1082, "path": "services/agent/internal/config/config.go" } }, @@ -2598,7 +2610,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1016, + "line": 1023, "path": "services/agent/internal/config/config.go" } }, @@ -2610,7 +2622,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 1011, + "line": 1018, "path": "services/agent/internal/config/config.go" } }, @@ -2622,7 +2634,7 @@ "community": "github.com/example/glpi-ai-agent/internal/config", "meta": { "exported": false, - "line": 986, + "line": 993, "path": "services/agent/internal/config/config.go" } }, @@ -7326,7 +7338,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 175, + "line": 239, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7338,7 +7350,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 202, + "line": 227, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7350,7 +7362,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 526, + "line": 568, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7362,7 +7374,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 130, + "line": 155, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7374,7 +7386,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 165, + "line": 190, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7386,7 +7398,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 185, + "line": 210, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7398,7 +7410,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 110, + "line": 135, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7410,7 +7422,19 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 271, + "line": 309, + "path": "services/knowledge/cmd/server/app.go" + } + }, + { + "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "kind": "function", + "label": "app.handleIntegrationStagingHealth", + "group": "engineering", + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, + "line": 293, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7422,7 +7446,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 134, + "line": 159, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7434,7 +7458,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 151, + "line": 176, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7446,7 +7470,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 497, + "line": 539, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7458,7 +7482,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 493, + "line": 535, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7470,7 +7494,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 551, + "line": 593, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7482,7 +7506,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 139, + "line": 164, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7494,7 +7518,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 422, + "line": 460, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7506,7 +7530,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 388, + "line": 426, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7518,7 +7542,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 341, + "line": 379, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7530,7 +7554,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 313, + "line": 351, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7542,7 +7566,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 405, + "line": 443, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7554,7 +7578,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 363, + "line": 401, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7566,7 +7590,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 477, + "line": 515, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7578,7 +7602,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 62, + "line": 63, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7590,7 +7614,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 51, + "line": 52, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7602,7 +7626,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 56, + "line": 57, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7614,10 +7638,22 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 125, + "line": 184, "path": "services/knowledge/cmd/server/main.go" } }, + { + "id": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "kind": "function", + "label": "browserWriteSameOrigin", + "group": "engineering", + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, + "line": 101, + "path": "services/knowledge/cmd/server/app.go" + } + }, { "id": "function:kb-editor/cmd/server:configFromEnv", "kind": "function", @@ -7626,7 +7662,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 103, + "line": 162, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7638,7 +7674,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 562, + "line": 604, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7650,7 +7686,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 228, + "line": 292, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7662,7 +7698,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 240, + "line": 304, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7674,7 +7710,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 254, + "line": 279, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7686,7 +7722,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 25, + "line": 29, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7698,7 +7734,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 281, + "line": 345, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7710,7 +7746,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 43, + "line": 44, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7722,7 +7758,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 247, + "line": 311, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7734,7 +7770,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 220, + "line": 284, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7746,7 +7782,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 170, + "line": 195, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7758,7 +7794,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 273, + "line": 337, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7770,7 +7806,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 99, + "line": 124, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7782,7 +7818,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 156, + "line": 220, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7794,7 +7830,19 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 146, + "line": 205, + "path": "services/knowledge/cmd/server/main.go" + } + }, + { + "id": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "kind": "function", + "label": "validateRuntimeSecrets", + "group": "engineering", + "community": "kb-editor/cmd/server", + "meta": { + "exported": false, + "line": 132, "path": "services/knowledge/cmd/server/main.go" } }, @@ -7806,7 +7854,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 584, + "line": 626, "path": "services/knowledge/cmd/server/app.go" } }, @@ -7818,7 +7866,7 @@ "community": "kb-editor/cmd/server", "meta": { "exported": false, - "line": 578, + "line": 620, "path": "services/knowledge/cmd/server/app.go" } }, @@ -8370,7 +8418,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 403, + "line": 421, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8382,7 +8430,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 383, + "line": 401, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8394,7 +8442,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 411, + "line": 429, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8406,7 +8454,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 578, + "line": 605, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8418,7 +8466,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 587, + "line": 614, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8430,7 +8478,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 602, + "line": 629, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8442,7 +8490,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 527, + "line": 554, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8454,7 +8502,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 471, + "line": 498, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8466,7 +8514,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 511, + "line": 538, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8478,7 +8526,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 501, + "line": 528, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8490,7 +8538,19 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 439, + "line": 466, + "path": "services/knowledge/internal/staging/staging.go" + } + }, + { + "id": "function:kb-editor/internal/staging:syncDir", + "kind": "function", + "label": "syncDir", + "group": "engineering", + "community": "kb-editor/internal/staging", + "meta": { + "exported": false, + "line": 457, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8502,7 +8562,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 540, + "line": 567, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8514,7 +8574,7 @@ "community": "kb-editor/internal/staging", "meta": { "exported": false, - "line": 557, + "line": 584, "path": "services/knowledge/internal/staging/staging.go" } }, @@ -8538,7 +8598,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": true, - "line": 726, + "line": 771, "path": "services/knowledge/internal/store/store.go" } }, @@ -8586,7 +8646,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": true, - "line": 1182, + "line": 1230, "path": "services/knowledge/internal/store/store.go" } }, @@ -8662,6 +8722,18 @@ "path": "services/knowledge/internal/store/store.go" } }, + { + "id": "function:kb-editor/internal/store:Store.RollbackImported", + "kind": "function", + "label": "Store.RollbackImported", + "group": "engineering", + "community": "kb-editor/internal/store", + "meta": { + "exported": true, + "line": 707, + "path": "services/knowledge/internal/store/store.go" + } + }, { "id": "function:kb-editor/internal/store:Store.Save", "kind": "function", @@ -8694,7 +8766,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 962, + "line": 1010, "path": "services/knowledge/internal/store/store.go" } }, @@ -8706,7 +8778,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 953, + "line": 1001, "path": "services/knowledge/internal/store/store.go" } }, @@ -8730,7 +8802,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 993, + "line": 1041, "path": "services/knowledge/internal/store/store.go" } }, @@ -8742,7 +8814,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 912, + "line": 957, "path": "services/knowledge/internal/store/store.go" } }, @@ -8754,7 +8826,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 796, + "line": 841, "path": "services/knowledge/internal/store/store.go" } }, @@ -8766,7 +8838,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1033, + "line": 1081, "path": "services/knowledge/internal/store/store.go" } }, @@ -8790,7 +8862,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1043, + "line": 1091, "path": "services/knowledge/internal/store/store.go" } }, @@ -8802,7 +8874,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1136, + "line": 1184, "path": "services/knowledge/internal/store/store.go" } }, @@ -8826,7 +8898,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1095, + "line": 1143, "path": "services/knowledge/internal/store/store.go" } }, @@ -8850,7 +8922,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 881, + "line": 926, "path": "services/knowledge/internal/store/store.go" } }, @@ -8862,7 +8934,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1062, + "line": 1110, "path": "services/knowledge/internal/store/store.go" } }, @@ -8886,7 +8958,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 859, + "line": 904, "path": "services/knowledge/internal/store/store.go" } }, @@ -8898,7 +8970,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 700, + "line": 745, "path": "services/knowledge/internal/store/store.go" } }, @@ -8910,7 +8982,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1155, + "line": 1203, "path": "services/knowledge/internal/store/store.go" } }, @@ -8934,7 +9006,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1052, + "line": 1100, "path": "services/knowledge/internal/store/store.go" } }, @@ -8946,7 +9018,19 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1004, + "line": 1052, + "path": "services/knowledge/internal/store/store.go" + } + }, + { + "id": "function:kb-editor/internal/store:syncDir", + "kind": "function", + "label": "syncDir", + "group": "engineering", + "community": "kb-editor/internal/store", + "meta": { + "exported": false, + "line": 736, "path": "services/knowledge/internal/store/store.go" } }, @@ -8958,7 +9042,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1078, + "line": 1126, "path": "services/knowledge/internal/store/store.go" } }, @@ -8994,7 +9078,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1161, + "line": 1209, "path": "services/knowledge/internal/store/store.go" } }, @@ -9006,7 +9090,7 @@ "community": "kb-editor/internal/store", "meta": { "exported": false, - "line": 1142, + "line": 1190, "path": "services/knowledge/internal/store/store.go" } }, @@ -9214,6 +9298,18 @@ "path": "services/control/graph.go" } }, + { + "id": "function:mega-control:controlBasicAuth", + "kind": "function", + "label": "controlBasicAuth", + "group": "engineering", + "community": "mega-control", + "meta": { + "exported": false, + "line": 178, + "path": "services/control/main.go" + } + }, { "id": "function:mega-control:csvSet", "kind": "function", @@ -9246,7 +9342,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 65, + "line": 69, "path": "services/control/main.go" } }, @@ -9294,7 +9390,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 72, + "line": 76, "path": "services/control/main.go" } }, @@ -9306,7 +9402,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 117, + "line": 201, "path": "services/control/main.go" } }, @@ -9318,7 +9414,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 160, + "line": 244, "path": "services/control/main.go" } }, @@ -9342,7 +9438,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 127, + "line": 211, "path": "services/control/main.go" } }, @@ -9426,7 +9522,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 131, + "line": 215, "path": "services/control/main.go" } }, @@ -9462,7 +9558,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 148, + "line": 232, "path": "services/control/main.go" } }, @@ -9490,6 +9586,18 @@ "path": "services/control/graph.go" } }, + { + "id": "function:mega-control:validateControlSecrets", + "kind": "function", + "label": "validateControlSecrets", + "group": "engineering", + "community": "mega-control", + "meta": { + "exported": false, + "line": 147, + "path": "services/control/main.go" + } + }, { "id": "function:mega-control:writeJSON", "kind": "function", @@ -9498,7 +9606,7 @@ "community": "mega-control", "meta": { "exported": false, - "line": 195, + "line": 279, "path": "services/control/main.go" } }, @@ -9582,7 +9690,7 @@ "community": "neuroforge/cmd/server", "meta": { "exported": false, - "line": 57, + "line": 92, "path": "platform/neuroforge/cmd/server/main.go" } }, @@ -9594,7 +9702,7 @@ "community": "neuroforge/cmd/server", "meta": { "exported": false, - "line": 50, + "line": 85, "path": "platform/neuroforge/cmd/server/main.go" } }, @@ -9606,7 +9714,31 @@ "community": "neuroforge/cmd/server", "meta": { "exported": false, - "line": 64, + "line": 99, + "path": "platform/neuroforge/cmd/server/main.go" + } + }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecret", + "kind": "function", + "label": "validateManagedSecret", + "group": "engineering", + "community": "neuroforge/cmd/server", + "meta": { + "exported": false, + "line": 50, + "path": "platform/neuroforge/cmd/server/main.go" + } + }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", + "kind": "function", + "label": "validateManagedSecretsFromEnv", + "group": "engineering", + "community": "neuroforge/cmd/server", + "meta": { + "exported": false, + "line": 65, "path": "platform/neuroforge/cmd/server/main.go" } }, @@ -9678,7 +9810,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 988, + "line": 1008, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9690,10 +9822,22 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 183, + "line": 203, "path": "platform/neuroforge/internal/brain/brain.go" } }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "kind": "function", + "label": "Engine.CheckStagingPublisher", + "group": "engineering", + "community": "neuroforge/internal/brain", + "meta": { + "exported": true, + "line": 232, + "path": "platform/neuroforge/internal/brain/staging.go" + } + }, { "id": "function:neuroforge/internal/brain:Engine.ClusterAbort", "kind": "function", @@ -9786,7 +9930,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 729, + "line": 749, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9798,7 +9942,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 513, + "line": 533, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9810,7 +9954,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 437, + "line": 457, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9846,7 +9990,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 358, + "line": 378, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9858,7 +10002,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 296, + "line": 342, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -9906,7 +10050,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 123, + "line": 169, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -9930,7 +10074,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 915, + "line": 935, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -9942,7 +10086,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 438, + "line": 484, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -9990,7 +10134,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 490, + "line": 510, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10002,7 +10146,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 1009, + "line": 1029, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10014,7 +10158,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 503, + "line": 523, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10042,6 +10186,18 @@ "path": "platform/neuroforge/internal/brain/v5.go" } }, + { + "id": "function:neuroforge/internal/brain:Engine.beginGoalCycle", + "kind": "function", + "label": "Engine.beginGoalCycle", + "group": "engineering", + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, + "line": 47, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, { "id": "function:neuroforge/internal/brain:Engine.chatModel", "kind": "function", @@ -10050,7 +10206,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 107, + "line": 127, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10062,7 +10218,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 111, + "line": 131, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10074,7 +10230,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 123, + "line": 143, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10158,7 +10314,19 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 62, + "line": 82, + "path": "platform/neuroforge/internal/brain/brain.go" + } + }, + { + "id": "function:neuroforge/internal/brain:Engine.endGoalCycle", + "kind": "function", + "label": "Engine.endGoalCycle", + "group": "engineering", + "community": "neuroforge/internal/brain", + "meta": { + "exported": false, + "line": 57, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10170,7 +10338,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 967, + "line": 987, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10182,7 +10350,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 532, + "line": 552, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10254,7 +10422,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 978, + "line": 998, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10314,7 +10482,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 331, + "line": 351, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10326,7 +10494,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 643, + "line": 663, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10338,7 +10506,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 676, + "line": 696, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10350,7 +10518,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 397, + "line": 443, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10386,7 +10554,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 574, + "line": 594, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10422,7 +10590,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 851, + "line": 871, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10434,7 +10602,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 207, + "line": 258, "path": "platform/neuroforge/internal/brain/staging.go" } }, @@ -10446,7 +10614,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": true, - "line": 39, + "line": 43, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10494,7 +10662,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 279, + "line": 325, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10506,7 +10674,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 317, + "line": 337, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10566,7 +10734,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 870, + "line": 890, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10578,7 +10746,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 248, + "line": 299, "path": "platform/neuroforge/internal/brain/staging.go" } }, @@ -10590,7 +10758,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 239, + "line": 285, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10602,7 +10770,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 229, + "line": 275, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10614,7 +10782,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 434, + "line": 480, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10638,7 +10806,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 193, + "line": 239, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10674,7 +10842,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 266, + "line": 317, "path": "platform/neuroforge/internal/brain/staging.go" } }, @@ -10698,7 +10866,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 427, + "line": 473, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10710,7 +10878,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 420, + "line": 440, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10722,7 +10890,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 941, + "line": 961, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10734,7 +10902,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 509, + "line": 555, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10746,7 +10914,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 264, + "line": 310, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10794,7 +10962,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 378, + "line": 424, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10806,7 +10974,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 359, + "line": 405, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10854,7 +11022,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 173, + "line": 193, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10866,7 +11034,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 388, + "line": 434, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10890,7 +11058,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 500, + "line": 546, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10926,7 +11094,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 168, + "line": 214, "path": "platform/neuroforge/internal/brain/v3.go" } }, @@ -10938,7 +11106,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 433, + "line": 453, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10950,7 +11118,7 @@ "community": "neuroforge/internal/brain", "meta": { "exported": false, - "line": 890, + "line": 910, "path": "platform/neuroforge/internal/brain/brain.go" } }, @@ -10962,7 +11130,7 @@ "community": "neuroforge/internal/core", "meta": { "exported": true, - "line": 673, + "line": 675, "path": "platform/neuroforge/internal/core/types.go" } }, @@ -11082,7 +11250,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": true, - "line": 37, + "line": 40, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11094,7 +11262,19 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": true, - "line": 42, + "line": 47, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, + { + "id": "function:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive", + "kind": "function", + "label": "Server.SetReadinessOllamaLive", + "group": "engineering", + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": true, + "line": 46, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11106,7 +11286,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 244, + "line": 276, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11118,7 +11298,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 121, + "line": 126, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11130,7 +11310,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 140, + "line": 145, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11166,7 +11346,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 679, + "line": 737, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11178,7 +11358,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 653, + "line": 711, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11214,7 +11394,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 675, + "line": 733, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11226,7 +11406,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 476, + "line": 508, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11250,7 +11430,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 531, + "line": 563, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11262,7 +11442,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 585, + "line": 617, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11346,7 +11526,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 642, + "line": 700, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11370,7 +11550,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 637, + "line": 695, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11382,7 +11562,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 479, + "line": 511, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11406,7 +11586,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 535, + "line": 567, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11418,7 +11598,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 598, + "line": 630, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11430,7 +11610,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 125, + "line": 130, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11478,7 +11658,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 152, + "line": 157, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11490,7 +11670,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 112, + "line": 117, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11502,7 +11682,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 572, + "line": 604, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11514,7 +11694,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 439, + "line": 471, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11538,7 +11718,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 665, + "line": 723, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11562,7 +11742,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 668, + "line": 726, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11574,7 +11754,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 148, + "line": 153, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11586,7 +11766,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 216, + "line": 221, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11598,7 +11778,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 280, + "line": 312, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11622,7 +11802,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 254, + "line": 286, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11718,10 +11898,22 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 108, + "line": 113, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "kind": "function", + "label": "Server.controlReadAuth", + "group": "engineering", + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, + "line": 253, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, { "id": "function:neuroforge/internal/httpapi:Server.err", "kind": "function", @@ -11730,7 +11922,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 276, + "line": 308, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11742,7 +11934,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 373, + "line": 405, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11754,7 +11946,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 91, + "line": 92, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11766,7 +11958,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 71, + "line": 72, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11802,7 +11994,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 81, + "line": 82, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11814,7 +12006,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 17, + "line": 18, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11826,7 +12018,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 56, + "line": 57, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11838,7 +12030,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 30, + "line": 31, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11850,7 +12042,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 13, + "line": 14, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11862,7 +12054,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 39, + "line": 40, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -11874,7 +12066,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 359, + "line": 391, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11886,7 +12078,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 145, + "line": 150, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -11914,6 +12106,18 @@ "path": "platform/neuroforge/internal/httpapi/v8.go" } }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "kind": "function", + "label": "Server.integrationAuth", + "group": "engineering", + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, + "line": 240, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, { "id": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph", "kind": "function", @@ -12018,7 +12222,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 271, + "line": 303, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12030,7 +12234,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 293, + "line": 325, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12042,7 +12246,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 100, + "line": 105, "path": "platform/neuroforge/internal/httpapi/v3.go" } }, @@ -12054,7 +12258,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 736, + "line": 794, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12066,7 +12270,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 186, + "line": 191, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12090,7 +12294,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 740, + "line": 859, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12102,7 +12306,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 711, + "line": 769, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12126,7 +12330,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 50, + "line": 55, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12138,7 +12342,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 306, + "line": 338, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12150,7 +12354,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 322, + "line": 354, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12162,7 +12366,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 689, + "line": 747, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12198,7 +12402,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 385, + "line": 417, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12210,7 +12414,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 235, + "line": 267, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12222,7 +12426,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 389, + "line": 421, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12234,7 +12438,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 416, + "line": 448, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12258,7 +12462,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 209, + "line": 214, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12274,6 +12478,30 @@ "path": "platform/neuroforge/internal/httpapi/metrics.go" } }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "kind": "function", + "label": "checkConfiguredOllamaModels", + "group": "engineering", + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, + "line": 812, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, + { + "id": "function:neuroforge/internal/httpapi:configuredModelAvailable", + "kind": "function", + "label": "configuredModelAvailable", + "group": "engineering", + "community": "neuroforge/internal/httpapi", + "meta": { + "exported": false, + "line": 798, + "path": "platform/neuroforge/internal/httpapi/httpapi.go" + } + }, { "id": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot", "kind": "function", @@ -12294,7 +12522,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 265, + "line": 297, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12390,7 +12618,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 576, + "line": 608, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12462,7 +12690,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 518, + "line": 550, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12522,7 +12750,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": false, - "line": 202, + "line": 207, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12558,7 +12786,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": true, - "line": 167, + "line": 172, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12570,7 +12798,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": true, - "line": 177, + "line": 182, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -12582,7 +12810,7 @@ "community": "neuroforge/internal/httpapi", "meta": { "exported": true, - "line": 169, + "line": 174, "path": "platform/neuroforge/internal/httpapi/httpapi.go" } }, @@ -13254,7 +13482,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 570, + "line": 576, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13566,7 +13794,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 783, + "line": 789, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13590,7 +13818,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1300, + "line": 1306, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13614,7 +13842,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1358, + "line": 1364, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13626,7 +13854,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 572, + "line": 578, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13710,7 +13938,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 588, + "line": 594, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13722,7 +13950,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1383, + "line": 1389, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13734,7 +13962,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 609, + "line": 615, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13758,7 +13986,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1145, + "line": 1151, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13770,7 +13998,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1098, + "line": 1104, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13806,7 +14034,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1405, + "line": 1411, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13854,7 +14082,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1345, + "line": 1351, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13866,7 +14094,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1425, + "line": 1431, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -13914,7 +14142,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 849, + "line": 855, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14058,7 +14286,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1240, + "line": 1246, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14070,7 +14298,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1198, + "line": 1204, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14082,7 +14310,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1217, + "line": 1223, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14202,7 +14430,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1332, + "line": 1338, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14226,7 +14454,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1062, + "line": 1068, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14298,7 +14526,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 906, + "line": 912, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14310,7 +14538,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1741, + "line": 1747, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14322,7 +14550,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1750, + "line": 1756, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14334,7 +14562,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 666, + "line": 672, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14346,7 +14574,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 600, + "line": 606, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14370,7 +14598,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1173, + "line": 1179, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14382,7 +14610,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1184, + "line": 1190, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14430,7 +14658,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1253, + "line": 1259, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14466,7 +14694,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1230, + "line": 1236, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14502,7 +14730,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1121, + "line": 1127, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14526,7 +14754,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 610, + "line": 616, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14538,7 +14766,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1246, + "line": 1252, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14550,7 +14778,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 673, + "line": 679, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14598,7 +14826,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1316, + "line": 1322, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14610,7 +14838,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": true, - "line": 1443, + "line": 1449, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14802,7 +15030,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 860, + "line": 866, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14886,7 +15114,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 535, + "line": 541, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14934,7 +15162,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 691, + "line": 697, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14970,7 +15198,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 555, + "line": 561, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -14982,7 +15210,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 558, + "line": 564, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15006,7 +15234,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 696, + "line": 702, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15018,7 +15246,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 733, + "line": 739, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15078,7 +15306,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 912, + "line": 918, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15150,7 +15378,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 1449, + "line": 1455, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15390,7 +15618,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 208, + "line": 214, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15462,7 +15690,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 885, + "line": 891, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15498,7 +15726,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 683, + "line": 689, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15558,7 +15786,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 1055, + "line": 1061, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15666,7 +15894,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 522, + "line": 528, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15810,7 +16038,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 483, + "line": 489, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -15930,7 +16158,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 1091, + "line": 1097, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -16014,7 +16242,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 562, + "line": 568, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -16146,7 +16374,7 @@ "community": "neuroforge/internal/store", "meta": { "exported": false, - "line": 543, + "line": 549, "path": "platform/neuroforge/internal/store/store.go" } }, @@ -18094,6 +18322,16 @@ "file": "services/knowledge/cmd/server/app.go" } }, + { + "id": "route:GET /api/integrations/staging/health", + "kind": "route", + "label": "GET /api/integrations/staging/health", + "group": "engineering", + "community": "services/knowledge", + "meta": { + "file": "services/knowledge/cmd/server/app.go" + } + }, { "id": "route:GET /api/items", "kind": "route", @@ -19040,7 +19278,10 @@ "label": "agent", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:agent-data-init", @@ -19048,7 +19289,10 @@ "label": "agent-data-init", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:control", @@ -19056,7 +19300,10 @@ "label": "control", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:knowledge", @@ -19064,7 +19311,10 @@ "label": "knowledge", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:neuroforge", @@ -19072,7 +19322,10 @@ "label": "neuroforge", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:neuroforge-worker", @@ -19080,7 +19333,10 @@ "label": "neuroforge-worker", "group": "runtime", "community": "compose", - "status": "configured" + "status": "configured", + "meta": { + "image": "git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.5.0}" + } }, { "id": "service:ollama", @@ -19448,6 +19704,18 @@ "to": "function:neuroforge/cmd/server:run", "kind": "defines" }, + { + "id": "file:platform/neuroforge/cmd/server/main.go-\u003efunction:neuroforge/cmd/server:validateManagedSecret:defines", + "from": "file:platform/neuroforge/cmd/server/main.go", + "to": "function:neuroforge/cmd/server:validateManagedSecret", + "kind": "defines" + }, + { + "id": "file:platform/neuroforge/cmd/server/main.go-\u003efunction:neuroforge/cmd/server:validateManagedSecretsFromEnv:defines", + "from": "file:platform/neuroforge/cmd/server/main.go", + "to": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", + "kind": "defines" + }, { "id": "file:platform/neuroforge/cmd/server/main.go-\u003epackage:context:imports", "from": "file:platform/neuroforge/cmd/server/main.go", @@ -19730,6 +19998,12 @@ "to": "function:neuroforge/internal/brain:Engine.SearchVector", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.beginGoalCycle:defines", + "from": "file:platform/neuroforge/internal/brain/brain.go", + "to": "function:neuroforge/internal/brain:Engine.beginGoalCycle", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.chatModel:defines", "from": "file:platform/neuroforge/internal/brain/brain.go", @@ -19754,6 +20028,12 @@ "to": "function:neuroforge/internal/brain:Engine.embed", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.endGoalCycle:defines", + "from": "file:platform/neuroforge/internal/brain/brain.go", + "to": "function:neuroforge/internal/brain:Engine.endGoalCycle", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/brain/brain.go-\u003efunction:neuroforge/internal/brain:Engine.enqueueRelink:defines", "from": "file:platform/neuroforge/internal/brain/brain.go", @@ -20114,6 +20394,12 @@ "to": "package:time", "kind": "imports" }, + { + "id": "file:platform/neuroforge/internal/brain/staging.go-\u003efunction:neuroforge/internal/brain:Engine.CheckStagingPublisher:defines", + "from": "file:platform/neuroforge/internal/brain/staging.go", + "to": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/brain/staging.go-\u003efunction:neuroforge/internal/brain:Engine.ConfigureStagingPublisher:defines", "from": "file:platform/neuroforge/internal/brain/staging.go", @@ -20984,6 +21270,12 @@ "to": "function:neuroforge/internal/httpapi:Server.Handler", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "function:neuroforge/internal/httpapi:Server.SetReadinessOllamaLive", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.adminAuth:defines", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21098,6 +21390,12 @@ "to": "function:neuroforge/internal/httpapi:Server.clusterAuth", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.controlReadAuth:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.err:defines", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21122,6 +21420,12 @@ "to": "function:neuroforge/internal/httpapi:Server.index", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.integrationAuth:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:Server.json:defines", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21212,6 +21516,18 @@ "to": "function:neuroforge/internal/httpapi:bearer", "kind": "defines" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:checkConfiguredOllamaModels:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "kind": "defines" + }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:configuredModelAvailable:defines", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "function:neuroforge/internal/httpapi:configuredModelAvailable", + "kind": "defines" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003efunction:neuroforge/internal/httpapi:decode:defines", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21284,6 +21600,12 @@ "to": "package:errors", "kind": "imports" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:fmt:imports", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "package:fmt", + "kind": "imports" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:io:imports", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21332,6 +21654,12 @@ "to": "package:neuroforge/internal/store", "kind": "imports" }, + { + "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:os:imports", + "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", + "to": "package:os", + "kind": "imports" + }, { "id": "file:platform/neuroforge/internal/httpapi/httpapi.go-\u003epackage:strconv:imports", "from": "file:platform/neuroforge/internal/httpapi/httpapi.go", @@ -21938,6 +22266,12 @@ "to": "package:net/http", "kind": "imports" }, + { + "id": "file:platform/neuroforge/internal/httpapi/v3.go-\u003epackage:neuroforge/internal/brain:imports", + "from": "file:platform/neuroforge/internal/httpapi/v3.go", + "to": "package:neuroforge/internal/brain", + "kind": "imports" + }, { "id": "file:platform/neuroforge/internal/httpapi/v3.go-\u003epackage:neuroforge/internal/core:imports", "from": "file:platform/neuroforge/internal/httpapi/v3.go", @@ -25628,6 +25962,12 @@ "to": "function:github.com/example/glpi-ai-agent/cmd/agent:maxDuration", "kind": "defines" }, + { + "id": "file:services/agent/cmd/agent/main.go-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck:defines", + "from": "file:services/agent/cmd/agent/main.go", + "to": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", + "kind": "defines" + }, { "id": "file:services/agent/cmd/agent/main.go-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool:defines", "from": "file:services/agent/cmd/agent/main.go", @@ -31070,6 +31410,12 @@ "to": "package:sync", "kind": "imports" }, + { + "id": "file:services/control/main.go-\u003efunction:mega-control:controlBasicAuth:defines", + "from": "file:services/control/main.go", + "to": "function:mega-control:controlBasicAuth", + "kind": "defines" + }, { "id": "file:services/control/main.go-\u003efunction:mega-control:env:defines", "from": "file:services/control/main.go", @@ -31112,6 +31458,12 @@ "to": "function:mega-control:server.statusSnapshot", "kind": "defines" }, + { + "id": "file:services/control/main.go-\u003efunction:mega-control:validateControlSecrets:defines", + "from": "file:services/control/main.go", + "to": "function:mega-control:validateControlSecrets", + "kind": "defines" + }, { "id": "file:services/control/main.go-\u003efunction:mega-control:writeJSON:defines", "from": "file:services/control/main.go", @@ -31124,6 +31476,12 @@ "to": "package:context", "kind": "imports" }, + { + "id": "file:services/control/main.go-\u003epackage:crypto/subtle:imports", + "from": "file:services/control/main.go", + "to": "package:crypto/subtle", + "kind": "imports" + }, { "id": "file:services/control/main.go-\u003epackage:embed:imports", "from": "file:services/control/main.go", @@ -31136,6 +31494,12 @@ "to": "package:encoding/json", "kind": "imports" }, + { + "id": "file:services/control/main.go-\u003epackage:errors:imports", + "from": "file:services/control/main.go", + "to": "package:errors", + "kind": "imports" + }, { "id": "file:services/control/main.go-\u003epackage:fmt:imports", "from": "file:services/control/main.go", @@ -31160,6 +31524,12 @@ "to": "package:net/http", "kind": "imports" }, + { + "id": "file:services/control/main.go-\u003epackage:os/signal:imports", + "from": "file:services/control/main.go", + "to": "package:os/signal", + "kind": "imports" + }, { "id": "file:services/control/main.go-\u003epackage:os:imports", "from": "file:services/control/main.go", @@ -31172,6 +31542,12 @@ "to": "package:strings", "kind": "imports" }, + { + "id": "file:services/control/main.go-\u003epackage:syscall:imports", + "from": "file:services/control/main.go", + "to": "package:syscall", + "kind": "imports" + }, { "id": "file:services/control/main.go-\u003epackage:time:imports", "from": "file:services/control/main.go", @@ -31220,6 +31596,12 @@ "to": "function:kb-editor/cmd/server:app.handleIntegrationStaging", "kind": "defines" }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:app.handleIntegrationStagingHealth:defines", + "from": "file:services/knowledge/cmd/server/app.go", + "to": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "kind": "defines" + }, { "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:app.handleList:defines", "from": "file:services/knowledge/cmd/server/app.go", @@ -31316,6 +31698,12 @@ "to": "function:kb-editor/cmd/server:app.withStaging", "kind": "defines" }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:browserWriteSameOrigin:defines", + "from": "file:services/knowledge/cmd/server/app.go", + "to": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "kind": "defines" + }, { "id": "file:services/knowledge/cmd/server/app.go-\u003efunction:kb-editor/cmd/server:decodeJSON:defines", "from": "file:services/knowledge/cmd/server/app.go", @@ -31436,6 +31824,12 @@ "to": "package:net/http", "kind": "imports" }, + { + "id": "file:services/knowledge/cmd/server/app.go-\u003epackage:net/url:imports", + "from": "file:services/knowledge/cmd/server/app.go", + "to": "package:net/url", + "kind": "imports" + }, { "id": "file:services/knowledge/cmd/server/app.go-\u003epackage:os:imports", "from": "file:services/knowledge/cmd/server/app.go", @@ -31532,6 +31926,18 @@ "to": "function:kb-editor/cmd/server:startAutoReload", "kind": "defines" }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003efunction:kb-editor/cmd/server:validateRuntimeSecrets:defines", + "from": "file:services/knowledge/cmd/server/main.go", + "to": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "kind": "defines" + }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:context:imports", + "from": "file:services/knowledge/cmd/server/main.go", + "to": "package:context", + "kind": "imports" + }, { "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:crypto/subtle:imports", "from": "file:services/knowledge/cmd/server/main.go", @@ -31544,6 +31950,12 @@ "to": "package:embed", "kind": "imports" }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:errors:imports", + "from": "file:services/knowledge/cmd/server/main.go", + "to": "package:errors", + "kind": "imports" + }, { "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:flag:imports", "from": "file:services/knowledge/cmd/server/main.go", @@ -31592,6 +32004,12 @@ "to": "package:net/http", "kind": "imports" }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:os/signal:imports", + "from": "file:services/knowledge/cmd/server/main.go", + "to": "package:os/signal", + "kind": "imports" + }, { "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:os:imports", "from": "file:services/knowledge/cmd/server/main.go", @@ -31616,6 +32034,12 @@ "to": "package:strings", "kind": "imports" }, + { + "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:syscall:imports", + "from": "file:services/knowledge/cmd/server/main.go", + "to": "package:syscall", + "kind": "imports" + }, { "id": "file:services/knowledge/cmd/server/main.go-\u003epackage:time:imports", "from": "file:services/knowledge/cmd/server/main.go", @@ -32126,6 +32550,12 @@ "to": "function:kb-editor/internal/staging:summarize", "kind": "defines" }, + { + "id": "file:services/knowledge/internal/staging/staging.go-\u003efunction:kb-editor/internal/staging:syncDir:defines", + "from": "file:services/knowledge/internal/staging/staging.go", + "to": "function:kb-editor/internal/staging:syncDir", + "kind": "defines" + }, { "id": "file:services/knowledge/internal/staging/staging.go-\u003efunction:kb-editor/internal/staging:toStrings:defines", "from": "file:services/knowledge/internal/staging/staging.go", @@ -32294,6 +32724,12 @@ "to": "function:kb-editor/internal/store:Store.Reload", "kind": "defines" }, + { + "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:Store.RollbackImported:defines", + "from": "file:services/knowledge/internal/store/store.go", + "to": "function:kb-editor/internal/store:Store.RollbackImported", + "kind": "defines" + }, { "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:Store.Save:defines", "from": "file:services/knowledge/internal/store/store.go", @@ -32438,6 +32874,12 @@ "to": "function:kb-editor/internal/store:summarize", "kind": "defines" }, + { + "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:syncDir:defines", + "from": "file:services/knowledge/internal/store/store.go", + "to": "function:kb-editor/internal/store:syncDir", + "kind": "defines" + }, { "id": "file:services/knowledge/internal/store/store.go-\u003efunction:kb-editor/internal/store:toStrings:defines", "from": "file:services/knowledge/internal/store/store.go", @@ -32570,6 +33012,12 @@ "to": "function:github.com/example/glpi-ai-agent/cmd/agent:maxDuration", "kind": "calls" }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:main-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck:calls", + "from": "function:github.com/example/glpi-ai-agent/cmd/agent:main", + "to": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", + "kind": "calls" + }, { "id": "function:github.com/example/glpi-ai-agent/cmd/agent:main-\u003efunction:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool:calls", "from": "function:github.com/example/glpi-ai-agent/cmd/agent:main", @@ -32709,6 +33157,20 @@ "kind": "calls_package", "label": "ToLower" }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck-\u003epackage:net/http:calls_package", + "from": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", + "to": "package:net/http", + "kind": "calls_package", + "label": "NewRequest" + }, + { + "id": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck-\u003epackage:os:calls_package", + "from": "function:github.com/example/glpi-ai-agent/cmd/agent:runHealthcheck", + "to": "package:os", + "kind": "calls_package", + "label": "Exit" + }, { "id": "function:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool-\u003epackage:log/slog:calls_package", "from": "function:github.com/example/glpi-ai-agent/cmd/agent:waitForOllamaPool", @@ -42348,6 +42810,24 @@ "kind": "calls_package", "label": "TrimSpace" }, + { + "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:integrationBearerAuthorized:calls", + "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "to": "function:kb-editor/cmd/server:integrationBearerAuthorized", + "kind": "calls" + }, + { + "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:writeError:calls", + "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "to": "function:kb-editor/cmd/server:writeError", + "kind": "calls" + }, + { + "id": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth-\u003efunction:kb-editor/cmd/server:writeJSON:calls", + "from": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "to": "function:kb-editor/cmd/server:writeJSON", + "kind": "calls" + }, { "id": "function:kb-editor/cmd/server:app.handleList-\u003efunction:kb-editor/cmd/server:queryFromURL:calls", "from": "function:kb-editor/cmd/server:app.handleList", @@ -42612,6 +43092,12 @@ "kind": "calls_package", "label": "Errorf" }, + { + "id": "function:kb-editor/cmd/server:app.routes-\u003efunction:kb-editor/cmd/server:browserWriteSameOrigin:calls", + "from": "function:kb-editor/cmd/server:app.routes", + "to": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "kind": "calls" + }, { "id": "function:kb-editor/cmd/server:app.routes-\u003efunction:kb-editor/cmd/server:securityHeaders:calls", "from": "function:kb-editor/cmd/server:app.routes", @@ -42653,6 +43139,33 @@ "kind": "calls_package", "label": "ParseDuration" }, + { + "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003efunction:kb-editor/cmd/server:writeError:calls", + "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "to": "function:kb-editor/cmd/server:writeError", + "kind": "calls" + }, + { + "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:net/http:calls_package", + "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "to": "package:net/http", + "kind": "calls_package", + "label": "HandlerFunc" + }, + { + "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:net/url:calls_package", + "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "to": "package:net/url", + "kind": "calls_package", + "label": "Parse" + }, + { + "id": "function:kb-editor/cmd/server:browserWriteSameOrigin-\u003epackage:strings:calls_package", + "from": "function:kb-editor/cmd/server:browserWriteSameOrigin", + "to": "package:strings", + "kind": "calls_package", + "label": "ToLower" + }, { "id": "function:kb-editor/cmd/server:configFromEnv-\u003efunction:kb-editor/cmd/server:envOr:calls", "from": "function:kb-editor/cmd/server:configFromEnv", @@ -42829,6 +43342,26 @@ "to": "function:kb-editor/cmd/server:startAutoReload", "kind": "calls" }, + { + "id": "function:kb-editor/cmd/server:main-\u003efunction:kb-editor/cmd/server:validateRuntimeSecrets:calls", + "from": "function:kb-editor/cmd/server:main", + "to": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "kind": "calls" + }, + { + "id": "function:kb-editor/cmd/server:main-\u003epackage:context:calls_package", + "from": "function:kb-editor/cmd/server:main", + "to": "package:context", + "kind": "calls_package", + "label": "Background" + }, + { + "id": "function:kb-editor/cmd/server:main-\u003epackage:errors:calls_package", + "from": "function:kb-editor/cmd/server:main", + "to": "package:errors", + "kind": "calls_package", + "label": "Is" + }, { "id": "function:kb-editor/cmd/server:main-\u003epackage:flag:calls_package", "from": "function:kb-editor/cmd/server:main", @@ -42857,6 +43390,13 @@ "kind": "calls_package", "label": "Fatal" }, + { + "id": "function:kb-editor/cmd/server:main-\u003epackage:os/signal:calls_package", + "from": "function:kb-editor/cmd/server:main", + "to": "package:os/signal", + "kind": "calls_package", + "label": "NotifyContext" + }, { "id": "function:kb-editor/cmd/server:main-\u003epackage:os:calls_package", "from": "function:kb-editor/cmd/server:main", @@ -43017,6 +43557,27 @@ "kind": "calls_package", "label": "NewTicker" }, + { + "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:fmt:calls_package", + "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:os:calls_package", + "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "to": "package:os", + "kind": "calls_package", + "label": "Getenv" + }, + { + "id": "function:kb-editor/cmd/server:validateRuntimeSecrets-\u003epackage:strings:calls_package", + "from": "function:kb-editor/cmd/server:validateRuntimeSecrets", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:kb-editor/cmd/server:writeError-\u003efunction:kb-editor/cmd/server:writeJSON:calls", "from": "function:kb-editor/cmd/server:writeError", @@ -43945,6 +44506,12 @@ "to": "function:kb-editor/internal/staging:Store.pathForKey", "kind": "calls" }, + { + "id": "function:kb-editor/internal/staging:Store.archive-\u003efunction:kb-editor/internal/staging:syncDir:calls", + "from": "function:kb-editor/internal/staging:Store.archive", + "to": "function:kb-editor/internal/staging:syncDir", + "kind": "calls" + }, { "id": "function:kb-editor/internal/staging:Store.archive-\u003epackage:fmt:calls_package", "from": "function:kb-editor/internal/staging:Store.archive", @@ -44080,6 +44647,12 @@ "to": "function:kb-editor/internal/staging:Store.Dir", "kind": "calls" }, + { + "id": "function:kb-editor/internal/staging:atomicWrite-\u003efunction:kb-editor/internal/staging:syncDir:calls", + "from": "function:kb-editor/internal/staging:atomicWrite", + "to": "function:kb-editor/internal/staging:syncDir", + "kind": "calls" + }, { "id": "function:kb-editor/internal/staging:atomicWrite-\u003epackage:os:calls_package", "from": "function:kb-editor/internal/staging:atomicWrite", @@ -44170,6 +44743,13 @@ "to": "function:kb-editor/internal/staging:toStrings", "kind": "calls" }, + { + "id": "function:kb-editor/internal/staging:syncDir-\u003epackage:os:calls_package", + "from": "function:kb-editor/internal/staging:syncDir", + "to": "package:os", + "kind": "calls_package", + "label": "Open" + }, { "id": "function:kb-editor/internal/staging:uniqueStrings-\u003epackage:strings:calls_package", "from": "function:kb-editor/internal/staging:uniqueStrings", @@ -44375,6 +44955,12 @@ "to": "function:kb-editor/internal/store:summarize", "kind": "calls" }, + { + "id": "function:kb-editor/internal/store:Store.ImportDocument-\u003efunction:kb-editor/internal/store:syncDir:calls", + "from": "function:kb-editor/internal/store:Store.ImportDocument", + "to": "function:kb-editor/internal/store:syncDir", + "kind": "calls" + }, { "id": "function:kb-editor/internal/store:Store.ImportDocument-\u003epackage:errors:calls_package", "from": "function:kb-editor/internal/store:Store.ImportDocument", @@ -44474,6 +45060,26 @@ "kind": "calls_package", "label": "EqualFold" }, + { + "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003efunction:kb-editor/internal/store:syncDir:calls", + "from": "function:kb-editor/internal/store:Store.RollbackImported", + "to": "function:kb-editor/internal/store:syncDir", + "kind": "calls" + }, + { + "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003epackage:fmt:calls_package", + "from": "function:kb-editor/internal/store:Store.RollbackImported", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:kb-editor/internal/store:Store.RollbackImported-\u003epackage:os:calls_package", + "from": "function:kb-editor/internal/store:Store.RollbackImported", + "to": "package:os", + "kind": "calls_package", + "label": "Remove" + }, { "id": "function:kb-editor/internal/store:Store.Save-\u003efunction:kb-editor/internal/store:New:calls", "from": "function:kb-editor/internal/store:Store.Save", @@ -44707,6 +45313,12 @@ "to": "function:kb-editor/internal/store:marshalDocument", "kind": "calls" }, + { + "id": "function:kb-editor/internal/store:Store.writeRecord-\u003efunction:kb-editor/internal/store:syncDir:calls", + "from": "function:kb-editor/internal/store:Store.writeRecord", + "to": "function:kb-editor/internal/store:syncDir", + "kind": "calls" + }, { "id": "function:kb-editor/internal/store:Store.writeRecord-\u003epackage:os:calls_package", "from": "function:kb-editor/internal/store:Store.writeRecord", @@ -44966,6 +45578,13 @@ "to": "function:kb-editor/internal/store:toStrings", "kind": "calls" }, + { + "id": "function:kb-editor/internal/store:syncDir-\u003epackage:os:calls_package", + "from": "function:kb-editor/internal/store:syncDir", + "to": "package:os", + "kind": "calls_package", + "label": "Open" + }, { "id": "function:kb-editor/internal/store:topFacets-\u003epackage:sort:calls_package", "from": "function:kb-editor/internal/store:topFacets", @@ -45292,6 +45911,34 @@ "kind": "calls_package", "label": "TrimSpace" }, + { + "id": "function:mega-control:controlBasicAuth-\u003epackage:crypto/subtle:calls_package", + "from": "function:mega-control:controlBasicAuth", + "to": "package:crypto/subtle", + "kind": "calls_package", + "label": "ConstantTimeCompare" + }, + { + "id": "function:mega-control:controlBasicAuth-\u003epackage:net/http:calls_package", + "from": "function:mega-control:controlBasicAuth", + "to": "package:net/http", + "kind": "calls_package", + "label": "HandlerFunc" + }, + { + "id": "function:mega-control:controlBasicAuth-\u003epackage:os:calls_package", + "from": "function:mega-control:controlBasicAuth", + "to": "package:os", + "kind": "calls_package", + "label": "Getenv" + }, + { + "id": "function:mega-control:controlBasicAuth-\u003epackage:strings:calls_package", + "from": "function:mega-control:controlBasicAuth", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:mega-control:csvSet-\u003epackage:strings:calls_package", "from": "function:mega-control:csvSet", @@ -45320,6 +45967,12 @@ "kind": "calls_package", "label": "Unmarshal" }, + { + "id": "function:mega-control:main-\u003efunction:mega-control:controlBasicAuth:calls", + "from": "function:mega-control:main", + "to": "function:mega-control:controlBasicAuth", + "kind": "calls" + }, { "id": "function:mega-control:main-\u003efunction:mega-control:env:calls", "from": "function:mega-control:main", @@ -45332,18 +45985,38 @@ "to": "function:mega-control:secure", "kind": "calls" }, + { + "id": "function:mega-control:main-\u003efunction:mega-control:validateControlSecrets:calls", + "from": "function:mega-control:main", + "to": "function:mega-control:validateControlSecrets", + "kind": "calls" + }, { "id": "function:mega-control:main-\u003efunction:mega-control:writeJSON:calls", "from": "function:mega-control:main", "to": "function:mega-control:writeJSON", "kind": "calls" }, + { + "id": "function:mega-control:main-\u003epackage:context:calls_package", + "from": "function:mega-control:main", + "to": "package:context", + "kind": "calls_package", + "label": "Background" + }, + { + "id": "function:mega-control:main-\u003epackage:errors:calls_package", + "from": "function:mega-control:main", + "to": "package:errors", + "kind": "calls_package", + "label": "Is" + }, { "id": "function:mega-control:main-\u003epackage:log:calls_package", "from": "function:mega-control:main", "to": "package:log", "kind": "calls_package", - "label": "Printf" + "label": "Fatal" }, { "id": "function:mega-control:main-\u003epackage:net/http:calls_package", @@ -45352,6 +46025,13 @@ "kind": "calls_package", "label": "NewServeMux" }, + { + "id": "function:mega-control:main-\u003epackage:os/signal:calls_package", + "from": "function:mega-control:main", + "to": "package:os/signal", + "kind": "calls_package", + "label": "NotifyContext" + }, { "id": "function:mega-control:main-\u003epackage:os:calls_package", "from": "function:mega-control:main", @@ -45760,6 +46440,40 @@ "kind": "calls_package", "label": "NewReplacer" }, + { + "id": "function:mega-control:validateControlSecrets-\u003efunction:mega-control:server.check:calls", + "from": "function:mega-control:validateControlSecrets", + "to": "function:mega-control:server.check", + "kind": "calls" + }, + { + "id": "function:mega-control:validateControlSecrets-\u003epackage:errors:calls_package", + "from": "function:mega-control:validateControlSecrets", + "to": "package:errors", + "kind": "calls_package", + "label": "New" + }, + { + "id": "function:mega-control:validateControlSecrets-\u003epackage:fmt:calls_package", + "from": "function:mega-control:validateControlSecrets", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:mega-control:validateControlSecrets-\u003epackage:os:calls_package", + "from": "function:mega-control:validateControlSecrets", + "to": "package:os", + "kind": "calls_package", + "label": "Getenv" + }, + { + "id": "function:mega-control:validateControlSecrets-\u003epackage:strings:calls_package", + "from": "function:mega-control:validateControlSecrets", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:mega-control:writeJSON-\u003epackage:encoding/json:calls_package", "from": "function:mega-control:writeJSON", @@ -45935,6 +46649,12 @@ "to": "function:neuroforge/cmd/server:maxIntMain", "kind": "calls" }, + { + "id": "function:neuroforge/cmd/server:run-\u003efunction:neuroforge/cmd/server:validateManagedSecretsFromEnv:calls", + "from": "function:neuroforge/cmd/server:run", + "to": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", + "kind": "calls" + }, { "id": "function:neuroforge/cmd/server:run-\u003epackage:context:calls_package", "from": "function:neuroforge/cmd/server:run", @@ -46040,6 +46760,33 @@ "kind": "calls_package", "label": "Duration" }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecret-\u003epackage:fmt:calls_package", + "from": "function:neuroforge/cmd/server:validateManagedSecret", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecret-\u003epackage:strings:calls_package", + "from": "function:neuroforge/cmd/server:validateManagedSecret", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv-\u003efunction:neuroforge/cmd/server:validateManagedSecret:calls", + "from": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", + "to": "function:neuroforge/cmd/server:validateManagedSecret", + "kind": "calls" + }, + { + "id": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv-\u003epackage:os:calls_package", + "from": "function:neuroforge/cmd/server:validateManagedSecretsFromEnv", + "to": "package:os", + "kind": "calls_package", + "label": "Getenv" + }, { "id": "function:neuroforge/cmd/worker:claim-\u003epackage:bytes:calls_package", "from": "function:neuroforge/cmd/worker:claim", @@ -46176,6 +46923,13 @@ "kind": "calls_package", "label": "Getenv" }, + { + "id": "function:neuroforge/cmd/worker:main-\u003epackage:strings:calls_package", + "from": "function:neuroforge/cmd/worker:main", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:neuroforge/cmd/worker:main-\u003epackage:time:calls_package", "from": "function:neuroforge/cmd/worker:main", @@ -46329,6 +47083,46 @@ "kind": "calls_package", "label": "TrimSpace" }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003efunction:neuroforge/internal/brain:Engine.stagingConfig:calls", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "function:neuroforge/internal/brain:Engine.stagingConfig", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003efunction:neuroforge/internal/brain:New:calls", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "function:neuroforge/internal/brain:New", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:fmt:calls_package", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:io:calls_package", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "package:io", + "kind": "calls_package", + "label": "ReadAll" + }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:net/http:calls_package", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "package:net/http", + "kind": "calls_package", + "label": "NewRequestWithContext" + }, + { + "id": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher-\u003epackage:strings:calls_package", + "from": "function:neuroforge/internal/brain:Engine.CheckStagingPublisher", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:neuroforge/internal/brain:Engine.ClusterProposeMemory-\u003efunction:neuroforge/internal/brain:Engine.addMemory:calls", "from": "function:neuroforge/internal/brain:Engine.ClusterProposeMemory", @@ -46815,6 +47609,12 @@ "to": "function:neuroforge/internal/brain:Engine.addMemory", "kind": "calls" }, + { + "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.beginGoalCycle:calls", + "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", + "to": "function:neuroforge/internal/brain:Engine.beginGoalCycle", + "kind": "calls" + }, { "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.chatModelLimitOn:calls", "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", @@ -46827,6 +47627,12 @@ "to": "function:neuroforge/internal/brain:Engine.embed", "kind": "calls" }, + { + "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.endGoalCycle:calls", + "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", + "to": "function:neuroforge/internal/brain:Engine.endGoalCycle", + "kind": "calls" + }, { "id": "function:neuroforge/internal/brain:Engine.RunGoalCycle-\u003efunction:neuroforge/internal/brain:Engine.maybePublishGoalDraft:calls", "from": "function:neuroforge/internal/brain:Engine.RunGoalCycle", @@ -48894,6 +49700,27 @@ "to": "function:neuroforge/internal/httpapi:decode", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:fmt:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", + "to": "package:fmt", + "kind": "calls_package", + "label": "Errorf" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:os:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", + "to": "package:os", + "kind": "calls_package", + "label": "LookupEnv" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.adminPutSecrets-\u003epackage:strings:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.adminPutSecrets", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:neuroforge/internal/httpapi:Server.adminRebalance-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", "from": "function:neuroforge/internal/httpapi:Server.adminRebalance", @@ -49331,6 +50158,37 @@ "to": "function:neuroforge/internal/httpapi:Server.json", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:New:calls", + "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "to": "function:neuroforge/internal/httpapi:New", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", + "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "to": "function:neuroforge/internal/httpapi:Server.err", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:bearer:calls", + "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "to": "function:neuroforge/internal/httpapi:bearer", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003efunction:neuroforge/internal/httpapi:secureEqual:calls", + "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "to": "function:neuroforge/internal/httpapi:secureEqual", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.controlReadAuth-\u003epackage:net/http:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "to": "package:net/http", + "kind": "calls_package", + "label": "HandlerFunc" + }, { "id": "function:neuroforge/internal/httpapi:Server.err-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", "from": "function:neuroforge/internal/httpapi:Server.err", @@ -49367,6 +50225,13 @@ "to": "function:neuroforge/internal/httpapi:Server.json", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.goalCycle-\u003epackage:errors:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.goalCycle", + "to": "package:errors", + "kind": "calls_package", + "label": "Is" + }, { "id": "function:neuroforge/internal/httpapi:Server.goalPause-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", "from": "function:neuroforge/internal/httpapi:Server.goalPause", @@ -49590,6 +50455,37 @@ "to": "function:neuroforge/internal/httpapi:decode", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:New:calls", + "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "to": "function:neuroforge/internal/httpapi:New", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:Server.err:calls", + "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "to": "function:neuroforge/internal/httpapi:Server.err", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:bearer:calls", + "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "to": "function:neuroforge/internal/httpapi:bearer", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003efunction:neuroforge/internal/httpapi:secureEqual:calls", + "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "to": "function:neuroforge/internal/httpapi:secureEqual", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.integrationAuth-\u003epackage:net/http:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "to": "package:net/http", + "kind": "calls_package", + "label": "HandlerFunc" + }, { "id": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", "from": "function:neuroforge/internal/httpapi:Server.integrationBrainGraph", @@ -50124,6 +51020,19 @@ "to": "function:neuroforge/internal/httpapi:Server.json", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003efunction:neuroforge/internal/httpapi:checkConfiguredOllamaModels:calls", + "from": "function:neuroforge/internal/httpapi:Server.readyz", + "to": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003epackage:context:calls_package", + "from": "function:neuroforge/internal/httpapi:Server.readyz", + "to": "package:context", + "kind": "calls_package", + "label": "WithTimeout" + }, { "id": "function:neuroforge/internal/httpapi:Server.readyz-\u003epackage:strings:calls_package", "from": "function:neuroforge/internal/httpapi:Server.readyz", @@ -50193,6 +51102,18 @@ "to": "function:neuroforge/internal/httpapi:Server.clusterAuth", "kind": "calls" }, + { + "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.controlReadAuth:calls", + "from": "function:neuroforge/internal/httpapi:Server.routes", + "to": "function:neuroforge/internal/httpapi:Server.controlReadAuth", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.integrationAuth:calls", + "from": "function:neuroforge/internal/httpapi:Server.routes", + "to": "function:neuroforge/internal/httpapi:Server.integrationAuth", + "kind": "calls" + }, { "id": "function:neuroforge/internal/httpapi:Server.routes-\u003efunction:neuroforge/internal/httpapi:Server.json:calls", "from": "function:neuroforge/internal/httpapi:Server.routes", @@ -50412,6 +51333,54 @@ "kind": "calls_package", "label": "HasPrefix" }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003efunction:neuroforge/internal/httpapi:configuredModelAvailable:calls", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "function:neuroforge/internal/httpapi:configuredModelAvailable", + "kind": "calls" + }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:encoding/json:calls_package", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "package:encoding/json", + "kind": "calls_package", + "label": "NewDecoder" + }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:fmt:calls_package", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "package:fmt", + "kind": "calls_package", + "label": "Sprintf" + }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:io:calls_package", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "package:io", + "kind": "calls_package", + "label": "LimitReader" + }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:net/http:calls_package", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "package:net/http", + "kind": "calls_package", + "label": "NewRequestWithContext" + }, + { + "id": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels-\u003epackage:strings:calls_package", + "from": "function:neuroforge/internal/httpapi:checkConfiguredOllamaModels", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, + { + "id": "function:neuroforge/internal/httpapi:configuredModelAvailable-\u003epackage:strings:calls_package", + "from": "function:neuroforge/internal/httpapi:configuredModelAvailable", + "to": "package:strings", + "kind": "calls_package", + "label": "TrimSpace" + }, { "id": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot-\u003epackage:runtime:calls_package", "from": "function:neuroforge/internal/httpapi:currentRuntimeSnapshot", @@ -58027,6 +58996,12 @@ "to": "route:GET /api/health", "kind": "defines_route" }, + { + "id": "package:kb-editor/cmd/server-\u003eroute:GET /api/integrations/staging/health:defines_route", + "from": "package:kb-editor/cmd/server", + "to": "route:GET /api/integrations/staging/health", + "kind": "defines_route" + }, { "id": "package:kb-editor/cmd/server-\u003eroute:GET /api/items/{key}:defines_route", "from": "package:kb-editor/cmd/server", @@ -59395,6 +60370,12 @@ "to": "function:kb-editor/cmd/server:app.handleHealth", "kind": "handles" }, + { + "id": "route:GET /api/integrations/staging/health-\u003efunction:kb-editor/cmd/server:app.handleIntegrationStagingHealth:handles", + "from": "route:GET /api/integrations/staging/health", + "to": "function:kb-editor/cmd/server:app.handleIntegrationStagingHealth", + "kind": "handles" + }, { "id": "route:GET /api/items-\u003efunction:kb-editor/cmd/server:app.handleList:handles", "from": "route:GET /api/items", @@ -60019,42 +61000,12 @@ "to": "service:agent-data-init", "kind": "depends_on" }, - { - "id": "service:agent-\u003eservice:neuroforge:depends_on", - "from": "service:agent", - "to": "service:neuroforge", - "kind": "depends_on" - }, { "id": "service:agent-\u003eservice:ollama:depends_on", "from": "service:agent", "to": "service:ollama", "kind": "depends_on" }, - { - "id": "service:control-\u003eservice:agent:depends_on", - "from": "service:control", - "to": "service:agent", - "kind": "depends_on" - }, - { - "id": "service:control-\u003eservice:knowledge:depends_on", - "from": "service:control", - "to": "service:knowledge", - "kind": "depends_on" - }, - { - "id": "service:control-\u003eservice:neuroforge:depends_on", - "from": "service:control", - "to": "service:neuroforge", - "kind": "depends_on" - }, - { - "id": "service:knowledge-\u003eservice:neuroforge:depends_on", - "from": "service:knowledge", - "to": "service:neuroforge", - "kind": "depends_on" - }, { "id": "service:knowledge-\u003eservice:ollama:depends_on", "from": "service:knowledge", @@ -60075,10 +61026,10 @@ } ], "meta": { - "edges": 6525, + "edges": 6633, "format_version": 1, "generator": "go-ast+compose", "modules": 4, - "nodes": 1669 + "nodes": 1689 } } diff --git a/services/control/main.go b/services/control/main.go index 10ab3e2..4b1dc00 100644 --- a/services/control/main.go +++ b/services/control/main.go @@ -2,14 +2,18 @@ package main import ( "context" + "crypto/subtle" "embed" "encoding/json" + "errors" "fmt" "io" "log" "net/http" "os" + "os/signal" "strings" + "syscall" "time" ) @@ -70,6 +74,11 @@ func env(k, d string) string { } func main() { + if err := validateControlSecrets(); err != nil { + log.Fatal(err) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() agentURL := env("AGENT_URL", "http://agent:8080") nfURL := env("NEUROFORGE_URL", "http://neuroforge:8080") nfKeyRaw := strings.TrimSpace(os.Getenv("NEUROFORGE_API_KEY")) @@ -109,9 +118,84 @@ func main() { _, _ = w.Write(b) }) addr := env("CONTROL_ADDR", ":8070") - srv := &http.Server{Addr: addr, Handler: secure(mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} + srv := &http.Server{Addr: addr, Handler: secure(controlBasicAuth(mux)), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} log.Printf("mega control listening on %s", addr) - log.Fatal(srv.ListenAndServe()) + errCh := make(chan error, 1) + go func() { + err := srv.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errCh <- err + }() + select { + case err := <-errCh: + if err != nil { + log.Printf("control HTTP server stopped unexpectedly: %v", err) + } + case <-ctx.Done(): + log.Printf("control shutdown requested") + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("control graceful shutdown failed: %v", err) + _ = srv.Close() + } +} + +func validateControlSecrets() error { + check := func(name string, min int) error { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return nil + } + u := strings.ToUpper(v) + if strings.Contains(u, "CHANGE_ME") || strings.Contains(u, "CHANGEME") || strings.Contains(u, "PLACEHOLDER") { + return fmt.Errorf("%s still contains a placeholder", name) + } + if len(v) < min { + return fmt.Errorf("%s must be at least %d characters", name, min) + } + return nil + } + if err := check("NEUROFORGE_API_KEY", 24); err != nil { + return err + } + if err := check("CONTROL_READ_TOKEN", 24); err != nil { + return err + } + if err := check("CONTROL_BASIC_AUTH_PASSWORD", 12); err != nil { + return err + } + u, p := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER")), strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD")) + if (u == "") != (p == "") { + return errors.New("CONTROL_BASIC_AUTH_USER and CONTROL_BASIC_AUTH_PASSWORD must both be set or both be empty") + } + return nil +} + +func controlBasicAuth(next http.Handler) http.Handler { + user := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_USER")) + pass := strings.TrimSpace(os.Getenv("CONTROL_BASIC_AUTH_PASSWORD")) + if user == "" && pass == "" { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/healthz" { + next.ServeHTTP(w, r) + return + } + u, p, ok := r.BasicAuth() + userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1 + passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1 + if !ok || !userOK || !passOK { + w.Header().Set("WWW-Authenticate", `Basic realm="NeuroForge Control", charset="UTF-8"`) + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) } func secure(next http.Handler) http.Handler { diff --git a/services/control/security_test.go b/services/control/security_test.go new file mode 100644 index 0000000..0928e63 --- /dev/null +++ b/services/control/security_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestControlBasicAuthProtectsOperationsButNotHealth(t *testing.T) { + t.Setenv("CONTROL_BASIC_AUTH_USER", "operator") + t.Setenv("CONTROL_BASIC_AUTH_PASSWORD", "control-password-123456") + h := controlBasicAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + + health := httptest.NewRecorder() + h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if health.Code != http.StatusOK { + t.Fatalf("health status=%d", health.Code) + } + + unauth := httptest.NewRecorder() + h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/status", nil)) + if unauth.Code != http.StatusUnauthorized { + t.Fatalf("unauth status=%d", unauth.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/api/status", nil) + req.SetBasicAuth("operator", "control-password-123456") + auth := httptest.NewRecorder() + h.ServeHTTP(auth, req) + if auth.Code != http.StatusOK { + t.Fatalf("auth status=%d", auth.Code) + } +} + +func TestControlSecretValidationRejectsPlaceholder(t *testing.T) { + t.Setenv("NEUROFORGE_API_KEY", "valid-neuroforge-read-token-123456") + t.Setenv("CONTROL_READ_TOKEN", "valid-agent-control-token-123456") + t.Setenv("CONTROL_BASIC_AUTH_USER", "operator") + t.Setenv("CONTROL_BASIC_AUTH_PASSWORD", "CHANGE_ME_CONTROL_PASSWORD") + if err := validateControlSecrets(); err == nil { + t.Fatal("placeholder password accepted") + } +} diff --git a/services/knowledge/Dockerfile b/services/knowledge/Dockerfile index 25f9614..e10cd08 100644 --- a/services/knowledge/Dockerfile +++ b/services/knowledge/Dockerfile @@ -3,7 +3,9 @@ WORKDIR /src COPY go.mod ./ COPY cmd ./cmd COPY internal ./internal -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/kb-helpdesk ./cmd/server +RUN go test ./... && \ + go vet ./... && \ + CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/kb-helpdesk ./cmd/server FROM alpine:3.24 RUN apk add --no-cache ca-certificates tzdata diff --git a/services/knowledge/cmd/server/app.go b/services/knowledge/cmd/server/app.go index 84ba9d8..a701020 100644 --- a/services/knowledge/cmd/server/app.go +++ b/services/knowledge/cmd/server/app.go @@ -9,6 +9,7 @@ import ( "io" "io/fs" "net/http" + "net/url" "os" "strconv" "strings" @@ -72,6 +73,7 @@ func (a *app) routes() http.Handler { mux.HandleFunc("GET /api/staging", a.handleStagingList) mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet) mux.HandleFunc("POST /api/integrations/staging", a.handleIntegrationStaging) + mux.HandleFunc("GET /api/integrations/staging/health", a.handleIntegrationStagingHealth) if a.config.Writable { mux.HandleFunc("PUT /api/items/{key}", a.handlePut) @@ -93,7 +95,30 @@ func (a *app) routes() http.Handler { static := http.FileServer(http.FS(a.web)) mux.Handle("GET /", static) - return securityHeaders(mux) + return securityHeaders(browserWriteSameOrigin(mux)) +} + +func browserWriteSameOrigin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions || r.URL.Path == "/api/integrations/staging" { + next.ServeHTTP(w, r) + return + } + fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site"))) + if fetchSite != "" && fetchSite != "same-origin" && fetchSite != "none" { + writeError(w, http.StatusForbidden, "cross-origin browser write blocked") + return + } + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin != "" { + u, err := url.Parse(origin) + if err != nil || !strings.EqualFold(u.Host, r.Host) { + writeError(w, http.StatusForbidden, "cross-origin browser write blocked") + return + } + } + next.ServeHTTP(w, r) + }) } func securityHeaders(next http.Handler) http.Handler { @@ -265,6 +290,19 @@ func integrationBearerAuthorized(r *http.Request) (bool, bool) { return true, subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 } +func (a *app) handleIntegrationStagingHealth(w http.ResponseWriter, r *http.Request) { + enabled, authorized := integrationBearerAuthorized(r) + if !enabled || a.staging == nil { + writeError(w, http.StatusServiceUnavailable, "KB staging integration is disabled") + return + } + if !authorized { + writeError(w, http.StatusUnauthorized, "invalid integration token") + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "staging": true, "writable": a.config.Writable}) +} + // handleIntegrationStaging is a one-way governance boundary: machine-generated // research may enter human review, but it cannot write production knowledge or // enable automatic replies. @@ -485,7 +523,11 @@ func (a *app) promoteStaging(key string) (map[string]any, error) { } archive, err := a.staging.ArchiveApproved(key) if err != nil { - return nil, fmt.Errorf("Produktivdatei wurde erstellt (%s), aber Staging konnte nicht als freigegeben archiviert werden: %w", summary.RelPath, err) + rollbackErr := a.store.RollbackImported(summary) + if rollbackErr != nil { + return nil, fmt.Errorf("staging archive failed after production import (%s); rollback also failed: archive=%v rollback=%v", summary.RelPath, err, rollbackErr) + } + return nil, fmt.Errorf("staging archive failed; production import %s was rolled back: %w", summary.RelPath, err) } return map[string]any{"ok": true, "production": summary, "staging_key": key, "staging_archive": archive}, nil } diff --git a/services/knowledge/cmd/server/app_test.go b/services/knowledge/cmd/server/app_test.go index 7c801c0..0b33453 100644 --- a/services/knowledge/cmd/server/app_test.go +++ b/services/knowledge/cmd/server/app_test.go @@ -459,3 +459,93 @@ func TestIntegrationDraftWithStableKeyUpdatesInsteadOfDuplicating(t *testing.T) t.Fatalf("draft not refreshed: %#v", got.Document) } } + +func TestBrowserWriteSameOriginGuardRejectsCrossSiteWrite(t *testing.T) { + s, err := store.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + web, err := fs.Sub(webFS, "web") + if err != nil { + t.Fatal(err) + } + h := newApp(s, web, appConfig{Mode: "editor", Writable: true}).routes() + req := httptest.NewRequest(http.MethodPost, "/api/bulk", bytes.NewBufferString(`{"keys":[],"dry_run":true}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.invalid") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } +} + +func TestPromotionRollsBackProductionWhenStagingArchiveFails(t *testing.T) { + knowledgeDir := t.TempDir() + stagingDir := t.TempDir() + t.Setenv("BACKUP_DIR", filepath.Join(t.TempDir(), "backups")) + s, err := store.New(knowledgeDir) + if err != nil { + t.Fatal(err) + } + st, err := staging.New(stagingDir) + if err != nil { + t.Fatal(err) + } + draft, err := st.Save("rollback", "test", staging.Draft{Title: "Rollback", Text: "Symptom", Answer: "Lösung"}, false, .8) + if err != nil { + t.Fatal(err) + } + // Force ArchiveApproved to fail after ImportDocument by occupying the archive + // directory path with a regular file. + if err := os.WriteFile(filepath.Join(stagingDir, ".approved"), []byte("block"), 0o644); err != nil { + t.Fatal(err) + } + web, err := fs.Sub(webFS, "web") + if err != nil { + t.Fatal(err) + } + a := newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st) + if _, err := a.promoteStaging(draft.Key); err == nil || !strings.Contains(err.Error(), "rolled back") { + t.Fatalf("promotion error=%v", err) + } + if s.Count() != 0 { + t.Fatalf("production count=%d, want rollback to zero", s.Count()) + } + if _, err := st.Get(draft.Key); err != nil { + t.Fatalf("staging draft should remain for retry: %v", err) + } +} + +func TestIntegrationStagingHealthBypassesUIBasicAuthButRequiresBearer(t *testing.T) { + t.Setenv("BASIC_AUTH_USER", "editor") + t.Setenv("BASIC_AUTH_PASSWORD", "knowledge-password-123456") + t.Setenv("KB_INTEGRATION_TOKEN", "integration-token-12345678901234567890") + s, err := store.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + st, err := staging.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + web, err := fs.Sub(webFS, "web") + if err != nil { + t.Fatal(err) + } + h := optionalBasicAuth(newApp(s, web, appConfig{Mode: "editor", Writable: true}).withStaging(st).routes()) + + unauth := httptest.NewRecorder() + h.ServeHTTP(unauth, httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil)) + if unauth.Code != http.StatusUnauthorized || strings.Contains(unauth.Body.String(), "authentication required") { + t.Fatalf("request should reach bearer guard, status=%d body=%s", unauth.Code, unauth.Body.String()) + } + req := httptest.NewRequest(http.MethodGet, "/api/integrations/staging/health", nil) + req.Header.Set("Authorization", "Bearer integration-token-12345678901234567890") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } +} diff --git a/services/knowledge/cmd/server/main.go b/services/knowledge/cmd/server/main.go index 6ba204a..c2b3233 100644 --- a/services/knowledge/cmd/server/main.go +++ b/services/knowledge/cmd/server/main.go @@ -1,17 +1,21 @@ package main import ( + "context" "crypto/subtle" "embed" + "errors" "flag" "fmt" "io/fs" "log" "net/http" "os" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" "time" "kb-editor/internal/aifallback" @@ -29,6 +33,12 @@ func main() { flag.StringVar(&listen, "listen", envOr("LISTEN_ADDR", ":8080"), "HTTP listen address") flag.Parse() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := validateRuntimeSecrets(); err != nil { + log.Fatal(err) + } + cfg, staticDir, err := configFromEnv() if err != nil { log.Fatal(err) @@ -58,7 +68,7 @@ func main() { log.Fatal(err) } if reloadInterval > 0 { - go startAutoReload(s, reloadInterval) + go startAutoReload(ctx, s, reloadInterval) } sub, err := fs.Sub(webFS, staticDir) @@ -95,9 +105,58 @@ func main() { if u := os.Getenv("BASIC_AUTH_USER"); u != "" { log.Printf("Basic authentication enabled for user %q", u) } - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatal(err) + errCh := make(chan error, 1) + go func() { + err := srv.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + errCh <- err + }() + select { + case err := <-errCh: + if err != nil { + log.Printf("KB HTTP server stopped unexpectedly: %v", err) + } + case <-ctx.Done(): + log.Printf("KB shutdown requested") } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("KB graceful shutdown failed: %v", err) + _ = srv.Close() + } +} + +func validateRuntimeSecrets() error { + check := func(name string, min int) error { + v := strings.TrimSpace(os.Getenv(name)) + if v == "" { + return nil + } + upper := strings.ToUpper(v) + if strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "CHANGEME") || strings.Contains(upper, "PLACEHOLDER") { + return fmt.Errorf("%s still contains a placeholder", name) + } + if len(v) < min { + return fmt.Errorf("%s must be at least %d characters", name, min) + } + return nil + } + for _, item := range []struct { + name string + min int + }{ + {"KB_INTEGRATION_TOKEN", 24}, + {"BASIC_AUTH_PASSWORD", 12}, + {"BRAIN_ACTIVITY_API_KEY", 24}, + } { + if err := check(item.name, item.min); err != nil { + return err + } + } + return nil } func configFromEnv() (appConfig, string, error) { @@ -143,12 +202,17 @@ func autoReloadInterval(mode string) (time.Duration, error) { return d, nil } -func startAutoReload(s *store.Store, interval time.Duration) { +func startAutoReload(ctx context.Context, s *store.Store, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() - for range ticker.C { - if err := s.Reload(); err != nil { - log.Printf("automatic index reload failed: %v", err) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.Reload(); err != nil { + log.Printf("automatic index reload failed: %v", err) + } } } } @@ -254,7 +318,7 @@ func optionalBasicAuth(next http.Handler) http.Handler { log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty") } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if (r.Method == http.MethodGet && r.URL.Path == "/api/health") || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") { + if (r.Method == http.MethodGet && (r.URL.Path == "/api/health" || r.URL.Path == "/api/integrations/staging/health")) || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") { next.ServeHTTP(w, r) return } diff --git a/services/knowledge/internal/staging/staging.go b/services/knowledge/internal/staging/staging.go index 869ef82..b42f179 100644 --- a/services/knowledge/internal/staging/staging.go +++ b/services/knowledge/internal/staging/staging.go @@ -377,6 +377,24 @@ func (s *Store) archive(key, bucket string) (string, error) { if err := os.Rename(path, dst); err != nil { return "", fmt.Errorf("move staging file to %s: %w", bucket, err) } + rollbackRename := func(cause error) error { + if err := os.Rename(dst, path); err != nil { + return fmt.Errorf("%v; staging archive rollback failed: %w", cause, err) + } + if err := syncDir(s.dir); err != nil { + return fmt.Errorf("%v; staging rollback source sync failed: %w", cause, err) + } + if err := syncDir(archiveDir); err != nil { + return fmt.Errorf("%v; staging rollback archive sync failed: %w", cause, err) + } + return cause + } + if err := syncDir(s.dir); err != nil { + return "", rollbackRename(fmt.Errorf("sync staging directory: %w", err)) + } + if err := syncDir(archiveDir); err != nil { + return "", rollbackRename(fmt.Errorf("sync staging archive: %w", err)) + } return dst, nil } @@ -433,7 +451,16 @@ func atomicWrite(path string, payload []byte, mode os.FileMode) error { if err := os.Rename(tmpName, path); err != nil { return err } - return nil + return syncDir(filepath.Dir(path)) +} + +func syncDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() } func summarize(result Result) Summary { diff --git a/services/knowledge/internal/store/store.go b/services/knowledge/internal/store/store.go index abaa16f..3cbc654 100644 --- a/services/knowledge/internal/store/store.go +++ b/services/knowledge/internal/store/store.go @@ -686,6 +686,10 @@ func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summar _ = os.Remove(tmpName) return Summary{}, err } + if err := syncDir(s.dataDir); err != nil { + _ = os.Remove(path) + return Summary{}, err + } rec, err := s.readRecord(path) if err != nil { _ = os.Remove(path) @@ -697,6 +701,47 @@ func (s *Store) ImportDocument(doc map[string]any, preferredBase string) (Summar return summarize(rec), nil } +// RollbackImported removes exactly the production record created by ImportDocument. +// The checksum guard prevents rollback from deleting a file that was changed after +// import. It is used to keep staging promotion transactional. +func (s *Store) RollbackImported(summary Summary) error { + s.mu.Lock() + defer s.mu.Unlock() + rec, ok := s.records[summary.Key] + if !ok { + return os.ErrNotExist + } + if summary.Checksum == "" || rec.Checksum != summary.Checksum { + return fmt.Errorf("refusing rollback: production record %s changed after import", summary.RelPath) + } + if err := os.Remove(rec.Path); err != nil { + return err + } + // The namespace mutation already happened once Remove succeeded. Reflect it + // in memory even when the durability fsync below reports an I/O error; + // otherwise this process would serve a record whose file no longer exists. + delete(s.records, summary.Key) + for i, key := range s.order { + if key == summary.Key { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } + if err := syncDir(s.dataDir); err != nil { + return fmt.Errorf("sync production directory after rollback: %w", err) + } + return nil +} + +func syncDir(dir string) error { + f, err := os.Open(dir) + if err != nil { + return err + } + defer f.Close() + return f.Sync() +} + func safeFilenameBase(value string) string { value = strings.TrimSpace(value) if value == "" { @@ -947,6 +992,9 @@ func (s *Store) writeRecord(rec *record, doc map[string]any) (*record, error) { _ = os.Remove(tmpName) return nil, err } + if err := syncDir(filepath.Dir(rec.Path)); err != nil { + return nil, err + } return s.readRecord(rec.Path) }