update
mega-ci / static-release-gates (push) Failing after 11s
release-tag / release-image (push) Successful in 6m46s
mega-ci / go-quality (services/control) (push) Successful in 10m7s
mega-ci / go-quality (platform/neuroforge) (push) Successful in 10m20s
mega-ci / go-quality (services/agent) (push) Successful in 11m1s
mega-ci / go-quality (services/knowledge) (push) Successful in 11m13s
mega-ci / docker-build (push) Has been skipped

This commit is contained in:
2026-09-09 11:10:31 +02:00
parent 9a4370e4df
commit e0bf42bf32
85 changed files with 8035 additions and 1138 deletions
+46 -2
View File
@@ -1,5 +1,5 @@
###############################################################################
# GLPI NEUROFORGE MEGA v1.5.9 - VOLLSTÄNDIGE .ENV.example
# GLPI NEUROFORGE MEGA v1.6.0 - VOLLSTÄNDIGE .ENV.example
#
# Diese Datei ist die zentrale Konfiguration für docker compose.
# Sie enthält:
@@ -29,7 +29,7 @@
# 01. MEGA STACK - RELEASE / HOST PORTS / PFADE
###############################################################################
# Immutable Registry-Tag der sechs Projekt-Images. "latest" ist produktiv verboten.
IMAGE_TAG=1.5.9
IMAGE_TAG=1.6.2
CONTROL_HOST_PORT=8070
AGENT_HOST_PORT=8080
@@ -193,6 +193,50 @@ NEUROFORGE_KB_STAGING_MAX_ARTICLE_CHARS=10000
NEUROFORGE_KB_STAGING_MIN_ANSWER_CHARS=160
NEUROFORGE_KB_STAGING_MAX_ANSWER_CHARS=1200
###############################################################################
# 07B. MASTER / SUBAGENTS / DURABLE ORCHESTRATOR / KNOWLEDGE GRAPH
###############################################################################
# Der NeuroForge-Master hält den autoritativen Zustand. Subagents ziehen nur
# capability-passende Jobs und arbeiten unter lease/heartbeat fencing.
NEUROFORGE_WORKER_LEASE_SECONDS=120
NEUROFORGE_WORKER_HEARTBEAT_SECONDS=15
NEUROFORGE_WORKER_STALE_AFTER_SECONDS=60
NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS=3
NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS=15
NEUROFORGE_WORKER_MAX_QUEUED_JOBS=5000
NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB=128
# Worker-Ergebnisse, die Master-State verändern (z.B. vector.relink), werden
# zweiphasig persistiert und bei Master-Neustart idempotent weiter angewendet.
NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS=5
NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS=5
NEUROFORGE_WORKER_JOB_RETENTION_HOURS=24
NEUROFORGE_WORKER_MAX_TERMINAL_JOBS=2000
# Lokale Compose-Subagents. Zusätzliche Remote-Subagents können mit
# docker-compose.subagent.yml auf anderen Hosts gestartet werden.
NEUROFORGE_CPU_WORKER_CONCURRENCY=2
NEUROFORGE_GPU_WORKER_CONCURRENCY=1
NEUROFORGE_WORKER_HEARTBEAT_INTERVAL=15s
# Bestehende/importierte knowledge.chunk-Memories werden kontrolliert zu einem
# n:m-Graphen verlinkt. Kein O(N²)-Queue-Burst: ANN-Kandidaten + bounded queue.
NEUROFORGE_GRAPH_BACKFILL_ENABLED=true
NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS=10
NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE=16
NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED=64
NEUROFORGE_GRAPH_BACKFILL_MIN_DEGREE=3
NEUROFORGE_GRAPH_CANDIDATE_MULTIPLIER=6
NEUROFORGE_GRAPH_RETRY_AFTER_MINUTES=360
NEUROFORGE_GRAPH_REQUIRE_WORKER=true
# Retrieval kann echte mehrstufige Assoziationsketten verfolgen.
NEUROFORGE_GRAPH_MAX_HOPS=3
NEUROFORGE_GRAPH_HOP_DECAY=0.60
NEUROFORGE_GRAPH_MAX_EXPANSION=64
NEUROFORGE_GRAPH_MIN_EDGE_WEIGHT=0.05
# LLM/Embedding-Aufgaben werden bevorzugt an GPU-Subagents delegiert. Bei nicht
# verfügbarem GPU-Subagent fällt der Master auf seinen Provider-Router zurück.
NEUROFORGE_OFFLOAD_CHAT=true
NEUROFORGE_OFFLOAD_EMBEDDINGS=true
NEUROFORGE_DISTRIBUTED_INFERENCE_WAIT_SECONDS=180
###############################################################################
# 08. OPTIONAL CODEBASE MEMORY MCP / ENGINEERING UI
###############################################################################
+20
View File
@@ -0,0 +1,20 @@
# Remote NeuroForge subagent host (v1.6.1)
IMAGE_TAG=1.6.1
NEUROFORGE_MASTER_URL=https://neuroforge.internal.example
NEUROFORGE_WORKER_TOKEN=CHANGE_ME_WORKER_TOKEN_SHARED_WITH_MASTER
NEUROFORGE_WORKER_HEARTBEAT_INTERVAL=15s
# CPU profile
NEUROFORGE_CPU_WORKER_ID=cpu-node-01
NEUROFORGE_CPU_WORKER_CAPABILITIES=cpu,vector.relink
NEUROFORGE_CPU_WORKER_CONCURRENCY=2
# GPU profile
NEUROFORGE_GPU_WORKER_ID=gpu-node-01
NEUROFORGE_GPU_WORKER_CAPABILITIES=gpu,model.chat,model.embed
NEUROFORGE_GPU_WORKER_CONCURRENCY=1
NEUROFORGE_WORKER_OLLAMA_URL=http://host.docker.internal:11434
OLLAMA_MODEL=gemma4
OLLAMA_EMBEDDING_MODEL=embeddinggemma
NEUROFORGE_OLLAMA_NUM_CTX=8192
OLLAMA_KEEP_ALIVE=10m
+86
View File
@@ -0,0 +1,86 @@
name: mega-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
go-quality:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
module:
- platform/neuroforge
- services/agent
- services/knowledge
- services/control
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: ${{ matrix.module }}/go.mod
cache-dependency-path: ${{ matrix.module }}/go.mod
- name: Test
working-directory: ${{ matrix.module }}
run: go test ./...
- name: Vet
working-directory: ${{ matrix.module }}
run: go vet ./...
- name: Build
working-directory: ${{ matrix.module }}
run: go build ./...
static-release-gates:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install PyYAML
run: python3 -m pip install --user PyYAML
- name: Static production preflight
run: ./scripts/preflight.sh --static
- name: Compose environment isolation
run: ./scripts/check-compose-env.py
- name: Secret scan
run: ./scripts/secret-scan.sh
- name: YAML syntax check
shell: bash
run: |
python3 - <<'PY'
from pathlib import Path
import yaml
files = list(Path('.').rglob('*.yml')) + list(Path('.').rglob('*.yaml'))
for p in files:
if '.git' in p.parts:
continue
with p.open(encoding='utf-8') as f:
yaml.safe_load(f)
print(f'parsed {len(files)} YAML files')
PY
docker-build:
runs-on: ubuntu-latest
needs: [go-quality, static-release-gates]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build all project images
run: docker buildx bake --set '*.output=type=cacheonly'
+145
View File
@@ -0,0 +1,145 @@
name: mega-release
on:
push:
tags:
- 'v*'
jobs:
release-images:
runs-on: ubuntu-latest
env:
REGISTRY: git.send.nrw/sendnrw
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate immutable release version
id: meta
shell: bash
run: |
set -euo pipefail
REF_NAME="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
VERSION="${REF_NAME#v}"
test -n "$VERSION"
FILE_VERSION="$(tr -d '\r\n' < VERSION)"
test "$VERSION" = "$FILE_VERSION" || {
echo "Tag version $VERSION does not match VERSION=$FILE_VERSION" >&2
exit 1
}
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."git.send.nrw"]
http = true
insecure = true
- name: Login to registry
uses: docker/login-action@v3
with:
registry: git.send.nrw
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push NeuroForge server
uses: docker/build-push-action@v6
with:
context: ./platform/neuroforge
file: ./platform/neuroforge/Dockerfile
target: server
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-neuroforge:${{ steps.meta.outputs.version }}
- name: Push NeuroForge worker
uses: docker/build-push-action@v6
with:
context: ./platform/neuroforge
file: ./platform/neuroforge/Dockerfile
target: worker
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-neuroforge-worker:${{ steps.meta.outputs.version }}
- name: Push Agent
uses: docker/build-push-action@v6
with:
context: ./services/agent
file: ./services/agent/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-agent:${{ steps.meta.outputs.version }}
- name: Push Agent data-init
uses: docker/build-push-action@v6
with:
context: ./services/agent
file: ./services/agent/Dockerfile
target: data-init
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-agent-data-init:${{ steps.meta.outputs.version }}
- name: Push Knowledge
uses: docker/build-push-action@v6
with:
context: ./services/knowledge
file: ./services/knowledge/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-knowledge:${{ steps.meta.outputs.version }}
- name: Push Control
uses: docker/build-push-action@v6
with:
context: ./services/control
file: ./services/control/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/glpi-neuroforge-mega-control:${{ steps.meta.outputs.version }}
release-archive:
needs: release-images
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate and package
shell: bash
run: |
set -euo pipefail
REF_NAME="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
VERSION="${REF_NAME#v}"
test "$VERSION" = "$(tr -d '\r\n' < VERSION)"
./scripts/secret-scan.sh
find . -type f ! -path './.git/*' ! -name MANIFEST.sha256 -print0 \
| sort -z \
| xargs -0 sha256sum > MANIFEST.sha256
ROOT_DIR="$(pwd)"
PKG_DIR="../glpi-neuroforge-mega-v${VERSION}-gitea"
rm -rf "$PKG_DIR"
mkdir -p "$PKG_DIR"
tar --exclude=.git -cf - . | (cd "$PKG_DIR" && tar -xf -)
cd ..
zip -qr "glpi-neuroforge-mega-v${VERSION}-gitea.zip" "glpi-neuroforge-mega-v${VERSION}-gitea"
sha256sum "glpi-neuroforge-mega-v${VERSION}-gitea.zip" > "glpi-neuroforge-mega-v${VERSION}-gitea.zip.sha256"
- name: Upload release archive
uses: actions/upload-artifact@v4
with:
name: glpi-neuroforge-mega-${{ gitea.ref_name }}
path: |
../glpi-neuroforge-mega-*.zip
../glpi-neuroforge-mega-*.zip.sha256
+54
View File
@@ -0,0 +1,54 @@
# Build & Release — GLPI NeuroForge Mega v1.6.2
The repository has one canonical image/release pipeline. Nested service workflows are intentionally not used.
## Project images
`docker buildx bake` builds all six immutable project images:
- `glpi-neuroforge-mega-neuroforge`
- `glpi-neuroforge-mega-neuroforge-worker`
- `glpi-neuroforge-mega-agent`
- `glpi-neuroforge-mega-agent-data-init`
- `glpi-neuroforge-mega-knowledge`
- `glpi-neuroforge-mega-control`
Default registry/tag:
```text
git.send.nrw/sendnrw/<image>:1.6.2
```
Override without editing the bake file:
```bash
IMAGE_TAG=1.6.2 REGISTRY=git.send.nrw/sendnrw docker buildx bake
```
## Gitea Actions
`.gitea/workflows/ci.yml` runs test/vet/build for all four Go modules, static release checks and Docker builds.
`.gitea/workflows/release.yml` runs only on immutable `v*` tags. The tag must exactly match the root `VERSION` file. It pushes all six project images with the version tag only; no production dependency on `latest` is introduced.
Required registry secrets:
```text
DOCKER_USERNAME
DOCKER_PASSWORD
```
Release example:
```bash
git tag v1.6.2
git push origin v1.6.2
```
## Local source gate
```bash
./scripts/release-gate.sh
```
This executes static production checks, Compose environment isolation, secret scanning, graph reproducibility, test/vet/build for every module, and targeted race checks.
+11 -3
View File
@@ -1,5 +1,13 @@
# v1.6.0 Distributed Deployment Bundle
# Distributed Deployment — v1.6.2
This is the unchanged v1.6.0 application release plus ready-to-edit deployment kits under `deployments/`. No application code or persisted data format was changed.
The distributed deployment is part of the canonical release, not a separate code fork.
See `deployments/README.md`.
- `deployments/master`: authoritative NeuroForge Master, GLPI Agent, Knowledge, Control, optional Research, Prometheus and Grafana.
- `deployments/cpu-subagent`: remote CPU execution node for graph/relink work.
- `deployments/gpu-subagent`: remote GPU execution node plus Ollama for chat/embedding work.
All three roles ship with complete `.env` templates containing `CHANGE_ME_...` placeholders. The same real `NEUROFORGE_WORKER_TOKEN` must be configured on Master and both worker roles.
Recommended startup order: GPU subagent, CPU subagent, then Master. Keep the NeuroForge data volume authoritative on the Master only.
The v1.6.1 recovery/OOM fixes are included unchanged in v1.6.2. Do not delete the existing `neuroforge-data` volume when upgrading.
+640 -615
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# Migration: Mega v1.6.1 -> Agent + Knowledge Core
Der Core-Modus verändert das Datenformat der Knowledge-JSONs nicht.
## Variante A: Knowledge-Verzeichnis weiterverwenden
Setze in `deployments/agent/.env` und `deployments/knowledge/.env` denselben `CORE_DATA_ROOT`, sodass beide auf denselben Hostpfad zeigen.
## Variante B: Agent-Index übernehmen
Der Agent speichert seinen persistenten lokalen Knowledge-Index unter `/app/data`. Der Core-Compose bind-mountet dafür `runtime/agent-data`.
Um einen bestehenden Named Volume zu übernehmen, zuerst dessen Namen ermitteln und dann offline kopieren. Niemals das laufende Volume gleichzeitig von zwei Agent-Instanzen beschreiben lassen.
## Funktionale Änderungen gegenüber Mega
Nicht verfügbar im Core-Modus:
- NeuroForge Memories/Synapses/Graph
- autonome Research Goals
- Staging-Synthese durch NeuroForge
- CPU/GPU Subagent-Orchestrator
- Control Center
Weiter verfügbar:
- GLPI Polling/OAuth2
- Agent WebUI und Diagnose
- lokale Knowledge-Indizierung und Embeddings
- Hybrid Retrieval/RAG
- Kategorien/Keywords/Source Policy
- lokale Human-in-the-loop Lernbeispiele
- optional GLPI-KB-Synchronisation
- Knowledge Editor/Search
+26 -2
View File
@@ -1,10 +1,18 @@
# GLPI NeuroForge Mega v1.6.0
# GLPI NeuroForge Mega v1.6.2
> Release: **v1.6.0** · Durable Master/Subagent Orchestration + Knowledge-Graph Convergence: CPU-/GPU-Arbeit wird capability-basiert, lease-gefenced und persistent geplant; importierte Memories werden bounded zu einem n:m-Synapsengraphen verknüpft und Retrieval kann mehrere Hops traversieren.
> Release: **v1.6.2** · kanonischer Vollrelease mit Full-Mega-, Distributed- und Standalone-Betriebsmodi. Die v1.6.1 Recovery/OOM-Härtung ist vollständig enthalten.
v1.6.2 konsolidiert die zuvor getrennten Pakete wieder in **ein vollständiges Monorepo**: NeuroForge Master, CPU/GPU-Subagents, GLPI Agent, Knowledge, Control, Ollama, Research sowie eigenständig betreibbare Agent-/Knowledge-/Ollama-Core-Kits und Prometheus/Grafana-Beispiele.
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.
## Crash-/Recovery-Hardening (seit v1.6.1, in v1.6.2 enthalten)
Die in v1.6.1 eingeführte Härtung behebt einen RAM-/Recovery-Fehler, der bei großen Graph-Backfills nach mehreren Stunden Laufzeit auftreten konnte. Erfolgreiche `vector.relink`-Jobs verwerfen ihre großen transienten Vector-Payloads unmittelbar nach dem autoritativen Master-Apply; ein Queue-Payload-Budget verhindert neue ungebremste Speicherbelegung. Beim ersten Start migriert NeuroForge alte v1.6.0-Checkpoints streaming, bevor `state.json` vollständig in den RAM geladen wird.
HNSW-Deltas werden nicht mehr über einen vollständigen Deep-Copy aller Vektoren erzeugt. Checkpoints persistieren den Index vor `state.json`, sodass ein Crash während der Indexpersistenz den autoritativen Checkpoint nicht vor den verwendbaren Index ziehen kann. Bei Containerstarts mit explizitem `-listen` ist sofort eine Bootstrap-Liveness-/Startup-Seite erreichbar; Logs zeigen die aktuelle Recovery-Phase (`checkpoint.precompact`, `memory-segments.scan`, `wal.replay`, `hnsw.snapshot.load`, `hnsw.rebuild`, ...).
## 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.
@@ -26,6 +34,22 @@ Lokale Standardrollen:
Zusätzliche Hosts können mit `docker-compose.subagent.yml` angebunden werden. Das Control Center bleibt read-only und zeigt Master-, Worker-, Queue- und Graphzustand. Details: [`docs/MASTER-SUBAGENT-ORCHESTRATOR.md`](docs/MASTER-SUBAGENT-ORCHESTRATOR.md).
## Deployment-Modi
Das Repository enthält bewusst mehrere, voneinander entkoppelte Betriebsformen:
- `docker-compose.yml`: vollständiger Mega-Stack mit lokalem CPU- und GPU-Worker.
- `deployments/master`: autoritativer Master ohne lokale Worker, für getrennte CPU-/GPU-Hosts.
- `deployments/cpu-subagent`: abgesetzter CPU-Worker für `cpu,vector.relink`.
- `deployments/gpu-subagent`: abgesetzter GPU-Worker plus Ollama für `gpu,model.chat,model.embed`.
- `deployments/agent`: GLPI Agent standalone mit lokalem Vector-Backend, ohne NeuroForge-Zwang.
- `deployments/knowledge`: Knowledge standalone.
- `deployments/ollama`: Ollama standalone.
- `deployments/combined`: Agent + Knowledge + Ollama ohne NeuroForge.
Details: [`deployments/README.md`](deployments/README.md).
## Unified Graph Explorer (v1.4.0)
Das read-only Control Center visualisiert Runtime/Trust, Ticket-Evidence, Learning-Lineage, Research-Provenance, einen redigierten NeuroForge-Brain-Graph sowie einen reproduzierbaren Engineering-Graph aus Go-AST und Compose. Für Dateien/Symbole/Routen gibt es zusätzlich eine statische Change-Impact-/Blast-Radius-Sicht. 2D ist der operative Default; 3D ist ein optionaler, gebundener Explorer.
+30
View File
@@ -0,0 +1,30 @@
# GLPI NeuroForge Mega v1.6.1
## Production hotfix: Master crash / silent recovery on large Knowledge corpora
v1.6.1 keeps the v1.6.0 Master/Subagent and n:m Knowledge-Graph architecture and hardens the NeuroForge master for long-running bulk imports/backfills.
### Fixed
- Completed `vector.relink` jobs no longer retain full target/candidate vectors after successful master apply.
- A one-time streaming startup migration compacts large v1.6.0 `state.json` checkpoints before full JSON unmarshal.
- Durable pending-job payload bytes are bounded by `NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB` (default 128 MiB).
- HNSW segmented checkpoint deltas no longer deep-copy every vector/node on each checkpoint; only changed nodes are copied.
- Checkpoint ordering is index-first, then `state.json`, then WAL prune, improving crash recovery when index persistence is interrupted.
- Startup with explicit `-listen` exposes `/livez` and a minimal `/admin` recovery page before the store is fully open.
- Startup phases are logged, so segment/WAL/HNSW recovery is no longer silent.
- Existing v1.6.0 completed relink payloads are compacted on first v1.6.1 boot.
- Authoritative `state.json` and `secrets.json` are decoded directly from files instead of `ReadFile`+`Unmarshal`; corrupt or trailing JSON now fails closed with an explicit startup error instead of being silently ignored.
- Checkpoints/secrets/index metadata are JSON-streamed to atomic temporary files instead of allocating a second complete encoded byte slice; the final file and directory entry are synced before success.
- WAL recovery immediately drops transient payload/result blobs from already-completed `vector.relink` jobs, so a large surviving WAL cannot rebuild the same multi-GB terminal-job heap during restart.
- Fresh data directories no longer consume the one-time v1.6.1 compaction marker before a possible v1.6.0 restore.
### Safer graph defaults
- `NEUROFORGE_WORKER_JOB_RETENTION_HOURS=24`
- `NEUROFORGE_WORKER_MAX_TERMINAL_JOBS=2000`
- `NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE=16`
- `NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED=64`
- `NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB=128`
These are throughput/stability defaults; completed relink payload compaction is enforced independently of the values above.
+38
View File
@@ -0,0 +1,38 @@
# GLPI NeuroForge Mega v1.6.2
## Consolidated production release
v1.6.2 is the canonical full repository release. It keeps the v1.6.1 runtime/data format and recovery/OOM hotfixes, and consolidates all previously split deployment variants into one coherent tree.
### Included deployment modes
- Full Mega stack with local CPU/GPU workers.
- Distributed Master with remote CPU and GPU subagents.
- Standalone GLPI Agent using a local vector backend.
- Standalone Knowledge service.
- Standalone Ollama.
- Combined Agent + Knowledge + Ollama core stack without NeuroForge.
- Prometheus/Grafana example monitoring on the distributed Master, including payload/queue/graph metrics and alerts.
### Packaging / CI corrections
- Restores the complete v1.6.1 NeuroForge recovery hardening and graph/orchestrator source tree.
- Includes complete `.env` files with safe placeholders for Master, CPU subagent, GPU subagent and standalone roles.
- Removes embedded real credentials from deployment examples.
- Replaces conflicting release workflows with one root CI workflow and one immutable tag release workflow.
- CI covers NeuroForge, Agent, Knowledge and Control plus all six project images.
- `docker-bake.hcl` builds NeuroForge server/worker, Agent, Agent data-init, Knowledge and Control.
- Removes nested/legacy workflow copies and stale duplicate compose files.
- Regenerates a single repository manifest for the exact final archive.
### Runtime safety inherited from v1.6.1
- Streaming compaction of legacy terminal `vector.relink` payloads before normal state loading.
- Completed relink payload/result blobs are discarded after successful Master apply and during WAL recovery.
- Bounded pending durable job payload bytes.
- HNSW delta checkpoints avoid full graph/vector deep copies.
- Index-first checkpoint ordering and fail-closed authoritative JSON recovery.
- `/livez` and a bootstrap `/admin` page are available while store recovery is still running.
- Conservative graph backfill and job-retention defaults for large Knowledge corpora.
There is no intentional persisted-data-format break from v1.6.1 to v1.6.2.
+1 -1
View File
@@ -1 +1 @@
1.6.0
1.6.2
+24 -11
View File
@@ -1,16 +1,29 @@
# Distributed Deployment Kits (v1.6.0)
# Deployment Kits — GLPI NeuroForge Mega v1.6.2
This directory contains three independent deployment kits:
This repository intentionally supports both the complete NeuroForge platform and stripped standalone operation.
- `master/` - authoritative NeuroForge Master plus Agent, Knowledge, Control, optional SearXNG and optional Prometheus/Grafana.
- `cpu-subagent/` - CPU worker for `vector.relink` / graph convergence.
- `gpu-subagent/` - GPU worker plus local Ollama for `model.chat` and `model.embed`.
## Complete / distributed platform
The generated `NEUROFORGE_WORKER_TOKEN` is identical in all three `.env` files. Replace the RFC 5737 example IP addresses (`192.0.2.x`) with real reachable addresses before starting.
- `master/` — authoritative NeuroForge Master plus GLPI Agent, Knowledge, Control, optional SearXNG, Prometheus and Grafana. Remote CPU/GPU workers connect to this node.
- `cpu-subagent/` — disposable CPU worker with `cpu,vector.relink` capabilities.
- `gpu-subagent/` — disposable GPU worker plus Ollama with `gpu,model.chat,model.embed` capabilities.
Recommended order:
1. GPU subagent: `docker compose --profile monitoring up -d`
2. CPU subagent: `docker compose --profile monitoring up -d`
3. Master: fill GLPI credentials, then `docker compose --profile research --profile monitoring up -d`
The Master is the only authoritative owner of NeuroForge state. CPU/GPU workers use leases, heartbeats and fenced job completion.
Only the Master holds authoritative NeuroForge state. Workers are disposable execution nodes.
## Standalone core operation
- `agent/` — GLPI Agent with local Knowledge vector backend; no NeuroForge, Control or Research dependency.
- `knowledge/` — standalone Knowledge editor/service.
- `ollama/` — standalone Ollama runtime.
- `combined/` — Agent + Knowledge + Ollama on one host, still without NeuroForge.
The standalone Agent and Knowledge kits share `runtime/knowledge` by default. The Agent mounts it read-only; Knowledge mounts it read-write.
## Release rules
- Project images are pinned by `IMAGE_TAG=1.6.2`; production compose files do not require `latest`.
- Replace every `CHANGE_ME_...` placeholder before startup.
- Never use `docker compose down -v` during an in-place upgrade unless loss of persistent state is intended.
- For a v1.6.0/v1.6.1 NeuroForge data volume, keep the volume: v1.6.2 includes the v1.6.1 recovery/OOM hotfixes and startup compaction path.
See each role's README/preflight and the root `README.md` for startup order.
@@ -0,0 +1,10 @@
services:
agent-data-init:
image: glpi-ai-agent-data-init-local:1.6.2
build:
context: ../../services/agent
target: data-init
agent:
image: glpi-ai-agent-local:1.6.2
build:
context: ../../services/agent
+62
View File
@@ -0,0 +1,62 @@
name: glpi-ai-agent-core
services:
agent-data-init:
image: ${AGENT_DATA_INIT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:-1.6.2}}
restart: "no"
user: "0:0"
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- FOWNER
agent:
image: ${AGENT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:-1.6.2}}
restart: unless-stopped
env_file:
- .env
environment:
HTTP_ADDR: :8080
DATA_DIR: /app/data
KNOWLEDGE_DIR: /app/knowledge
KNOWLEDGE_VECTOR_BACKEND: local
OUTCOME_LEARNING_ENABLED: "false"
OUTCOME_RETRIEVAL_ENABLED: "false"
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
NEUROFORGE_URL: ""
NEUROFORGE_API_KEY: ""
CONTROL_READ_TOKEN: ""
ports:
- ${AGENT_BIND_IP:-127.0.0.1}:${AGENT_HOST_PORT:-9980}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/app/knowledge:ro
depends_on:
agent-data-init:
condition: service_completed_successfully
networks:
- core
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
networks:
core:
external: true
name: ${CORE_NETWORK:-glpi-ai-core}
+99
View File
@@ -0,0 +1,99 @@
name: glpi-ai-core
services:
ollama:
image: ${OLLAMA_IMAGE:-ollama/ollama:0.33.2}
restart: unless-stopped
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/ollama:/root/.ollama
ports:
- ${OLLAMA_BIND_IP:-127.0.0.1}:${OLLAMA_HOST_PORT:-11434}:11434
networks:
core:
aliases: [ollama]
security_opt:
- no-new-privileges:true
agent-data-init:
image: ${AGENT_DATA_INIT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:-1.6.1}}
restart: "no"
user: "0:0"
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER]
agent:
image: ${AGENT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:-1.6.1}}
restart: unless-stopped
env_file: [.env]
environment:
HTTP_ADDR: :8080
DATA_DIR: /app/data
KNOWLEDGE_DIR: /app/knowledge
OLLAMA_URL: http://ollama:11434
OLLAMA_URLS: http://ollama:11434
KNOWLEDGE_VECTOR_BACKEND: local
OUTCOME_LEARNING_ENABLED: "false"
OUTCOME_RETRIEVAL_ENABLED: "false"
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
NEUROFORGE_URL: ""
NEUROFORGE_API_KEY: ""
CONTROL_READ_TOKEN: ""
ports:
- ${AGENT_BIND_IP:-127.0.0.1}:${AGENT_HOST_PORT:-9980}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/app/knowledge:ro
depends_on:
agent-data-init:
condition: service_completed_successfully
ollama:
condition: service_started
networks: [core]
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: ${KNOWLEDGE_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:-1.6.1}}
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
OLLAMA_BASE_URL: http://ollama:11434
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
ports:
- ${KNOWLEDGE_BIND_IP:-127.0.0.1}:${KNOWLEDGE_HOST_PORT:-9981}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/data/knowledge:rw
- ${CORE_DATA_ROOT:-../../runtime}/staging:/data/staging:rw
- ${CORE_DATA_ROOT:-../../runtime}/backups:/data/backups:rw
depends_on:
ollama:
condition: service_started
networks: [core]
read_only: true
tmpfs: [/tmp:size=32m,mode=1777]
security_opt: [no-new-privileges:true]
cap_drop: [ALL]
stop_grace_period: 35s
networks:
core:
name: ${CORE_NETWORK:-glpi-ai-core}
+14
View File
@@ -0,0 +1,14 @@
services:
agent-data-init:
image: glpi-ai-agent-data-init-local:1.6.1
build:
context: ../../services/agent
target: data-init
agent:
image: glpi-ai-agent-local:1.6.1
build:
context: ../../services/agent
knowledge:
image: glpi-ai-knowledge-local:1.6.1
build:
context: ../../services/knowledge
@@ -0,0 +1,14 @@
services:
agent-data-init:
image: glpi-ai-agent-data-init-local:1.6.2
build:
context: ../../services/agent
target: data-init
agent:
image: glpi-ai-agent-local:1.6.2
build:
context: ../../services/agent
knowledge:
image: glpi-ai-knowledge-local:1.6.2
build:
context: ../../services/knowledge
+99
View File
@@ -0,0 +1,99 @@
name: glpi-ai-core
services:
ollama:
image: ${OLLAMA_IMAGE:-ollama/ollama:0.33.2}
restart: unless-stopped
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/ollama:/root/.ollama
ports:
- ${OLLAMA_BIND_IP:-127.0.0.1}:${OLLAMA_HOST_PORT:-11434}:11434
networks:
core:
aliases: [ollama]
security_opt:
- no-new-privileges:true
agent-data-init:
image: ${AGENT_DATA_INIT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:-1.6.2}}
restart: "no"
user: "0:0"
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
security_opt:
- no-new-privileges:true
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER]
agent:
image: ${AGENT_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:-1.6.2}}
restart: unless-stopped
env_file: [.env]
environment:
HTTP_ADDR: :8080
DATA_DIR: /app/data
KNOWLEDGE_DIR: /app/knowledge
OLLAMA_URL: http://ollama:11434
OLLAMA_URLS: http://ollama:11434
KNOWLEDGE_VECTOR_BACKEND: local
OUTCOME_LEARNING_ENABLED: "false"
OUTCOME_RETRIEVAL_ENABLED: "false"
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
NEUROFORGE_URL: ""
NEUROFORGE_API_KEY: ""
CONTROL_READ_TOKEN: ""
ports:
- ${AGENT_BIND_IP:-127.0.0.1}:${AGENT_HOST_PORT:-9980}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/agent-data:/app/data
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/app/knowledge:ro
depends_on:
agent-data-init:
condition: service_completed_successfully
ollama:
condition: service_started
networks: [core]
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: ${KNOWLEDGE_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:-1.6.2}}
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
OLLAMA_BASE_URL: http://ollama:11434
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
ports:
- ${KNOWLEDGE_BIND_IP:-127.0.0.1}:${KNOWLEDGE_HOST_PORT:-9981}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/data/knowledge:rw
- ${CORE_DATA_ROOT:-../../runtime}/staging:/data/staging:rw
- ${CORE_DATA_ROOT:-../../runtime}/backups:/data/backups:rw
depends_on:
ollama:
condition: service_started
networks: [core]
read_only: true
tmpfs: [/tmp:size=32m,mode=1777]
security_opt: [no-new-privileges:true]
cap_drop: [ALL]
stop_grace_period: 35s
networks:
core:
name: ${CORE_NETWORK:-glpi-ai-core}
+8 -4
View File
@@ -1,7 +1,11 @@
#!/usr/bin/env sh
set -eu
for k in NEUROFORGE_MASTER_URL NEUROFORGE_WORKER_TOKEN NEUROFORGE_CPU_WORKER_ID; do v=$(grep -E "^${k}=" .env | head -1 | cut -d= -f2- || true); [ -n "$v" ] || { echo "FEHLT: $k"; exit 1; }; echo "$v" | grep -q '192\.0\.2\.' && { echo "SETZEN: $k"; exit 1; } || true; done
command -v docker >/dev/null 2>&1 || { echo "FEHLT: docker"; exit 1; }
docker compose version >/dev/null
[ -f .env ] || { echo 'missing .env'; exit 1; }
for k in IMAGE_TAG NEUROFORGE_MASTER_URL NEUROFORGE_WORKER_TOKEN NEUROFORGE_CPU_WORKER_ID; do
v=$(awk -F= -v key="$k" '$1==key {sub(/^[^=]*=/,""); print; exit}' .env)
[ -n "$v" ] || { echo "missing: $k"; exit 1; }
done
if grep -Eq '192\.0\.2\.|example\.invalid|CHANGE_ME|YOUR-' .env; then echo 'placeholder/example values remain in .env'; exit 1; fi
[ "$(awk -F= '$1=="IMAGE_TAG"{print $2}' .env)" = "1.6.2" ] || { echo 'IMAGE_TAG must be 1.6.2'; exit 1; }
docker compose --profile monitoring config >/dev/null
echo "CPU subagent preflight: OK"
echo 'cpu subagent preflight: OK'
+8 -5
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env sh
set -eu
for k in NEUROFORGE_MASTER_URL NEUROFORGE_WORKER_TOKEN NEUROFORGE_GPU_WORKER_ID OLLAMA_MODEL OLLAMA_EMBEDDING_MODEL; do v=$(grep -E "^${k}=" .env | head -1 | cut -d= -f2- || true); [ -n "$v" ] || { echo "FEHLT: $k"; exit 1; }; echo "$v" | grep -q '192\.0\.2\.' && { echo "SETZEN: $k"; exit 1; } || true; done
command -v docker >/dev/null 2>&1 || { echo "FEHLT: docker"; exit 1; }
docker compose version >/dev/null
[ -f .env ] || { echo 'missing .env'; exit 1; }
for k in IMAGE_TAG NEUROFORGE_MASTER_URL NEUROFORGE_WORKER_TOKEN NEUROFORGE_GPU_WORKER_ID OLLAMA_MODEL OLLAMA_EMBEDDING_MODEL; do
v=$(awk -F= -v key="$k" '$1==key {sub(/^[^=]*=/,""); print; exit}' .env)
[ -n "$v" ] || { echo "missing: $k"; exit 1; }
done
if grep -Eq '192\.0\.2\.|example\.invalid|CHANGE_ME|YOUR-' .env; then echo 'placeholder/example values remain in .env'; exit 1; fi
[ "$(awk -F= '$1=="IMAGE_TAG"{print $2}' .env)" = "1.6.2" ] || { echo 'IMAGE_TAG must be 1.6.2'; exit 1; }
docker compose --profile monitoring config >/dev/null
command -v nvidia-smi >/dev/null 2>&1 || echo "WARNUNG: nvidia-smi nicht gefunden; NVIDIA Host/Toolkit prüfen."
echo "GPU subagent preflight: OK"
echo 'gpu subagent preflight: OK'
@@ -0,0 +1,5 @@
services:
knowledge:
image: glpi-ai-knowledge-local:1.6.2
build:
context: ../../services/knowledge
+36
View File
@@ -0,0 +1,36 @@
name: glpi-ai-knowledge-core
services:
knowledge:
image: ${KNOWLEDGE_IMAGE:-git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:-1.6.2}}
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
BRAIN_ACTIVITY_URL: ""
BRAIN_ACTIVITY_API_KEY: ""
ports:
- ${KNOWLEDGE_BIND_IP:-127.0.0.1}:${KNOWLEDGE_HOST_PORT:-9981}:8080
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/knowledge:/data/knowledge:rw
- ${CORE_DATA_ROOT:-../../runtime}/staging:/data/staging:rw
- ${CORE_DATA_ROOT:-../../runtime}/backups:/data/backups:rw
networks:
- core
read_only: true
tmpfs:
- /tmp:size=32m,mode=1777
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stop_grace_period: 35s
networks:
core:
external: true
name: ${CORE_NETWORK:-glpi-ai-core}
+11 -10
View File
@@ -23,7 +23,7 @@ services:
- ALL
neuroforge:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG
to an immutable release tag, for example 1.6.0}
to an immutable release tag, for example 1.6.2}
command:
- -data
- /app/data
@@ -57,14 +57,15 @@ services:
NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS: ${NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS:-3}
NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS: ${NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS:-15}
NEUROFORGE_WORKER_MAX_QUEUED_JOBS: ${NEUROFORGE_WORKER_MAX_QUEUED_JOBS:-5000}
NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB: ${NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB:-128}
NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS: ${NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS:-5}
NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS: ${NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS:-5}
NEUROFORGE_WORKER_JOB_RETENTION_HOURS: ${NEUROFORGE_WORKER_JOB_RETENTION_HOURS:-168}
NEUROFORGE_WORKER_MAX_TERMINAL_JOBS: ${NEUROFORGE_WORKER_MAX_TERMINAL_JOBS:-20000}
NEUROFORGE_WORKER_JOB_RETENTION_HOURS: ${NEUROFORGE_WORKER_JOB_RETENTION_HOURS:-24}
NEUROFORGE_WORKER_MAX_TERMINAL_JOBS: ${NEUROFORGE_WORKER_MAX_TERMINAL_JOBS:-2000}
NEUROFORGE_GRAPH_BACKFILL_ENABLED: ${NEUROFORGE_GRAPH_BACKFILL_ENABLED:-true}
NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS: ${NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS:-10}
NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE: ${NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE:-64}
NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED: ${NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED:-256}
NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE: ${NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE:-16}
NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED: ${NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED:-64}
NEUROFORGE_GRAPH_BACKFILL_MIN_DEGREE: ${NEUROFORGE_GRAPH_BACKFILL_MIN_DEGREE:-3}
NEUROFORGE_GRAPH_CANDIDATE_MULTIPLIER: ${NEUROFORGE_GRAPH_CANDIDATE_MULTIPLIER:-6}
NEUROFORGE_GRAPH_RETRY_AFTER_MINUTES: ${NEUROFORGE_GRAPH_RETRY_AFTER_MINUTES:-360}
@@ -137,7 +138,7 @@ services:
stop_grace_period: 35s
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.6.0}
IMAGE_TAG to an immutable release tag, for example 1.6.2}
restart: 'no'
user: 0:0
volumes:
@@ -151,7 +152,7 @@ services:
- FOWNER
agent:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG
to an immutable release tag, for example 1.6.0}
to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-}
@@ -373,7 +374,7 @@ services:
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.6.0}
to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
APP_MODE: ${KB_APP_MODE:-editor}
@@ -414,7 +415,7 @@ services:
stop_grace_period: 35s
control:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG
to an immutable release tag, for example 1.6.0}
to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
CONTROL_ADDR: :8070
@@ -543,7 +544,7 @@ services:
- prometheus-secrets:/etc/prometheus-secrets:ro
depends_on:
neuroforge:
condition: service_healthy
condition: service_started
prometheus-secrets-init:
condition: service_completed_successfully
prometheus-config-init:
@@ -9,7 +9,7 @@
],
"timezone": "browser",
"schemaVersion": 41,
"version": 1,
"version": 2,
"refresh": "15s",
"time": {
"from": "now-6h",
@@ -1443,6 +1443,45 @@
"sort": "desc"
}
}
},
{
"id": 26,
"title": "Durable Job Payload Bytes",
"type": "timeseries",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"x": 0,
"y": 56,
"w": 24,
"h": 8
},
"fieldConfig": {
"defaults": {
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"expr": "neuroforge_job_payload_bytes",
"legendFormat": "{{state}}",
"refId": "A"
}
]
}
]
}
@@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: NeuroForge
orgId: 1
folder: NeuroForge
type: file
disableDeletion: true
updateIntervalSeconds: 30
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
@@ -1,4 +1,5 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
@@ -56,3 +56,19 @@ groups:
annotations:
summary: "Knowledge graph remains highly isolated"
description: "More than 80% of memories are still isolated after 30 minutes."
- alert: NeuroForgePendingPayloadHigh
expr: neuroforge_job_payload_bytes{state="pending"} > 104857600
for: 5m
labels: {severity: warning}
annotations:
summary: "NeuroForge pending job payload memory is high"
description: "Pending durable job payload/result bytes exceed 100 MiB for 5 minutes; backfill should remain below the v1.6.2 safety budget."
- alert: NeuroForgeTerminalPayloadHigh
expr: neuroforge_job_payload_bytes{state="terminal"} > 67108864
for: 10m
labels: {severity: warning}
annotations:
summary: "NeuroForge terminal job payload retention is high"
description: "Terminal job payload/result bytes exceed 64 MiB. Completed vector.relink jobs should be compacted automatically in v1.6.2."
+11 -12
View File
@@ -1,15 +1,14 @@
#!/usr/bin/env sh
set -eu
bad=0
check() { key="$1"; val=$(grep -E "^${key}=" .env | head -1 | cut -d= -f2- || true); if [ -z "$val" ] || echo "$val" | grep -Eq 'CHANGE_ME|example\.invalid|192\.0\.2\.'; then echo "SETZEN: $key"; bad=1; fi; }
for k in GLPI_URL GLPI_CLIENT_ID GLPI_CLIENT_SECRET GLPI_USERNAME GLPI_PASSWORD OLLAMA_BASE_URL OLLAMA_URLS; do check "$k"; done
if [ "$bad" -ne 0 ]; then echo "Preflight fehlgeschlagen: Platzhalter ersetzen."; exit 1; fi
command -v docker >/dev/null 2>&1 || { echo "FEHLT: docker"; exit 1; }
docker compose version >/dev/null
[ -f .env ] || { echo 'missing .env'; exit 1; }
required='IMAGE_TAG 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 GLPI_URL GLPI_CLIENT_ID GLPI_CLIENT_SECRET GLPI_USERNAME GLPI_PASSWORD GRAFANA_ADMIN_PASSWORD'
for k in $required; do
v=$(awk -F= -v key="$k" '$1==key {sub(/^[^=]*=/,""); print; exit}' .env)
[ -n "$v" ] || { echo "missing: $k"; exit 1; }
done
if grep -Eq '(^|=)(https?://)?192\.0\.2\.|example\.invalid|CHANGE_ME|YOUR-' .env; then
echo 'placeholder/example values remain in .env'; exit 1
fi
[ "$(awk -F= '$1=="IMAGE_TAG"{print $2}' .env)" = "1.6.2" ] || { echo 'IMAGE_TAG must be 1.6.2'; exit 1; }
docker compose --profile research --profile monitoring config >/dev/null
[ -f monitoring/prometheus/prometheus.yml.template ]
[ -f monitoring/prometheus/alerts.yml ]
[ -f monitoring/grafana/dashboards/neuroforge-master-subagents.json ]
mkdir -p knowledge staging backups
./monitoring/validate.sh
echo "Master preflight: OK"
echo 'master preflight: OK'
+20
View File
@@ -0,0 +1,20 @@
name: glpi-ai-ollama-core
services:
ollama:
image: ${OLLAMA_IMAGE:-ollama/ollama:0.33.2}
restart: unless-stopped
ports:
- ${OLLAMA_BIND_IP:-127.0.0.1}:${OLLAMA_HOST_PORT:-11434}:11434
volumes:
- ${CORE_DATA_ROOT:-../../runtime}/ollama:/root/.ollama
networks:
core:
aliases:
- ollama
security_opt:
- no-new-privileges:true
networks:
core:
external: true
name: ${CORE_NETWORK:-glpi-ai-core}
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
[ -f .env ] && set -a && . ./.env && set +a
: "${OLLAMA_MODEL:=gemma4}"
: "${OLLAMA_EMBEDDING_MODEL:=embeddinggemma}"
docker compose exec ollama ollama pull "$OLLAMA_MODEL"
docker compose exec ollama ollama pull "$OLLAMA_EMBEDDING_MODEL"
echo "Modelle vorhanden:"
docker compose exec ollama ollama list
+57
View File
@@ -0,0 +1,57 @@
variable "IMAGE_TAG" {
default = "1.6.2"
}
variable "REGISTRY" {
default = "git.send.nrw/sendnrw"
}
group "default" {
targets = [
"neuroforge",
"neuroforge-worker",
"agent",
"agent-data-init",
"knowledge",
"control",
]
}
target "neuroforge" {
context = "./platform/neuroforge"
dockerfile = "Dockerfile"
target = "server"
tags = ["${REGISTRY}/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG}"]
}
target "neuroforge-worker" {
context = "./platform/neuroforge"
dockerfile = "Dockerfile"
target = "worker"
tags = ["${REGISTRY}/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG}"]
}
target "agent" {
context = "./services/agent"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/glpi-neuroforge-mega-agent:${IMAGE_TAG}"]
}
target "agent-data-init" {
context = "./services/agent"
dockerfile = "Dockerfile"
target = "data-init"
tags = ["${REGISTRY}/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG}"]
}
target "knowledge" {
context = "./services/knowledge"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/glpi-neuroforge-mega-knowledge:${IMAGE_TAG}"]
}
target "control" {
context = "./services/control"
dockerfile = "Dockerfile"
tags = ["${REGISTRY}/glpi-neuroforge-mega-control:${IMAGE_TAG}"]
}
+2 -2
View File
@@ -7,7 +7,7 @@ name: neuroforge-subagents
services:
cpu-subagent:
profiles: [cpu]
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG, e.g. 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG, e.g. 1.6.1}
command:
- -server
- ${NEUROFORGE_MASTER_URL:?Set the reachable NeuroForge master URL}
@@ -29,7 +29,7 @@ services:
gpu-subagent:
profiles: [gpu]
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG, e.g. 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG, e.g. 1.6.1}
command:
- -server
- ${NEUROFORGE_MASTER_URL:?Set the reachable NeuroForge master URL}
+12 -11
View File
@@ -33,7 +33,7 @@ services:
cap_drop:
- ALL
neuroforge:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
command:
- -data
- /app/data
@@ -61,14 +61,15 @@ services:
NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS: ${NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS:-3}
NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS: ${NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS:-15}
NEUROFORGE_WORKER_MAX_QUEUED_JOBS: ${NEUROFORGE_WORKER_MAX_QUEUED_JOBS:-5000}
NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB: ${NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB:-128}
NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS: ${NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS:-5}
NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS: ${NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS:-5}
NEUROFORGE_WORKER_JOB_RETENTION_HOURS: ${NEUROFORGE_WORKER_JOB_RETENTION_HOURS:-168}
NEUROFORGE_WORKER_MAX_TERMINAL_JOBS: ${NEUROFORGE_WORKER_MAX_TERMINAL_JOBS:-20000}
NEUROFORGE_WORKER_JOB_RETENTION_HOURS: ${NEUROFORGE_WORKER_JOB_RETENTION_HOURS:-24}
NEUROFORGE_WORKER_MAX_TERMINAL_JOBS: ${NEUROFORGE_WORKER_MAX_TERMINAL_JOBS:-2000}
NEUROFORGE_GRAPH_BACKFILL_ENABLED: ${NEUROFORGE_GRAPH_BACKFILL_ENABLED:-true}
NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS: ${NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS:-10}
NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE: ${NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE:-64}
NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED: ${NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED:-256}
NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE: ${NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE:-16}
NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED: ${NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED:-64}
NEUROFORGE_GRAPH_BACKFILL_MIN_DEGREE: ${NEUROFORGE_GRAPH_BACKFILL_MIN_DEGREE:-3}
NEUROFORGE_GRAPH_CANDIDATE_MULTIPLIER: ${NEUROFORGE_GRAPH_CANDIDATE_MULTIPLIER:-6}
NEUROFORGE_GRAPH_RETRY_AFTER_MINUTES: ${NEUROFORGE_GRAPH_RETRY_AFTER_MINUTES:-360}
@@ -142,7 +143,7 @@ services:
start_period: 15s
stop_grace_period: 35s
neuroforge-worker-cpu:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
command:
- -server
- http://neuroforge:8080
@@ -167,7 +168,7 @@ services:
- ALL
neuroforge-worker-gpu:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-neuroforge-worker:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
command:
- -server
- http://neuroforge:8080
@@ -199,7 +200,7 @@ services:
- 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.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent-data-init:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
restart: 'no'
user: 0:0
volumes:
@@ -212,7 +213,7 @@ services:
- 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.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-agent:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
AI_CONTENT_LABEL_ENABLED: ${AI_CONTENT_LABEL_ENABLED:-}
@@ -433,7 +434,7 @@ services:
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.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-knowledge:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
APP_MODE: ${KB_APP_MODE:-editor}
@@ -474,7 +475,7 @@ services:
- ALL
stop_grace_period: 35s
control:
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.0}
image: git.send.nrw/sendnrw/glpi-neuroforge-mega-control:${IMAGE_TAG:?Set IMAGE_TAG to an immutable release tag, for example 1.6.2}
restart: unless-stopped
environment:
CONTROL_ADDR: :8070
+27
View File
@@ -0,0 +1,27 @@
# Migration v1.6.0 → v1.6.1
1. **Kein Volume löschen.** `neuroforge-data` enthält den autoritativen Store.
2. Backup des NeuroForge-Volumes erstellen.
3. `IMAGE_TAG=1.6.1` setzen.
4. Für große Graph-Backfills die neuen sicheren Defaults übernehmen:
- `NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB=128`
- `NEUROFORGE_WORKER_JOB_RETENTION_HOURS=24`
- `NEUROFORGE_WORKER_MAX_TERMINAL_JOBS=2000`
- `NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE=16`
- `NEUROFORGE_GRAPH_BACKFILL_MAX_QUEUED=64`
5. Nur NeuroForge Master zuerst neu erstellen. Beim ersten Start läuft ggf. `checkpoint.precompact`; diese Streaming-Migration entfernt historische abgeschlossene Relink-Vectorblobs aus dem v1.6.0-Checkpoint.
6. Während Recovery sind `/livez` und `/admin` bereits erreichbar; `/readyz` bleibt bis zum vollständigen Store-/Provider-Start 503.
7. Erst nach `/readyz=200` CPU-/GPU-Subagents und übrige Services normal starten.
Erwartete Startup-Phasen im Log:
`store.prepare → checkpoint.precompact → checkpoint.load → memory-segments.scan → wal.replay → jobs.compact → graph.restore → disk-ann.load → hnsw.snapshot.load [→ hnsw.rebuild] → checkpoint.write → store.ready`
## Recovery-Verhalten in v1.6.1
- `state.json` und `secrets.json` sind autoritativ. Syntaxfehler, ein zweites angehängtes JSON-Objekt oder sonstige Dekodierfehler werden **nicht** mehr ignoriert; NeuroForge meldet den konkreten Startup-Fehler und bleibt nicht mit stillschweigend zurückgesetztem Zustand/Tokens in Betrieb.
- Große Checkpoints werden direkt aus der Datei dekodiert und beim Schreiben gestreamt. Dadurch entfällt die zusätzliche vollständige `[]byte`-Kopie des Checkpoints im RAM.
- Beim WAL-Replay werden bereits abgeschlossene `vector.relink`-Payloads sofort verworfen. `apply_wait`, `queued`, `claimed`, `retry_wait` und fehlgeschlagene/retrybare Jobs behalten ihre für Recovery/Retry benötigten Daten.
- Ein beschädigter `state.json`/`secrets.json` wird nicht automatisch überschrieben. Erst Backup/Restore bzw. gezielte Reparatur durchführen.
Vor einem Upgrade bei einem bereits hängenden v1.6.0-Master das Volume **nicht** löschen. Ein Dateisystem-/Volume-Snapshot ist vorzuziehen.
+12
View File
@@ -0,0 +1,12 @@
# Migration v1.6.1 -> v1.6.2
v1.6.2 is primarily a packaging/deployment consolidation release. It retains the v1.6.1 NeuroForge recovery and persisted-state behavior.
1. Back up the persistent data volumes/directories.
2. Set `IMAGE_TAG=1.6.2` in the selected deployment `.env`.
3. Keep existing `neuroforge-data`, Agent data and Knowledge files; do not delete volumes.
4. Replace deployment examples with the v1.6.2 variants, preserving real secrets locally.
5. Recreate project containers so changed environment/configuration is applied.
6. Check `/livez`, `/readyz`, Agent `/api/status`, worker status, graph convergence and Prometheus alerts.
The distributed roles now live alongside the standalone Agent/Knowledge/Ollama roles in the same canonical repository.
+1
View File
@@ -20,3 +20,4 @@ b031ea42356a022d7bb03e7e2bda23c4ceddb66ccc59a5d1f0ebe787a790b829 v1.5.1-to-v1.5
53ab438b31a31590edbfe00eea6ba9d9ebff2b4c6255d7ac2056c86757f65c3a v1.5.7-to-v1.5.8.diff
24bb0da9bf77a0e8b692989b05c00b623ed9a558b9b5050c24e396e1da41d0f8 v1.5.8-to-v1.5.9.diff
e579d88ed2846cf5bc306cad45502af3edc61bd1c5bb5a2272979feff23dca62 v1.5.9-to-v1.6.0.diff
0cdce94c11a931e11c3c908f2fa3dfa733c5c602ac00f7f89e41b8c301574766 v1.6.0-to-v1.6.1.diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -1,4 +1,17 @@
# Changelog
- v1.6.1 hotfix: streaming authoritative checkpoint I/O avoids full raw/encoded `state.json` copies during load/save.
- v1.6.1 hotfix: corrupt `state.json`/`secrets.json` fail closed instead of being silently ignored.
- v1.6.1 hotfix: completed `vector.relink` blobs are compacted during WAL replay as well as checkpoint migration.
## v0.8.3
- Crash-/Recovery-Hardening für große Knowledge-Korpora und Graph-Backfills.
- Streaming-Migration entfernt historische `vector.relink`-Payloads vor dem vollständigen Checkpoint-Unmarshal.
- Erfolgreiche Relink-Jobs verwerfen Target-/Candidate-Vektoren sofort nach Master-Apply.
- Queue-Payload-Budget schützt den Master vor ungebremstem transientem Job-State.
- HNSW-Deltas kopieren nur veränderte Nodes statt bei jedem Checkpoint den kompletten Index.
- Checkpoint-Reihenfolge index-first verhindert `state.json`-Vorlauf nach Crash in der Indexpersistenz.
- Bootstrap-HTTP und Startup-Phasenlogs machen lange Recovery sichtbar.
## v0.8.2
+5 -1
View File
@@ -1,4 +1,4 @@
# NeuroForge v0.8.2 Production Guide
# NeuroForge v0.8.3 Production Guide
## 1. Sicherheitsgrenze
@@ -146,3 +146,7 @@ Research-Trace-Events sind **Observability/Audit**, nicht autoritative Knowledge
Die `preview`-Felder im Research-Trace sind absichtlich gekürzt und enthalten keine vollständigen Dokumente. Für vollständige Inhalte den Source-/Memory-Inspector verwenden.
### v1.6.1 Recovery / large graph backfill
For large graph backfills keep completed relink payloads compact and bound pending payload bytes. The master streams `state.json` on load/save and fails closed on corrupt authoritative state/secrets. Never delete or replace a corrupt checkpoint automatically; retain the volume for forensic recovery/restore. During startup with explicit `-listen`, `/livez` and the bootstrap `/admin` page expose the current recovery phase while `/readyz` remains 503.
+2 -2
View File
@@ -1,8 +1,8 @@
# NeuroForge v0.8.2
# NeuroForge v0.8.3
NeuroForge ist eine persistente, assoziativ lernende KI-Schicht in Go. Ollama und optional OpenAI liefern Inferenz/Embeddings; NeuroForge besitzt den dauerhaften Wissenszustand: Vektoren, HNSW/Disk-PQ-Recall, Synapsen, Rewards, Provenance, Konflikte, Konsolidierung, Goals und Learning Cycles.
**v0.8.2 erweitert den Production-/Explainability-Stand um einen source-grounded Lernpfad, Dokument-/Text-Ingestion, SearXNG-Research und ein vollständig neu gestaltetes CSS/Vanilla-JS-Admin-UI mit responsive Knowledge-Graph und Level-of-Detail (LOD).** Wissen soll nicht nur gespeichert, sondern als Kette `Quelle → Evidence → Recall → Learning` nachvollziehbar sein.
**v0.8.3 übernimmt den v0.8.2-Funktionsumfang und härtet große Bulk-/Graph-Workloads gegen Speicher- und Recovery-Spitzen. v0.8.2 erweitert den Production-/Explainability-Stand um einen source-grounded Lernpfad, Dokument-/Text-Ingestion, SearXNG-Research und ein vollständig neu gestaltetes CSS/Vanilla-JS-Admin-UI mit responsive Knowledge-Graph und Level-of-Detail (LOD).** Wissen soll nicht nur gespeichert, sondern als Kette `Quelle → Evidence → Recall → Learning` nachvollziehbar sein.
## Neu in v0.8.2: Live Research pro Goal
+1 -1
View File
@@ -1 +1 @@
0.8.2
0.8.3
+129 -20
View File
@@ -6,12 +6,14 @@ import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
@@ -101,6 +103,59 @@ func maxIntMain(a, b int) int {
return b
}
type bootstrapHandler struct {
mu sync.RWMutex
phase string
handler http.Handler
}
func newBootstrapHandler() *bootstrapHandler {
return &bootstrapHandler{phase: "process.start"}
}
func (b *bootstrapHandler) SetPhase(phase string) {
b.mu.Lock()
b.phase = phase
b.mu.Unlock()
}
func (b *bootstrapHandler) SetHandler(h http.Handler) {
b.mu.Lock()
b.handler = h
b.phase = "ready"
b.mu.Unlock()
}
func (b *bootstrapHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b.mu.RLock()
h := b.handler
phase := b.phase
b.mu.RUnlock()
if h != nil {
h.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
switch r.URL.Path {
case "/livez", "/healthz":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `{"ok":true,"status":"starting","phase":%q}`, phase)
case "/readyz":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, `{"ok":false,"status":"starting","phase":%q}`, phase)
case "/", "/admin":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, `<!doctype html><html><head><meta charset="utf-8"><title>NeuroForge starting</title><meta http-equiv="refresh" content="5"></head><body><h1>NeuroForge startet</h1><p>Recovery-/Startphase: <code>%s</code></p><p>Die autoritativen Daten werden geladen. Diese Seite aktualisiert sich automatisch.</p></body></html>`, phase)
default:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = fmt.Fprintf(w, `{"error":"neuroforge is starting","phase":%q}`, phase)
}
}
func main() {
if err := run(); err != nil {
log.Printf("fatal: %v", err)
@@ -117,7 +172,55 @@ func run() (retErr error) {
return err
}
s, err := store.New(*data)
var boot *bootstrapHandler
var srv *http.Server
var errCh chan error
// Container deployments pass an explicit -listen address. Bind it before
// opening the potentially large store so liveness and a minimal startup UI
// remain reachable during WAL/index recovery instead of looking like a dead
// container with no logs.
if strings.TrimSpace(*listen) != "" {
boot = newBootstrapHandler()
d := core.DefaultConfig()
h := d.HTTP
srv = &http.Server{
Addr: *listen,
Handler: boot,
ReadHeaderTimeout: time.Duration(h.ReadHeaderTimeoutSeconds) * time.Second,
ReadTimeout: time.Duration(h.ReadTimeoutSeconds) * time.Second,
WriteTimeout: time.Duration(h.WriteTimeoutSeconds) * time.Second,
IdleTimeout: time.Duration(h.IdleTimeoutSeconds) * time.Second,
MaxHeaderBytes: h.MaxHeaderBytes,
}
ln, err := net.Listen("tcp", *listen)
if err != nil {
return fmt.Errorf("bootstrap listen %s: %w", *listen, err)
}
errCh = make(chan error, 1)
go func() {
err := srv.Serve(ln)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
log.Printf("NeuroForge bootstrap listener active on %s", *listen)
defer func() {
if retErr != nil && srv != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
}()
}
reportStartup := func(phase string) {
log.Printf("startup phase: %s", phase)
if boot != nil {
boot.SetPhase(phase)
}
}
s, err := store.NewWithProgress(*data, reportStartup)
if err != nil {
return err
}
@@ -258,7 +361,7 @@ func run() (retErr error) {
// and environment-owned only when set, preserving admin-managed config otherwise.
workerEnv := []string{
"NEUROFORGE_WORKER_LEASE_SECONDS", "NEUROFORGE_WORKER_HEARTBEAT_SECONDS", "NEUROFORGE_WORKER_STALE_AFTER_SECONDS",
"NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS", "NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS", "NEUROFORGE_WORKER_MAX_QUEUED_JOBS",
"NEUROFORGE_WORKER_DEFAULT_MAX_ATTEMPTS", "NEUROFORGE_WORKER_RETRY_BACKOFF_SECONDS", "NEUROFORGE_WORKER_MAX_QUEUED_JOBS", "NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB",
"NEUROFORGE_WORKER_JOB_RETENTION_HOURS", "NEUROFORGE_WORKER_MAX_TERMINAL_JOBS",
"NEUROFORGE_WORKER_MASTER_APPLY_MAX_ATTEMPTS", "NEUROFORGE_WORKER_MASTER_APPLY_BACKOFF_SECONDS",
"NEUROFORGE_GRAPH_BACKFILL_ENABLED", "NEUROFORGE_GRAPH_BACKFILL_INTERVAL_SECONDS", "NEUROFORGE_GRAPH_BACKFILL_BATCH_SIZE",
@@ -294,6 +397,9 @@ func run() (retErr error) {
if v, ok := envInt("NEUROFORGE_WORKER_MAX_QUEUED_JOBS"); ok {
cfg.Worker.MaxQueuedJobs = v
}
if v, ok := envInt("NEUROFORGE_WORKER_MAX_QUEUED_PAYLOAD_MB"); ok {
cfg.Worker.MaxQueuedPayloadMB = v
}
if v, ok := envInt("NEUROFORGE_WORKER_JOB_RETENTION_HOURS"); ok {
cfg.Worker.JobRetentionHours = v
}
@@ -453,30 +559,33 @@ func run() (retErr error) {
}
h := cfg.HTTP
srv := &http.Server{
Addr: addr,
Handler: api.Handler(),
ReadHeaderTimeout: time.Duration(h.ReadHeaderTimeoutSeconds) * time.Second,
ReadTimeout: time.Duration(h.ReadTimeoutSeconds) * time.Second,
WriteTimeout: time.Duration(h.WriteTimeoutSeconds) * time.Second,
IdleTimeout: time.Duration(h.IdleTimeoutSeconds) * time.Second,
MaxHeaderBytes: h.MaxHeaderBytes,
if srv == nil {
srv = &http.Server{
Addr: addr,
Handler: api.Handler(),
ReadHeaderTimeout: time.Duration(h.ReadHeaderTimeoutSeconds) * time.Second,
ReadTimeout: time.Duration(h.ReadTimeoutSeconds) * time.Second,
WriteTimeout: time.Duration(h.WriteTimeoutSeconds) * time.Second,
IdleTimeout: time.Duration(h.IdleTimeoutSeconds) * time.Second,
MaxHeaderBytes: h.MaxHeaderBytes,
}
errCh = make(chan error, 1)
go func() {
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
} else {
boot.SetHandler(api.Handler())
}
log.Printf("NeuroForge v0.8.2 listening on %s", addr)
log.Printf("NeuroForge v0.8.3 listening on %s", addr)
log.Printf("Admin dashboard: /admin · readiness: /readyz · metrics: /metrics")
if os.Getenv("NEUROFORGE_ADMIN_TOKEN") == "" {
log.Printf("Admin token is intentionally not printed; read it locally from %s or set NEUROFORGE_ADMIN_TOKEN", filepath.Join(*data, "secrets.json"))
}
errCh := make(chan error, 1)
go func() {
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
var serveErr error
select {
case serveErr = <-errCh:
@@ -0,0 +1,38 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestBootstrapHandlerExposesLivenessAndStartupUI(t *testing.T) {
b := newBootstrapHandler()
b.SetPhase("hnsw.rebuild")
rr := httptest.NewRecorder()
b.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/livez", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hnsw.rebuild") {
t.Fatalf("livez=%d %s", rr.Code, rr.Body.String())
}
rr = httptest.NewRecorder()
b.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz=%d", rr.Code)
}
rr = httptest.NewRecorder()
b.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rr.Code != http.StatusServiceUnavailable || !strings.Contains(rr.Body.String(), "hnsw.rebuild") {
t.Fatalf("admin=%d %s", rr.Code, rr.Body.String())
}
b.SetHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
rr = httptest.NewRecorder()
b.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rr.Code != http.StatusNoContent {
t.Fatalf("delegated=%d", rr.Code)
}
}
+6 -4
View File
@@ -398,6 +398,7 @@ type Config struct {
DefaultMaxAttempts int `json:"default_max_attempts"`
RetryBackoffSeconds int `json:"retry_backoff_seconds"`
MaxQueuedJobs int `json:"max_queued_jobs"`
MaxQueuedPayloadMB int `json:"max_queued_payload_mb"`
MasterApplyMaxAttempts int `json:"master_apply_max_attempts"`
MasterApplyBackoffSeconds int `json:"master_apply_backoff_seconds"`
JobRetentionHours int `json:"job_retention_hours"`
@@ -921,14 +922,15 @@ func DefaultConfig() Config {
c.Worker.DefaultMaxAttempts = 3
c.Worker.RetryBackoffSeconds = 15
c.Worker.MaxQueuedJobs = 5000
c.Worker.MaxQueuedPayloadMB = 128
c.Worker.MasterApplyMaxAttempts = 5
c.Worker.MasterApplyBackoffSeconds = 5
c.Worker.JobRetentionHours = 168
c.Worker.MaxTerminalJobs = 20000
c.Worker.JobRetentionHours = 24
c.Worker.MaxTerminalJobs = 2000
c.Worker.GraphBackfillEnabled = true
c.Worker.GraphBackfillIntervalS = 10
c.Worker.GraphBackfillBatchSize = 64
c.Worker.GraphBackfillMaxQueued = 512
c.Worker.GraphBackfillBatchSize = 16
c.Worker.GraphBackfillMaxQueued = 64
c.Worker.GraphBackfillMinDegree = 3
c.Worker.GraphCandidateMultiplier = 6
c.Worker.GraphRetryAfterMinutes = 360
@@ -59,7 +59,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.livez)
s.mux.HandleFunc("GET /livez", s.livez)
s.mux.HandleFunc("GET /readyz", s.readyz)
s.mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) { s.json(w, 200, map[string]any{"version": "0.8.2"}) })
s.mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) { s.json(w, 200, map[string]any{"version": "0.8.3"}) })
s.mux.Handle("POST /api/v1/chat", s.appAuth(http.HandlerFunc(s.chat)))
s.mux.Handle("POST /api/v1/learn", s.appAuth(http.HandlerFunc(s.learn)))
s.mux.Handle("POST /api/v1/search", s.appAuth(http.HandlerFunc(s.search)))
@@ -882,7 +882,7 @@ func (s *Server) requestLimits(next http.Handler) http.Handler {
}
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"})
s.json(w, http.StatusOK, map[string]any{"ok": true, "status": "alive", "time": time.Now().UTC(), "version": "0.8.3"})
}
func configuredModelAvailable(models map[string]bool, configured string) bool {
@@ -276,6 +276,9 @@ func (s *Server) metricsEndpoint(w http.ResponseWriter, r *http.Request) {
for _, status := range statuses {
promSample(&b, "neuroforge_jobs", orch.JobsByStatus[status], "status", status)
}
promHeader(&b, "neuroforge_job_payload_bytes", "Durable orchestrator payload/result bytes retained in memory and checkpoints.", "gauge")
promSample(&b, "neuroforge_job_payload_bytes", orch.PendingPayloadBytes, "state", "pending")
promSample(&b, "neuroforge_job_payload_bytes", orch.TerminalPayloadBytes, "state", "terminal")
promHeader(&b, "neuroforge_jobs_by_resource", "Current durable orchestrator jobs by resource class.", "gauge")
for resource, n := range orch.JobsByResource {
promSample(&b, "neuroforge_jobs_by_resource", n, "resource", resource)
@@ -169,7 +169,7 @@ func FetchResource(ctx context.Context, cfg FetchConfig, rawURL string) (Resourc
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown,text/csv,application/json;q=0.9,*/*;q=0.2")
ua := strings.TrimSpace(cfg.UserAgent)
if ua == "" {
ua = "NeuroForge/0.8.2 research bot"
ua = "NeuroForge/0.8.3 research bot"
}
req.Header.Set("User-Agent", ua)
resp, err := client.Do(req)
@@ -0,0 +1,56 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"neuroforge/internal/core"
)
func readCheckpointRevision(t *testing.T, dir string) uint64 {
t.Helper()
b, err := os.ReadFile(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatal(err)
}
var st core.PersistedState
if err := json.Unmarshal(b, &st); err != nil {
t.Fatal(err)
}
return st.Revision
}
func TestCheckpointDoesNotAdvanceStateBeforeIndexSnapshotSucceeds(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
cfg := s.Config()
cfg.Storage.CheckpointEvery = 1
if err := s.UpdateConfig(cfg); err != nil {
t.Fatal(err)
}
before := readCheckpointRevision(t, dir)
idxDir := filepath.Join(dir, "hnsw-index")
if err := os.RemoveAll(idxDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(idxDir, []byte("block directory creation"), 0600); err != nil {
t.Fatal(err)
}
m := &core.Memory{ID: "m1", Kind: core.MemorySemantic, MemoryType: core.MemorySemantic, Text: "x", Vector: []float32{1, 0, 0}, VectorDim: 3, Status: core.MemoryActive}
if err := s.AddMemory(m); err == nil {
t.Fatal("expected checkpoint/index failure")
}
after := readCheckpointRevision(t, dir)
if after != before {
t.Fatalf("state checkpoint advanced despite index failure: before=%d after=%d", before, after)
}
_ = os.Remove(idxDir)
_ = s.Close()
}
@@ -211,41 +211,28 @@ func (s *Store) writeSegmentedIndexSnapshotLocked() error {
return nil
}
current := s.currentSnapshotsLocked()
currentShadow := make(map[int]indexSnapshotShadow, len(s.indexes))
delta := indexDeltaBundle{Revision: s.state.Revision, Dimensions: map[string]indexDimensionDelta{}}
dims := map[int]bool{}
for dim := range current {
for dim := range s.indexes {
dims[dim] = true
}
for dim := range s.indexShadow {
dims[dim] = true
}
for dim := range dims {
cur, curOK := current[dim]
idx, curOK := s.indexes[dim]
prev, prevOK := s.indexShadow[dim]
key := strconv.Itoa(dim)
if !curOK {
if !curOK || idx == nil {
delta.Dimensions[key] = indexDimensionDelta{DeletedDimension: true}
continue
}
d := indexDimensionDelta{Config: cur.Config, EntryID: cur.EntryID, MaxLevel: cur.MaxLevel}
curIDs := map[string]bool{}
for _, n := range cur.Nodes {
curIDs[n.ID] = true
h := hashSnapshotNode(n)
if ph, ok := prev.Nodes[n.ID]; !prevOK || !ok || ph != h {
d.Upserts = append(d.Upserts, n)
}
}
if prevOK {
for id := range prev.Nodes {
if !curIDs[id] {
d.Deletes = append(d.Deletes, id)
}
}
}
sort.Strings(d.Deletes)
if !prevOK || len(d.Upserts) > 0 || len(d.Deletes) > 0 || prev.EntryID != cur.EntryID || prev.MaxLevel != cur.MaxLevel || prev.Config != cur.Config {
vprev := vector.HNSWShadow{Config: prev.Config, EntryID: prev.EntryID, MaxLevel: prev.MaxLevel, Nodes: prev.Nodes}
cur, upserts, deletes := idx.Delta(vprev)
currentShadow[dim] = indexSnapshotShadow{Config: cur.Config, EntryID: cur.EntryID, MaxLevel: cur.MaxLevel, Nodes: cur.Nodes}
d := indexDimensionDelta{Config: cur.Config, EntryID: cur.EntryID, MaxLevel: cur.MaxLevel, Upserts: upserts, Deletes: deletes}
if !prevOK || len(upserts) > 0 || len(deletes) > 0 || prev.EntryID != cur.EntryID || prev.MaxLevel != cur.MaxLevel || prev.Config != cur.Config {
delta.Dimensions[key] = d
}
}
@@ -258,7 +245,7 @@ func (s *Store) writeSegmentedIndexSnapshotLocked() error {
if err := writeAtomic(manifestPath, 0600, &manifest); err != nil {
return err
}
s.indexShadow = buildIndexShadow(current)
s.indexShadow = currentShadow
s.indexSnapshotRevision = s.state.Revision
s.indexDeltaCount = len(manifest.Deltas)
return nil
@@ -43,18 +43,20 @@ type WorkerHeartbeat struct {
}
type OrchestratorStatus struct {
Workers []core.WorkerState `json:"workers"`
JobsByStatus map[string]int `json:"jobs_by_status"`
JobsByType map[string]int `json:"jobs_by_type"`
JobsByResource map[string]int `json:"jobs_by_resource"`
OldestQueued time.Time `json:"oldest_queued,omitempty"`
Queued int `json:"queued"`
Claimed int `json:"claimed"`
Retrying int `json:"retrying"`
Applying int `json:"applying"`
Blocked int `json:"blocked"`
Failed int `json:"failed"`
Done int `json:"done"`
Workers []core.WorkerState `json:"workers"`
JobsByStatus map[string]int `json:"jobs_by_status"`
JobsByType map[string]int `json:"jobs_by_type"`
JobsByResource map[string]int `json:"jobs_by_resource"`
OldestQueued time.Time `json:"oldest_queued,omitempty"`
Queued int `json:"queued"`
Claimed int `json:"claimed"`
Retrying int `json:"retrying"`
Applying int `json:"applying"`
Blocked int `json:"blocked"`
Failed int `json:"failed"`
Done int `json:"done"`
PendingPayloadBytes int64 `json:"pending_payload_bytes"`
TerminalPayloadBytes int64 `json:"terminal_payload_bytes"`
}
func normalizeCapabilities(in []string) []string {
@@ -104,15 +106,22 @@ func (s *Store) EnqueueJobSpec(spec JobSpec) (*core.Job, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg := s.state.Config.Worker
if cfg.MaxQueuedJobs > 0 {
pending := 0
for _, j := range s.state.Jobs {
if j != nil && (j.Status == "queued" || j.Status == "claimed" || j.Status == "retry_wait" || j.Status == "blocked" || j.Status == "apply_wait") {
pending++
}
pending := 0
var pendingPayloadBytes int64
for _, j := range s.state.Jobs {
if j == nil || (j.Status != "queued" && j.Status != "claimed" && j.Status != "retry_wait" && j.Status != "blocked" && j.Status != "apply_wait") {
continue
}
if pending >= cfg.MaxQueuedJobs {
return nil, fmt.Errorf("orchestrator queue full: %d >= %d", pending, cfg.MaxQueuedJobs)
pending++
pendingPayloadBytes += int64(len(j.Payload) + len(j.Result))
}
if cfg.MaxQueuedJobs > 0 && pending >= cfg.MaxQueuedJobs {
return nil, fmt.Errorf("orchestrator queue full: %d >= %d", pending, cfg.MaxQueuedJobs)
}
if cfg.MaxQueuedPayloadMB > 0 {
limit := int64(cfg.MaxQueuedPayloadMB) << 20
if pendingPayloadBytes+int64(len(b)) > limit {
return nil, fmt.Errorf("orchestrator queued payload budget exceeded: %d + %d > %d bytes", pendingPayloadBytes, len(b), limit)
}
}
if key := strings.TrimSpace(spec.IdempotencyKey); key != "" {
@@ -603,6 +612,13 @@ func (s *Store) OrchestratorStatus() OrchestratorStatus {
if j == nil {
continue
}
payloadBytes := int64(len(j.Payload) + len(j.Result))
switch j.Status {
case "queued", "claimed", "retry_wait", "blocked", "apply_wait":
out.PendingPayloadBytes += payloadBytes
case "done", "failed", "canceled":
out.TerminalPayloadBytes += payloadBytes
}
out.JobsByStatus[j.Status]++
out.JobsByType[j.Type]++
resource := j.ResourceClass
@@ -701,6 +717,15 @@ func (s *Store) FinishMasterApply(id string, applyErr error) (*core.Job, error)
j.ApplyError = ""
j.ApplyNextAttemptAt = time.Time{}
j.FinishedAt = now
// vector.relink payloads contain the target plus a bounded set of full
// candidate vectors. Once the authoritative master apply succeeded these
// blobs have no retry value and retaining thousands of them can consume
// gigabytes during a large graph backfill. Keep the durable audit metadata
// (type/idempotency/status/timestamps) but release the transient vectors.
if j.Type == "vector.relink" {
j.Payload = nil
j.Result = nil
}
} else {
j.ApplyError = strings.TrimSpace(applyErr.Error())
maxAttempts := j.MaxApplyAttempts
@@ -732,6 +757,41 @@ func (s *Store) FinishMasterApply(id string, applyErr error) (*core.Job, error)
return &cp, s.commitLocked("job.upsert", cp)
}
// compactCompletedRelinkJobsLocked removes transient vector blobs from jobs
// that already reached their durable terminal state. It is also used on boot to
// migrate v1.6.0 checkpoints that may contain thousands of completed backfill
// payloads. Caller must hold s.mu.
func (s *Store) compactCompletedRelinkJobsLocked() int {
n := 0
for _, j := range s.state.Jobs {
if j == nil || j.Type != "vector.relink" || j.Status != "done" {
continue
}
if len(j.Payload) == 0 && len(j.Result) == 0 {
continue
}
j.Payload = nil
j.Result = nil
n++
}
return n
}
func (s *Store) CompactCompletedRelinkJobs() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
n := s.compactCompletedRelinkJobsLocked()
if n == 0 {
return 0, nil
}
// One checkpoint is substantially cheaper than one WAL event per historical
// job and atomically rewrites state.json without the obsolete vector blobs.
if err := s.checkpointLocked(); err != nil {
return 0, err
}
return n, nil
}
func (s *Store) CancelJob(id, reason string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -292,3 +292,52 @@ func TestApplyWaitJobSurvivesRestart(t *testing.T) {
t.Fatalf("recovered master apply queue=%+v", pending)
}
}
func TestOrchestratorQueuedPayloadBudget(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.mu.Lock()
s.state.Config.Worker.MaxQueuedPayloadMB = 1
s.mu.Unlock()
blob := strings.Repeat("x", 700<<10)
if _, err := s.EnqueueJobSpec(JobSpec{Type: "large", Payload: map[string]any{"blob": blob}, ResourceClass: "cpu"}); err != nil {
t.Fatal(err)
}
if _, err := s.EnqueueJobSpec(JobSpec{Type: "large2", Payload: map[string]any{"blob": blob}, ResourceClass: "cpu"}); err == nil || !strings.Contains(err.Error(), "payload budget") {
t.Fatalf("expected payload budget rejection, err=%v", err)
}
}
func TestCompletedRelinkDropsTransientVectorBlobs(t *testing.T) {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer s.Close()
j, err := s.EnqueueJobSpec(JobSpec{Type: "vector.relink", Payload: map[string]any{"target": []float32{1, 2, 3}}, ResourceClass: "cpu", RequiredCapabilities: []string{"cpu", "vector.relink"}, RequiresMasterApply: true})
if err != nil {
t.Fatal(err)
}
w := WorkerHeartbeat{ID: "cpu", ResourceClass: "cpu", Capabilities: []string{"cpu", "vector.relink"}, MaxConcurrency: 1}
claimed, err := s.ClaimJobForWorker(w, time.Minute)
if err != nil || claimed == nil || claimed.ID != j.ID {
t.Fatalf("claim=%+v err=%v", claimed, err)
}
waiting, err := s.CompleteJobLease(j.ID, w.ID, claimed.LeaseToken, json.RawMessage(`{"target_id":"x","neighbors":[]}`), "")
if err != nil {
t.Fatal(err)
}
if waiting.Status != "apply_wait" {
t.Fatalf("status=%s", waiting.Status)
}
done, err := s.FinishMasterApply(j.ID, nil)
if err != nil {
t.Fatal(err)
}
if done.Status != "done" || len(done.Payload) != 0 || len(done.Result) != 0 {
t.Fatalf("done job retained transient blobs: %+v", done)
}
}
@@ -0,0 +1,120 @@
package store
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"neuroforge/internal/core"
)
func TestNewFailsClosedOnCorruptAuthoritativeState(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, v161JobCompactionMarker), []byte("compacted=0\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "state.json"), []byte(`{"config":`), 0600); err != nil {
t.Fatal(err)
}
if _, err := New(dir); err == nil || !strings.Contains(err.Error(), "load authoritative state checkpoint") {
t.Fatalf("New error=%v, want authoritative state corruption failure", err)
}
}
func TestNewFailsClosedOnCorruptAuthoritativeSecrets(t *testing.T) {
dir := t.TempDir()
st := core.PersistedState{
Config: core.DefaultConfig(),
Memories: map[string]*core.Memory{},
Synapses: map[string]*core.Synapse{},
Jobs: map[string]*core.Job{},
Goals: map[string]*core.Goal{},
Sources: map[string]*core.KnowledgeSource{},
ResearchRuns: map[string]*core.ResearchRun{},
}
if err := writeAtomic(filepath.Join(dir, "state.json"), 0600, &st); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "secrets.json"), []byte(`{"admin_token":"unterminated}`), 0600); err != nil {
t.Fatal(err)
}
if _, err := New(dir); err == nil || !strings.Contains(err.Error(), "load authoritative secrets") {
t.Fatalf("New error=%v, want authoritative secrets corruption failure", err)
}
}
func TestLoadJSONRejectsTrailingJSONValue(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
if err := os.WriteFile(path, []byte(`{"revision":1}{"revision":2}`), 0600); err != nil {
t.Fatal(err)
}
s := &Store{}
var st core.PersistedState
if err := s.loadJSON(path, &st); err == nil || !strings.Contains(err.Error(), "unexpected trailing JSON token") {
t.Fatalf("loadJSON error=%v, want trailing JSON rejection", err)
}
}
func TestReplayWALCompactsCompletedRelinkPayloadImmediately(t *testing.T) {
dir := t.TempDir()
walDir := filepath.Join(dir, "wal")
if err := os.MkdirAll(walDir, 0700); err != nil {
t.Fatal(err)
}
job := core.Job{
ID: "job_done_relink",
Type: "vector.relink",
Status: "done",
Payload: json.RawMessage(`{"target":{"vector":[1,2,3]},"candidates":[{"vector":[4,5,6]}]}`),
Result: json.RawMessage(`{"edges":[{"a":"a","b":"b","weight":0.9}]}`),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
data, err := json.Marshal(job)
if err != nil {
t.Fatal(err)
}
ev := walEvent{Revision: 1, Time: time.Now().UTC(), Type: "job.upsert", Data: data}
f, err := os.OpenFile(filepath.Join(walDir, "wal-active.jsonl"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
t.Fatal(err)
}
bw := bufio.NewWriter(f)
if err := json.NewEncoder(bw).Encode(ev); err != nil {
t.Fatal(err)
}
if err := bw.Flush(); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
s, err := New(dir)
if err != nil {
t.Fatal(err)
}
defer s.Close()
got, ok := s.Job(job.ID)
if !ok {
t.Fatal("replayed job missing")
}
if len(got.Payload) != 0 || len(got.Result) != 0 {
t.Fatalf("replayed completed relink retained vector blobs: payload=%d result=%d", len(got.Payload), len(got.Result))
}
}
func TestFreshDirectoryDoesNotConsumeV161CompactionMarker(t *testing.T) {
dir := t.TempDir()
if n, err := compactLegacyTerminalRelinkCheckpoint(dir); err != nil || n != 0 {
t.Fatalf("compact fresh dir = %d, %v", n, err)
}
if _, err := os.Stat(filepath.Join(dir, v161JobCompactionMarker)); !os.IsNotExist(err) {
t.Fatalf("fresh directory unexpectedly created migration marker: %v", err)
}
}
@@ -0,0 +1,277 @@
package store
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"neuroforge/internal/core"
)
const v161JobCompactionMarker = ".migration-v1.6.1-terminal-relink-compaction"
// compactLegacyTerminalRelinkCheckpoint is a one-time, streaming migration for
// v1.6.0 checkpoints. That release retained full target/candidate vectors in
// completed vector.relink jobs. A large graph backfill can therefore make
// state.json hundreds of MB or larger and cause OOM during the next startup.
//
// The migration deliberately runs before state.json is unmarshaled. It rewrites
// JSON token-by-token and only materializes one Job at a time, so peak memory is
// bounded by the largest single job instead of the whole checkpoint.
func compactLegacyTerminalRelinkCheckpoint(dir string) (int, error) {
marker := filepath.Join(dir, v161JobCompactionMarker)
if _, err := os.Stat(marker); err == nil {
return 0, nil
}
path := filepath.Join(dir, "state.json")
in, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// Fresh data directory. Do not create the migration marker yet: an
// operator may restore a v1.6.0 checkpoint into this directory before
// the next boot, and that restored checkpoint must still be compacted.
return 0, nil
}
return 0, err
}
defer in.Close()
tmp := path + ".v161-compact.tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
if err != nil {
return 0, err
}
ok := false
defer func() {
_ = out.Close()
if !ok {
_ = os.Remove(tmp)
}
}()
dec := json.NewDecoder(bufio.NewReaderSize(in, 1<<20))
dec.UseNumber()
bw := bufio.NewWriterSize(out, 1<<20)
compacted, err := rewriteCheckpointObject(dec, bw)
if err != nil {
return 0, err
}
if err := bw.Flush(); err != nil {
return 0, err
}
if err := out.Sync(); err != nil {
return 0, err
}
if err := out.Close(); err != nil {
return 0, err
}
if compacted > 0 {
if err := os.Rename(tmp, path); err != nil {
return 0, err
}
} else {
_ = os.Remove(tmp)
}
if err := os.WriteFile(marker, []byte(fmt.Sprintf("compacted=%d\n", compacted)), 0600); err != nil {
return 0, err
}
ok = true
return compacted, nil
}
func rewriteCheckpointObject(dec *json.Decoder, w *bufio.Writer) (int, error) {
tok, err := dec.Token()
if err != nil {
return 0, err
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return 0, errors.New("state checkpoint must be a JSON object")
}
if err := w.WriteByte('{'); err != nil {
return 0, err
}
first := true
compacted := 0
for dec.More() {
kt, err := dec.Token()
if err != nil {
return 0, err
}
key, ok := kt.(string)
if !ok {
return 0, errors.New("state checkpoint object key is not a string")
}
if !first {
if err := w.WriteByte(','); err != nil {
return 0, err
}
}
first = false
kb, _ := json.Marshal(key)
if _, err := w.Write(kb); err != nil {
return 0, err
}
if err := w.WriteByte(':'); err != nil {
return 0, err
}
if key == "jobs" {
n, err := rewriteJobsObject(dec, w)
if err != nil {
return 0, err
}
compacted += n
continue
}
if err := copyJSONValue(dec, w); err != nil {
return 0, err
}
}
if _, err := dec.Token(); err != nil { // closing }
return 0, err
}
if err := w.WriteByte('}'); err != nil {
return 0, err
}
if tok, err := dec.Token(); err != io.EOF {
if err == nil {
return 0, fmt.Errorf("unexpected trailing JSON token %v", tok)
}
return 0, err
}
return compacted, nil
}
func rewriteJobsObject(dec *json.Decoder, w *bufio.Writer) (int, error) {
tok, err := dec.Token()
if err != nil {
return 0, err
}
if tok == nil {
_, err = w.WriteString("null")
return 0, err
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return 0, errors.New("jobs must be a JSON object")
}
if err := w.WriteByte('{'); err != nil {
return 0, err
}
first := true
compacted := 0
for dec.More() {
kt, err := dec.Token()
if err != nil {
return 0, err
}
key := kt.(string)
var job core.Job
if err := dec.Decode(&job); err != nil {
return 0, err
}
if job.Type == "vector.relink" && job.Status == "done" {
if len(job.Payload) > 0 || len(job.Result) > 0 {
compacted++
}
job.Payload = nil
job.Result = nil
}
if !first {
if err := w.WriteByte(','); err != nil {
return 0, err
}
}
first = false
kb, _ := json.Marshal(key)
jb, err := json.Marshal(job)
if err != nil {
return 0, err
}
if _, err := w.Write(kb); err != nil {
return 0, err
}
if err := w.WriteByte(':'); err != nil {
return 0, err
}
if _, err := w.Write(jb); err != nil {
return 0, err
}
}
if _, err := dec.Token(); err != nil {
return 0, err
}
return compacted, w.WriteByte('}')
}
func copyJSONValue(dec *json.Decoder, w *bufio.Writer) error {
tok, err := dec.Token()
if err != nil {
return err
}
if d, ok := tok.(json.Delim); ok {
switch d {
case '{':
if err := w.WriteByte('{'); err != nil {
return err
}
first := true
for dec.More() {
kt, err := dec.Token()
if err != nil {
return err
}
if !first {
if err := w.WriteByte(','); err != nil {
return err
}
}
first = false
kb, _ := json.Marshal(kt.(string))
if _, err := w.Write(kb); err != nil {
return err
}
if err := w.WriteByte(':'); err != nil {
return err
}
if err := copyJSONValue(dec, w); err != nil {
return err
}
}
if _, err := dec.Token(); err != nil {
return err
}
return w.WriteByte('}')
case '[':
if err := w.WriteByte('['); err != nil {
return err
}
first := true
for dec.More() {
if !first {
if err := w.WriteByte(','); err != nil {
return err
}
}
first = false
if err := copyJSONValue(dec, w); err != nil {
return err
}
}
if _, err := dec.Token(); err != nil {
return err
}
return w.WriteByte(']')
default:
return fmt.Errorf("unexpected JSON delimiter %q", d)
}
}
b, err := json.Marshal(tok)
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
@@ -0,0 +1,56 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"neuroforge/internal/core"
)
func TestCompactLegacyTerminalRelinkCheckpoint(t *testing.T) {
dir := t.TempDir()
st := core.PersistedState{Config: core.DefaultConfig(), Jobs: map[string]*core.Job{}, Synapses: map[string]*core.Synapse{}, Goals: map[string]*core.Goal{}}
st.Jobs["done"] = &core.Job{ID: "done", Type: "vector.relink", Status: "done", Payload: json.RawMessage(`{"target":[1,2,3]}`), Result: json.RawMessage(`{"ok":true}`)}
st.Jobs["queued"] = &core.Job{ID: "queued", Type: "vector.relink", Status: "queued", Payload: json.RawMessage(`{"target":[4,5,6]}`)}
st.Jobs["other"] = &core.Job{ID: "other", Type: "model.chat", Status: "done", Payload: json.RawMessage(`{"prompt":"keep"}`), Result: json.RawMessage(`{"text":"keep"}`)}
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "state.json"), b, 0600); err != nil {
t.Fatal(err)
}
n, err := compactLegacyTerminalRelinkCheckpoint(dir)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("compacted=%d want 1", n)
}
var got core.PersistedState
bb, err := os.ReadFile(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(bb, &got); err != nil {
t.Fatal(err)
}
cleared := func(b json.RawMessage) bool { return len(b) == 0 || string(b) == "null" }
if !cleared(got.Jobs["done"].Payload) || !cleared(got.Jobs["done"].Result) {
t.Fatalf("done relink blobs not cleared: payload=%q result=%q", got.Jobs["done"].Payload, got.Jobs["done"].Result)
}
if len(got.Jobs["queued"].Payload) == 0 {
t.Fatalf("queued relink payload must be retained")
}
if len(got.Jobs["other"].Payload) == 0 || len(got.Jobs["other"].Result) == 0 {
t.Fatalf("unrelated job blobs must be retained")
}
if n2, err := compactLegacyTerminalRelinkCheckpoint(dir); err != nil || n2 != 0 {
t.Fatalf("second migration = %d, %v", n2, err)
}
}
+107 -10
View File
@@ -1,11 +1,13 @@
package store
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/url"
"os"
@@ -49,7 +51,19 @@ type Store struct {
synapseAdj map[string]map[string]*core.Synapse
}
type OpenProgressFunc func(phase string)
func New(dir string) (*Store, error) {
return NewWithProgress(dir, nil)
}
func NewWithProgress(dir string, report OpenProgressFunc) (*Store, error) {
progress := func(phase string) {
if report != nil {
report(phase)
}
}
progress("store.prepare")
if dir == "" {
dir = "./data"
}
@@ -58,8 +72,18 @@ func New(dir string) (*Store, error) {
}
s := &Store{dir: dir, indexes: map[int]*vector.HNSW{}, diskIndexes: map[int]*vector.PQIndex{}, provenanceSourceIDs: map[string]map[string]struct{}{}, workers: map[string]core.WorkerState{}, synapseAdj: map[string]map[string]*core.Synapse{}}
s.state = core.PersistedState{Config: core.DefaultConfig(), Memories: map[string]*core.Memory{}, Synapses: map[string]*core.Synapse{}, Jobs: map[string]*core.Job{}, Goals: map[string]*core.Goal{}, Sources: map[string]*core.KnowledgeSource{}, ResearchRuns: map[string]*core.ResearchRun{}}
_ = s.loadJSON(filepath.Join(dir, "state.json"), &s.state)
_ = s.loadJSON(filepath.Join(dir, "secrets.json"), &s.secrets)
progress("checkpoint.precompact")
if _, err := compactLegacyTerminalRelinkCheckpoint(dir); err != nil {
return nil, fmt.Errorf("compact legacy terminal relink jobs: %w", err)
}
progress("checkpoint.load")
if err := s.loadJSON(filepath.Join(dir, "state.json"), &s.state); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("load authoritative state checkpoint: %w", err)
}
progress("secrets.load")
if err := s.loadJSON(filepath.Join(dir, "secrets.json"), &s.secrets); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("load authoritative secrets: %w", err)
}
if s.state.Memories == nil {
s.state.Memories = map[string]*core.Memory{}
}
@@ -84,6 +108,7 @@ func New(dir string) (*Store, error) {
// v0.6 binary vector sidecar: rebuildable acceleration data used by the
// disk ANN builder. Corruption must never prevent the authoritative memory
// store from opening; move a bad cache aside and recreate it empty.
progress("vector-journal.open")
vjPath := filepath.Join(dir, "vector-journal.nfv")
vj, vjErr := openVectorJournal(vjPath, vectorJournalOptionsFromConfig(s.state.Config))
if vjErr != nil {
@@ -95,6 +120,7 @@ func New(dir string) (*Store, error) {
}
if s.state.Config.Storage.Segments.Enabled {
progress("memory-segments.scan")
seg, err := openSegmentStore(filepath.Join(dir, "memory-segments"), s.state.Config.Storage.Segments.MaxSegmentBytes, s.state.Config.Storage.Segments.MmapSealed)
if err != nil {
return nil, fmt.Errorf("open memory segments: %w", err)
@@ -110,9 +136,13 @@ func New(dir string) (*Store, error) {
s.state.Memories = meta
}
}
progress("wal.replay")
if err := s.replayWAL(); err != nil {
return nil, err
}
progress("jobs.compact")
_ = s.compactCompletedRelinkJobsLocked()
progress("graph.restore")
s.rebuildSynapseAdjLocked()
applyNewDefaults(&s.state.Config)
if s.state.Cluster.Term < s.state.Config.Cluster.Term {
@@ -198,19 +228,26 @@ func New(dir string) (*Store, error) {
if s.secrets.ClusterToken == "" {
s.secrets.ClusterToken = randomID(24)
}
progress("disk-ann.load")
_ = s.loadDiskANNLocked()
progress("hnsw.snapshot.load")
if !s.loadIndexSnapshotLocked() {
progress("hnsw.rebuild")
s.rebuildIndexesLocked()
}
progress("secrets.persist")
if err := s.persistSecretsLocked(); err != nil {
return nil, err
}
progress("checkpoint.write")
if err := s.checkpointLocked(); err != nil {
return nil, err
}
if s.state.Config.Storage.Tiering.Enabled && s.segments != nil {
progress("tiering.initialize")
s.tierMemoryBodiesLocked(time.Now().UTC())
}
progress("store.ready")
return s, nil
}
@@ -524,6 +561,9 @@ func applyNewDefaults(c *core.Config) {
if c.Worker.MaxQueuedJobs == 0 {
c.Worker.MaxQueuedJobs = d.Worker.MaxQueuedJobs
}
if c.Worker.MaxQueuedPayloadMB == 0 {
c.Worker.MaxQueuedPayloadMB = d.Worker.MaxQueuedPayloadMB
}
if c.Worker.MasterApplyMaxAttempts == 0 {
c.Worker.MasterApplyMaxAttempts = d.Worker.MasterApplyMaxAttempts
}
@@ -613,23 +653,80 @@ func inferMemoryType(kind string) string {
}
func (s *Store) loadJSON(path string, v any) error {
b, err := os.ReadFile(path)
f, err := os.Open(path)
if err != nil {
return err
}
return json.Unmarshal(b, v)
defer f.Close()
// Decode directly from the file instead of ReadFile+Unmarshal. Large
// checkpoints can contain millions of graph edges/jobs; keeping a second
// raw []byte copy of state.json during boot needlessly doubles peak memory.
dec := json.NewDecoder(bufio.NewReaderSize(f, 1<<20))
if err := dec.Decode(v); err != nil {
return fmt.Errorf("decode %s: %w", filepath.Base(path), err)
}
// Reject a second JSON value/trailing non-whitespace. Silently accepting a
// partially corrupt authoritative checkpoint can create split-brain state.
if tok, err := dec.Token(); err != io.EOF {
if err == nil {
return fmt.Errorf("decode %s: unexpected trailing JSON token %v", filepath.Base(path), tok)
}
return fmt.Errorf("decode %s trailing data: %w", filepath.Base(path), err)
}
return nil
}
func writeAtomic(path string, perm os.FileMode, v any) error {
b, err := json.MarshalIndent(v, "", " ")
func writeAtomic(path string, perm os.FileMode, v any) (retErr error) {
// Stream JSON directly into the temporary file instead of MarshalIndent+
// WriteFile. state.json and index manifests can be large; allocating the
// complete encoded checkpoint as another in-memory byte slice is avoidable.
tmp := path + ".tmp"
_ = os.Remove(tmp)
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, perm)
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, perm); err != nil {
closed := false
defer func() {
if !closed {
_ = f.Close()
}
if retErr != nil {
_ = os.Remove(tmp)
}
}()
bw := bufio.NewWriterSize(f, 1<<20)
enc := json.NewEncoder(bw)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
return err
}
return os.Rename(tmp, path)
if err := bw.Flush(); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
if err := f.Close(); err != nil {
return err
}
closed = true
if err := os.Rename(tmp, path); err != nil {
return err
}
// Persist the directory entry as well. This matters for secrets/state after
// host power loss and is cheap compared with the checkpoint itself.
d, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
if err := d.Sync(); err != nil {
_ = d.Close()
return err
}
return d.Close()
}
func (s *Store) persistLocked() error {
@@ -1609,7 +1706,7 @@ func (s *Store) validateConfigLocked(c core.Config) error {
if c.Worker.LeaseSeconds < 10 || c.Worker.LeaseSeconds > 3600 || c.Worker.HeartbeatSeconds < 2 || c.Worker.HeartbeatSeconds >= c.Worker.LeaseSeconds || c.Worker.StaleAfterSeconds < c.Worker.HeartbeatSeconds || c.Worker.StaleAfterSeconds > 7200 {
return errors.New("invalid worker lease/heartbeat/stale timing")
}
if c.Worker.DefaultMaxAttempts < 1 || c.Worker.DefaultMaxAttempts > 20 || c.Worker.RetryBackoffSeconds < 1 || c.Worker.RetryBackoffSeconds > 3600 || c.Worker.MaxQueuedJobs < 16 || c.Worker.MaxQueuedJobs > 1000000 || c.Worker.MasterApplyMaxAttempts < 1 || c.Worker.MasterApplyMaxAttempts > 20 || c.Worker.MasterApplyBackoffSeconds < 1 || c.Worker.MasterApplyBackoffSeconds > 3600 || c.Worker.JobRetentionHours < 1 || c.Worker.JobRetentionHours > 8760 || c.Worker.MaxTerminalJobs < 100 || c.Worker.MaxTerminalJobs > 1000000 {
if c.Worker.DefaultMaxAttempts < 1 || c.Worker.DefaultMaxAttempts > 20 || c.Worker.RetryBackoffSeconds < 1 || c.Worker.RetryBackoffSeconds > 3600 || c.Worker.MaxQueuedJobs < 16 || c.Worker.MaxQueuedJobs > 1000000 || c.Worker.MaxQueuedPayloadMB < 16 || c.Worker.MaxQueuedPayloadMB > 65536 || c.Worker.MasterApplyMaxAttempts < 1 || c.Worker.MasterApplyMaxAttempts > 20 || c.Worker.MasterApplyBackoffSeconds < 1 || c.Worker.MasterApplyBackoffSeconds > 3600 || c.Worker.JobRetentionHours < 1 || c.Worker.JobRetentionHours > 8760 || c.Worker.MaxTerminalJobs < 100 || c.Worker.MaxTerminalJobs > 1000000 {
return errors.New("invalid worker retry/queue/retention configuration")
}
if c.Worker.GraphBackfillIntervalS < 2 || c.Worker.GraphBackfillIntervalS > 3600 || c.Worker.GraphBackfillBatchSize < 1 || c.Worker.GraphBackfillBatchSize > 4096 || c.Worker.GraphBackfillMaxQueued < 1 || c.Worker.GraphBackfillMaxQueued > c.Worker.MaxQueuedJobs || c.Worker.GraphBackfillMinDegree < 1 || c.Worker.GraphBackfillMinDegree > 100 || c.Worker.GraphCandidateMultiplier < 2 || c.Worker.GraphCandidateMultiplier > 64 || c.Worker.GraphRetryAfterMinutes < 1 || c.Worker.GraphRetryAfterMinutes > 43200 {
+17 -3
View File
@@ -232,6 +232,14 @@ func (s *Store) applyWALEvent(ev walEvent) error {
if err := json.Unmarshal(ev.Data, &x); err != nil {
return err
}
// v1.6.0 could leave a large WAL containing completed vector.relink
// jobs with full target/candidate vectors. Compact each terminal event as
// it is replayed so recovery memory remains bounded by one WAL record
// instead of accumulating every historical vector payload in state.
if x.Type == "vector.relink" && x.Status == "done" {
x.Payload = nil
x.Result = nil
}
s.state.Jobs[x.ID] = &x
case "job.delete":
var ids []string
@@ -332,14 +340,20 @@ func (s *Store) checkpointLocked() error {
// Keep state.json O(non-memory-state) instead of O(memory-count).
checkpoint.Memories = nil
}
if err := writeAtomic(filepath.Join(s.dir, "state.json"), 0600, &checkpoint); err != nil {
return err
}
// Persist acceleration state before the authoritative non-memory checkpoint.
// If the process dies after the index snapshot but before state.json, the WAL
// remains intact; boot replays it to the same revision and can immediately use
// the already-written index. The previous order could advance state.json first,
// then die during a large HNSW snapshot and force a full synchronous rebuild on
// every restart.
if s.state.Config.Storage.IndexSnapshot && s.state.Config.Brain.Index.Enabled {
if err := s.writeIndexSnapshotLocked(); err != nil {
return err
}
}
if err := writeAtomic(filepath.Join(s.dir, "state.json"), 0600, &checkpoint); err != nil {
return err
}
// The checkpoint and (when enabled) memory segments now cover every WAL
// event through state.Revision. Prune those already-checkpointed log files
// only after all checkpoint artifacts succeeded, otherwise long bulk
+75 -23
View File
@@ -679,6 +679,50 @@ type HNSWShadow struct {
Nodes map[string][32]byte
}
func (h *HNSW) nodeFingerprintLocked(n *hnswNode) [32]byte {
hash := sha256.New()
writeHashString(hash, n.ID)
writeHashU32(hash, uint32(n.Level))
writeHashU32(hash, uint32(len(n.Vector)))
var b [4]byte
for _, x := range n.Vector {
binary.LittleEndian.PutUint32(b[:], math.Float32bits(x))
_, _ = hash.Write(b[:])
}
for level := 0; level <= n.Level; level++ {
writeHashU32(hash, uint32(level))
edges := n.Neighbors[level]
writeHashU32(hash, uint32(len(edges)))
for _, edge := range edges {
idx := int(edge.idx)
if idx >= 0 && idx < len(h.nodes) {
writeHashString(hash, h.nodes[idx].ID)
}
}
}
var sum [32]byte
copy(sum[:], hash.Sum(nil))
return sum
}
func (h *HNSW) snapshotNodeLocked(n *hnswNode) HNSWSnapshotNode {
cn := HNSWSnapshotNode{ID: n.ID, Vector: append([]float32(nil), n.Vector...), Level: n.Level, Neighbors: map[int][]string{}}
for level, ids := range n.Neighbors {
if len(ids) == 0 {
continue
}
refs := make([]string, 0, len(ids))
for _, edge := range ids {
idx := int(edge.idx)
if idx >= 0 && idx < len(h.nodes) {
refs = append(refs, h.nodes[idx].ID)
}
}
cn.Neighbors[level] = refs
}
return cn
}
func (h *HNSW) Shadow() HNSWShadow {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -688,33 +732,41 @@ func (h *HNSW) Shadow() HNSWShadow {
}
out := HNSWShadow{Config: h.cfg, EntryID: entryID, MaxLevel: h.maxLevel, Nodes: make(map[string][32]byte, len(h.nodes))}
for _, n := range h.nodes {
hash := sha256.New()
writeHashString(hash, n.ID)
writeHashU32(hash, uint32(n.Level))
writeHashU32(hash, uint32(len(n.Vector)))
var b [4]byte
for _, x := range n.Vector {
binary.LittleEndian.PutUint32(b[:], math.Float32bits(x))
_, _ = hash.Write(b[:])
}
for level := 0; level <= n.Level; level++ {
writeHashU32(hash, uint32(level))
edges := n.Neighbors[level]
writeHashU32(hash, uint32(len(edges)))
for _, edge := range edges {
idx := int(edge.idx)
if idx >= 0 && idx < len(h.nodes) {
writeHashString(hash, h.nodes[idx].ID)
}
}
}
var sum [32]byte
copy(sum[:], hash.Sum(nil))
out.Nodes[n.ID] = sum
out.Nodes[n.ID] = h.nodeFingerprintLocked(n)
}
return out
}
// Delta returns only nodes whose vector/graph representation changed since prev,
// plus a compact hash shadow for the current graph. Unlike Snapshot(), this does
// not deep-copy every vector on each checkpoint, which keeps bulk-ingest memory
// bounded as the HNSW grows.
func (h *HNSW) Delta(prev HNSWShadow) (HNSWShadow, []HNSWSnapshotNode, []string) {
h.mu.RLock()
defer h.mu.RUnlock()
entryID := ""
if h.entry >= 0 && h.entry < len(h.nodes) {
entryID = h.nodes[h.entry].ID
}
cur := HNSWShadow{Config: h.cfg, EntryID: entryID, MaxLevel: h.maxLevel, Nodes: make(map[string][32]byte, len(h.nodes))}
upserts := make([]HNSWSnapshotNode, 0)
for _, n := range h.nodes {
fp := h.nodeFingerprintLocked(n)
cur.Nodes[n.ID] = fp
if old, ok := prev.Nodes[n.ID]; !ok || old != fp {
upserts = append(upserts, h.snapshotNodeLocked(n))
}
}
deletes := make([]string, 0)
for id := range prev.Nodes {
if _, ok := cur.Nodes[id]; !ok {
deletes = append(deletes, id)
}
}
sort.Strings(deletes)
return cur, upserts, deletes
}
func writeHashString(w io.Writer, s string) {
writeHashU32(w, uint32(len(s)))
_, _ = io.WriteString(w, s)
@@ -0,0 +1,38 @@
package vector
import "testing"
func TestHNSWDeltaDoesNotSnapshotUnchangedVectors(t *testing.T) {
h := NewHNSW(HNSWConfig{M: 4, EfConstruction: 16, EfSearch: 8})
h.Add("a", []float32{1, 0, 0})
h.Add("b", []float32{0.9, 0.1, 0})
base := h.Shadow()
cur, upserts, deletes := h.Delta(base)
if len(upserts) != 0 || len(deletes) != 0 {
t.Fatalf("unchanged delta upserts=%d deletes=%d", len(upserts), len(deletes))
}
if len(cur.Nodes) != 2 {
t.Fatalf("shadow nodes=%d", len(cur.Nodes))
}
h.Add("c", []float32{0.8, 0.2, 0})
cur2, upserts, deletes := h.Delta(base)
if len(upserts) == 0 {
t.Fatalf("expected changed/new nodes")
}
if len(deletes) != 0 {
t.Fatalf("unexpected deletes: %v", deletes)
}
if len(cur2.Nodes) != 3 {
t.Fatalf("shadow nodes=%d", len(cur2.Nodes))
}
foundC := false
for _, n := range upserts {
if n.ID == "c" {
foundC = true
}
}
if !foundC {
t.Fatalf("new node c missing from delta")
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: NeuroForge API
version: 0.8.2
version: 0.8.3
description: REST API for NeuroForge associative learning, explainable vector recall,
provenance, document/text ingestion, SearXNG-backed autonomous research, source-grounded
goal cycles, responsive knowledge graph, model routing, cost controls, Prometheus
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env sh
set -eu
NETWORK="${CORE_NETWORK:-glpi-ai-core}"
if docker network inspect "$NETWORK" >/dev/null 2>&1; then
echo "Docker-Netzwerk $NETWORK existiert bereits."
else
docker network create "$NETWORK" >/dev/null
echo "Docker-Netzwerk $NETWORK wurde angelegt."
fi
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
AGENT_URL="${AGENT_URL:-http://127.0.0.1:9980}"
KNOWLEDGE_URL="${KNOWLEDGE_URL:-http://127.0.0.1:9981}"
printf 'Agent /healthz: '
curl -fsS "$AGENT_URL/healthz" || true
echo
printf 'Agent /readyz: '
curl -fsS "$AGENT_URL/readyz" || true
echo
printf 'Knowledge /api/health: '
curl -fsS "$KNOWLEDGE_URL/api/health" || true
echo
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -eu
ROLE="${1:-}"
case "$ROLE" in agent|knowledge|ollama|combined) ;; *) echo "Usage: $0 agent|knowledge|ollama|combined" >&2; exit 2;; esac
DIR="$(CDPATH= cd -- "$(dirname -- "$0")/../deployments/$ROLE" && pwd)"
ENVFILE="$DIR/.env"
[ -f "$ENVFILE" ] || { echo "Fehlt: $ENVFILE" >&2; exit 1; }
if grep -Eq '^[A-Z0-9_]+=CHANGE_ME' "$ENVFILE"; then
echo "Hinweis: CHANGE_ME-Werte vorhanden:"
grep -E '^[A-Z0-9_]+=CHANGE_ME' "$ENVFILE" | cut -d= -f1
if [ "$ROLE" = agent ] || [ "$ROLE" = combined ]; then exit 1; fi
fi
mkdir -p "$DIR/../../runtime/agent-data" "$DIR/../../runtime/knowledge" "$DIR/../../runtime/backups" "$DIR/../../runtime/staging" "$DIR/../../runtime/ollama"
if command -v docker >/dev/null 2>&1; then
(cd "$DIR" && docker compose config >/dev/null)
echo "docker compose config: OK ($ROLE)"
else
echo "Docker CLI nicht vorhanden; YAML wurde beim Release statisch geprüft."
fi
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+29 -1
View File
@@ -23,7 +23,11 @@ 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
# Distributed deployment bundles intentionally contain three complete `.env`
# templates. They must remain placeholder-only; every other private env/key file
# is still forbidden.
if grep -E '(^|/)\.env$|\.pem$|\.p12$|\.pfx$|(^|/)id_rsa$|(^|/)id_ed25519$' "$FILES" \
| grep -Ev '^deployments/(master|cpu-subagent|gpu-subagent|agent|knowledge|ollama|combined)/\.env$' >/dev/null; then
bad "private environment/key material found"
fi
@@ -47,6 +51,30 @@ else
fi
fi
# Checked-in deployment .env files are templates, never live configuration.
for envf in \
deployments/master/.env \
deployments/cpu-subagent/.env \
deployments/gpu-subagent/.env \
deployments/agent/.env \
deployments/knowledge/.env \
deployments/ollama/.env \
deployments/combined/.env; do
[ -f "$envf" ] || continue
if awk -F= '
/^[[:space:]]*#/ || NF < 2 { next }
{
key=$1; sub(/^[[:space:]]+/, "", key); sub(/[[:space:]]+$/, "", key)
val=$0; sub(/^[^=]*=/, "", val)
if (key ~ /(TOKEN|PASSWORD|SECRET|CLIENT_ID|CLIENT_SECRET|API_KEY)$/ && val != "" && val !~ /^CHANGE_ME/) {
print FILENAME ":" NR ": live-looking secret in " key > "/dev/stderr";
bad=1
}
}
END { exit bad ? 1 : 0 }
' "$envf"; then :; else bad "deployment template contains a non-placeholder secret: $envf"; fi
done
# Reject accidental binary blobs outside explicitly expected assets.
while IFS= read -r f; do
[ -f "$f" ] || continue
Regular → Executable
View File
Regular → Executable
View File
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
for f in \
services/agent/Dockerfile \
services/knowledge/Dockerfile \
.gitea/workflows/ci.yml \
.gitea/workflows/release.yml \
docker-bake.hcl; do
test -f "$f" || { echo "missing: $f" >&2; exit 1; }
done
grep -q '^FROM ' services/agent/Dockerfile
grep -q 'AS data-init' services/agent/Dockerfile
grep -q '^FROM ' services/knowledge/Dockerfile
echo "OK: Dockerfiles and repository workflows are present."
File diff suppressed because it is too large Load Diff