This commit is contained in:
22
.env.example
22
.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
|
||||
###############################################################################
|
||||
|
||||
@@ -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
|
||||
|
||||
17
Makefile
17
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
|
||||
|
||||
13
README.md
13
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.
|
||||
|
||||
|
||||
52
RELEASE-NOTES-v1.5.0.md
Normal file
52
RELEASE-NOTES-v1.5.0.md
Normal file
@@ -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.
|
||||
30
docker-compose.dev.yml
Normal file
30
docker-compose.dev.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
23
docs/BACKUP-RESTORE-v1.5.0.md
Normal file
23
docs/BACKUP-RESTORE-v1.5.0.md
Normal file
@@ -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`).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
27
docs/GO-LIVE-v1.5.0.md
Normal file
27
docs/GO-LIVE-v1.5.0.md
Normal file
@@ -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.
|
||||
45
docs/MIGRATION-v1.4.5-to-v1.5.0.md
Normal file
45
docs/MIGRATION-v1.4.5-to-v1.5.0.md
Normal file
@@ -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=<unique 24+ char secret>
|
||||
NEUROFORGE_CONTROL_READ_TOKEN=<unique 24+ char secret>
|
||||
CONTROL_BASIC_AUTH_USER=admin
|
||||
CONTROL_BASIC_AUTH_PASSWORD=<unique 12+ char 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.
|
||||
34
docs/VALIDATION-v1.5.0.md
Normal file
34
docs/VALIDATION-v1.5.0.md
Normal file
@@ -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.
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
7416
patches/v1.4.5-to-v1.5.0.diff
Normal file
7416
patches/v1.4.5-to-v1.5.0.diff
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
33
scripts/backup-data.sh
Normal file
33
scripts/backup-data.sh
Normal file
@@ -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"
|
||||
60
scripts/check-compose-env.py
Normal file
60
scripts/check-compose-env.py
Normal file
@@ -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)")
|
||||
@@ -7,6 +7,8 @@ cat <<OUT
|
||||
# Mega service tokens
|
||||
NEUROFORGE_ADMIN_TOKEN=$(gen)
|
||||
NEUROFORGE_APP_API_KEY=$(gen)
|
||||
NEUROFORGE_INTEGRATION_TOKEN=$(gen)
|
||||
NEUROFORGE_CONTROL_READ_TOKEN=$(gen)
|
||||
NEUROFORGE_WORKER_TOKEN=$(gen)
|
||||
NEUROFORGE_METRICS_TOKEN=$(gen)
|
||||
KB_INTEGRATION_TOKEN=$(gen)
|
||||
@@ -16,5 +18,6 @@ SEARXNG_SECRET=$(gen)
|
||||
# Web/UI and optional webhook secrets
|
||||
WEB_PASSWORD=$(gen)
|
||||
BASIC_AUTH_PASSWORD=$(gen)
|
||||
CONTROL_BASIC_AUTH_PASSWORD=$(gen)
|
||||
WEBHOOK_SECRET=$(gen)
|
||||
OUT
|
||||
|
||||
13
scripts/go-live.sh
Normal file
13
scripts/go-live.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
ENV_FILE=${ENV_FILE:-.env}
|
||||
./scripts/preflight.sh
|
||||
echo "go-live: pulling immutable registry images"
|
||||
docker compose --env-file "$ENV_FILE" pull
|
||||
echo "go-live: starting stack"
|
||||
docker compose --env-file "$ENV_FILE" up -d --remove-orphans
|
||||
echo "go-live: current container state"
|
||||
docker compose --env-file "$ENV_FILE" ps
|
||||
cat <<'MSG'
|
||||
go-live: container start completed. Verify the host-specific smoke gates from docs/GO-LIVE-v1.5.0.md before enabling write automation.
|
||||
MSG
|
||||
90
scripts/preflight.sh
Normal file
90
scripts/preflight.sh
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ENV_FILE=${ENV_FILE:-.env}
|
||||
STATIC_ONLY=false
|
||||
[ "${1:-}" = "--static" ] && STATIC_ONLY=true
|
||||
|
||||
fail() { echo "preflight: ERROR: $*" >&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"
|
||||
23
scripts/release-gate.sh
Normal file
23
scripts/release-gate.sh
Normal file
@@ -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"
|
||||
@@ -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.'
|
||||
|
||||
42
scripts/restore-data.sh
Normal file
42
scripts/restore-data.sh
Normal file
@@ -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"
|
||||
60
scripts/secret-scan.sh
Normal file
60
scripts/secret-scan.sh
Normal file
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
43
services/control/security_test.go
Normal file
43
services/control/security_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user