Compare commits

...

19 Commits

Author SHA1 Message Date
devops 8ad8c956eb fix(deploy): sanitized agent config — use Docker to read openclaw.json
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 27s
The Gitea runner doesn't have direct filesystem access to
/home/projekte_bao/openclaw/, so the previous python3 inline extraction
would fail silently and no agents-sanitized.json would be generated.

Now the deploy script uses a Docker container with bind mounts to read
openclaw.json (readonly) and write agents-sanitized.json to the host.

This completes the P4 migration: Nexus no longer needs read access to
openclaw.json for any code path or deployment step.
2026-07-12 15:08:49 +02:00
devops dbda764190 fix: execute Nexus deploy inside host-mounted workspace
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Failing after 21s
2026-07-10 00:16:47 +02:00
devops 361a64f886 ops: move canonical workspace to projects root
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 7s
2026-07-10 00:02:41 +02:00
devops a104acf160 ci: consolidate Nexus deployment and provenance
CI - Build & Test / Backend (.NET) (push) Successful in 48s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 55s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
2026-07-09 23:46:46 +02:00
devops aaec3eb4ed feat: complete Nexus mission-control workflows 2026-07-09 23:40:36 +02:00
devops 436ddfee0f fix: stream deploy source snapshot
CI - Build & Test / Backend (.NET) (push) Successful in 38s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:56:18 +02:00
devops 38954feb8f fix: snapshot deploy source before sync
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 3s
2026-06-24 07:52:24 +02:00
devops f30cce4fb3 fix: harden nexus deploy sync
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:43:02 +02:00
devops 7216bfdeff fix: repair gitea deploy pipeline
CI - Build & Test / Backend (.NET) (push) Successful in 34s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 7s
2026-06-24 07:33:10 +02:00
devops 16385d10cb docs: update routing docs for Traefik
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:43:33 +02:00
devops 250e730f33 fix: require auth for chat endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:32:04 +02:00
devops c9e22195ad fix: assert forbidden workflow results
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:36:18 +02:00
devops 8d8f8cc8a8 fix: assert task workflow result statuses
CI - Build & Test / Backend (.NET) (push) Failing after 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:30:59 +02:00
devops 873c5d586c fix: repair agent model converter build
CI - Build & Test / Backend (.NET) (push) Failing after 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:26:56 +02:00
devops 95495a8332 feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:23:49 +02:00
devops 68b428e411 fix: prioritize rollback over queued deploys 2026-06-24 01:22:55 +02:00
devops 1214cf9a4d chore: simplify nexus cicd pipeline 2026-06-24 01:22:55 +02:00
devops 195c497c88 fix: route standalone views via route metadata 2026-06-24 01:22:55 +02:00
devops a2272c5df6 fix: harden owner bootstrap and auth persistence 2026-06-24 01:22:55 +02:00
81 changed files with 5197 additions and 1344 deletions
-12
View File
@@ -1,12 +0,0 @@
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=replace-with-a-strong-database-password
JWT_KEY=replace-with-at-least-32-random-bytes
OWNER_EMAIL=owner@example.com
OWNER_PASSWORD=replace-with-at-least-14-characters
OWNER_DISPLAY_NAME=Owner
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_PASSWORD=
OLLAMA_BASE_URL=http://host.docker.internal:11434
NVIDIA_API_KEY=
+2 -4
View File
@@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
# ── Owner Account ───────────────────────────────────────
OWNER_EMAIL=***
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
# ── Bootstrap Owner (first seed only) ───────────────────
BOOTSTRAP_OWNER_EMAIL=***
# ── OpenClaw Integration ────────────────────────────────
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
+226
View File
@@ -0,0 +1,226 @@
#!/bin/sh
set -eu
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
ENV_TMPFILE=""
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
cleanup() {
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
fi
}
trap cleanup EXIT INT TERM
require_env() {
name="$1"
eval "value=\${$name:-}"
if [ -z "$value" ]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env ENV_POSTGRES_PASSWORD
require_env ENV_JWT_KEY
secure_tmpfile() {
template="$1"
dir="$(dirname "$template")"
base="$(basename "$template")"
mkdir -p "$dir"
mktemp "$dir/$base.XXXXXX"
}
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
chmod 600 "$ENV_TMPFILE"
if [ ! -f VERSION ]; then
echo "VERSION file not found" >&2
exit 1
fi
VERSION="$(tr -d '[:space:]' < VERSION)"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Invalid VERSION value: $VERSION" >&2
exit 1
fi
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "Deploying Nexus v$VERSION from $GIT_REF"
umask 077
cat > "$ENV_TMPFILE" <<EOF_ENV
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=${VERSION}
NEXUS_GIT_SHA=${GIT_SHA}
EOF_ENV
echo "Syncing source to deploy path: $DEPLOY_PATH"
git archive --format=tar HEAD | docker run --rm -i \
-v "$DEPLOY_PATH:/dest" \
alpine:3.20 \
sh -c '
set -eu
dest_owner="$(stat -c "%u:%g" /dest)"
mkdir -p /src-snapshot
tar -xf - -C /src-snapshot
is_protected_path() {
case "$1" in
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
rm -rf "$path"
fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
cp -a "$path" /dest/
fi
done
chown -R "$dest_owner" /dest
'
# ── Sanitized agents config for Nexus (no secrets) ──
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.json)"
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
OPENCLAW_CONFIG_DIR="/home/projekte_bao/openclaw/data/openclaw"
# Use Docker to read openclaw.json (runner doesn't have direct host fs access)
if docker run --rm \
-v "$OPENCLAW_CONFIG:/input/openclaw.json:ro" \
-v "$OPENCLAW_CONFIG_DIR:/output" \
python:3.12-alpine \
python3 -c "
import json, sys, os
config_path = '/input/openclaw.json'
output_path = '/output/agents-sanitized.json'
if not os.path.isfile(config_path):
print(f'WARNING: openclaw.json not found at {config_path} — agents-sanitized.json NOT generated', file=sys.stderr)
sys.exit(1)
with open(config_path) as f:
data = json.load(f)
agents = data.get('agents')
if agents is None:
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
sys.exit(1)
with open(output_path, 'w') as f:
json.dump({'agents': agents}, f, indent=2)
f.write('\n')
print(f'Sanitized agents config written ({len(agents.get(\"list\", []))} agents)')
" 2>&1; then
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
else
echo "WARNING: Failed to generate agents-sanitized.json — Nexus will use fallback agent IDs" >&2
fi
echo "Building and starting Docker compose stack"
docker run --rm \
-v "$DEPLOY_PATH:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c 'set -eu
umask 077
cat > /tmp/nexus-deploy-env
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-deploy-env build
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
docker compose --env-file /tmp/nexus-deploy-env ps
' < "$ENV_TMPFILE"
echo "Verifying image provenance"
for container in nexus-api-1 nexus-web-1; do
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
version="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$container")"
if [ "$revision" != "$GIT_SHA" ]; then
echo "Image revision mismatch for $container: expected $GIT_SHA, got $revision" >&2
exit 1
fi
if [ "$version" != "$VERSION" ]; then
echo "Image version mismatch for $container: expected $VERSION, got $version" >&2
exit 1
fi
echo "$container provenance verified: v$version $revision"
done
echo "Checking live health"
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
echo "Health check passed"
break
fi
if [ "$retry" -eq 6 ]; then
echo "Health check failed" >&2
exit 1
fi
sleep "$retry"
done
pass=0
fail=0
check() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check_post() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{}' "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check "/dashboard" "200" "Dashboard"
check "/health" "200" "Health"
check "/api/v1/operations/snapshot" "401" "Operations auth"
check_post "/api/v1/chat" "401" "Chat auth"
if [ "$fail" -ne 0 ]; then
echo "Smoke test failed: $fail failed, $pass passed" >&2
exit 1
fi
echo "Nexus v$VERSION deployed and verified"
+4 -4
View File
@@ -33,7 +33,7 @@ on:
host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)'
required: false
default: '/home/projekte_bao/openclaw/backups'
default: '/home/projekte_bao/backups/nexus'
type: string
# Optional: uncomment to enable nightly automatic backups
@@ -43,11 +43,11 @@ on:
jobs:
backup:
name: Backup PostgreSQL
runs-on: ubuntu-latest
runs-on: linux
env:
ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
DEPLOY_PATH: /home/projekte_bao/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1
steps:
@@ -72,7 +72,7 @@ jobs:
echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus" \
| gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
+28 -3
View File
@@ -8,9 +8,12 @@ concurrency:
on:
push:
branches: [main]
branches:
- main
- 'codex/**'
pull_request:
branches: [main]
workflow_dispatch:
jobs:
# ─── Backend ───────────────────────────────────
@@ -51,7 +54,7 @@ jobs:
- name: Setup pnpm
run: |
corepack enable
corepack prepare pnpm@latest --activate
corepack prepare pnpm@10.12.1 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,7 +76,7 @@ jobs:
security:
name: Security Check
runs-on: linux
if: github.ref == 'refs/heads/main'
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -97,3 +100,25 @@ jobs:
else
echo "✅ No obvious secrets found"
fi
deploy:
name: Deploy Nexus
runs-on: linux
needs: [backend, frontend, security]
concurrency:
group: deploy-production
cancel-in-progress: false
if: |
gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main'
env:
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy after green CI
run: sh .gitea/scripts/deploy-nexus.sh
-199
View File
@@ -1,199 +0,0 @@
name: Deploy Now
run-name: 🚀 Deploy Now by @${{ gitea.actor }}
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
- name: Resolve Version
id: version
run: |
set -euo pipefail
if [ ! -f VERSION ]; then
echo "ERROR: VERSION file not found"
exit 1
fi
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: Invalid semver in VERSION: $VERSION"
exit 1
fi
GIT_REF=$(git rev-parse --short HEAD)
echo "Deploy version: v${VERSION} git:${GIT_REF}"
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
- name: Prepare .env
run: |
set -euo pipefail
HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true)
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}"
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}"
chmod 600 "${ENV_TMPFILE}"
echo "OK .env written to ${ENV_TMPFILE}"
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "cd /src && find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -r {} /dest/ \; && DEST_OWNER=\$(stat -c '%u:%g' /dest) && chown -R \"\$DEST_OWNER\" /dest"
echo "OK synced to ${DEPLOY_PATH}"
- name: Build and Deploy
run: |
set -euo pipefail
SCRIPT=/tmp/nexus-deploy-script.sh
printf '#!/bin/sh\n' > "$SCRIPT"
printf 'set -e\n' >> "$SCRIPT"
printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT"
printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT"
printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT"
printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT"
printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT"
printf 'else\n' >> "$SCRIPT"
printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT"
printf 'fi\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Waiting for services to become healthy (up to 180s)..."\n' >> "$SCRIPT"
printf 'for i in $(seq 1 36); do\n' >> "$SCRIPT"
printf ' STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)\n' >> "$SCRIPT"
printf ' if echo "$STATUS" | grep -q unhealthy; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Unhealthy containers - failing fast"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env logs --tail=30\n' >> "$SCRIPT"
printf ' exit 1\n' >> "$SCRIPT"
printf ' elif echo "$STATUS" | grep -q starting; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Still starting..."\n' >> "$SCRIPT"
printf ' sleep 5\n' >> "$SCRIPT"
printf ' else\n' >> "$SCRIPT"
printf ' echo "All containers healthy"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' exit 0\n' >> "$SCRIPT"
printf ' fi\n' >> "$SCRIPT"
printf 'done\n' >> "$SCRIPT"
printf 'echo "Timeout waiting for services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env logs --tail=20\n' >> "$SCRIPT"
printf 'exit 1\n' >> "$SCRIPT"
chmod +x "$SCRIPT"
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${SCRIPT}:/deploy.sh:ro" \
-w /workspace/nexus \
-i \
docker:cli \
sh /deploy.sh < "${ENV_TMPFILE}"
rm -f "$SCRIPT"
echo "OK deployed"
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "OK cleaned"
fi
- name: Health Check
run: |
echo "Health check..."
RETRY=0; MAX=6; WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "OK Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "ERROR Health check failed after $MAX attempts"
exit 1
- name: Smoke Test
run: |
PASS=0; FAIL=0; BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " OK"
PASS=$((PASS + 1))
else
echo " FAIL (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "ERROR Smoke test failed"
exit 1
fi
echo "OK Smoke test passed"
- name: Summary
if: always()
run: |
echo "========================================"
echo " Deploy Summary"
echo "========================================"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: main"
echo " Service: all"
echo " Trigger: Manual"
echo " Status: ${{ job.status }}"
echo "========================================"
+7 -367
View File
@@ -1,388 +1,28 @@
name: Deploy Nexus v2
run-name: 🚀 Deploy v2 by @${{ gitea.actor }}
name: Deploy Nexus Manual
run-name: Deploy Nexus manually by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version Management:
# The VERSION file in the repo root is the single source of truth.
# Version bumps happen in the Dev workflow BEFORE merge to main.
# The deploy workflow only reads, validates, and logs the version.
# The [skip ci] filter remains as a safety layer for auto-triggers.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run:
workflows: ["CI - Build & Test"]
types: [completed]
branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch:
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
# ── Env for the deploy target path ──
runs-on: linux
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
# This ensures the password stays consistent across deploys and the DB is the
# single source of truth after initial seed (enforced by SeedAudit guard).
steps:
# ═══════════════════════════════════════════════════
# Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
- name: Checkout main
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════
# Step 2: Set up Git identity
# ═══════════════════════════════════════════════════
- name: Configure Git
run: |
git config user.email "devops@noveria.net"
git config user.name "DevOps"
# ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version
#
# Reads VERSION from repo root — the single source of truth.
# Validates semver format, logs version + git metadata.
# No git mutation: version bumps happen in the Dev workflow.
# ═══════════════════════════════════════════════════
- name: Resolve Version
id: version
run: |
set -euo pipefail
# 1. Check VERSION exists
if [ ! -f VERSION ]; then
echo "❌ VERSION file not found"
exit 1
fi
# 2. Read and validate semver format
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1
fi
# 3. Log version, git ref, and describe
GIT_REF=$(git rev-parse --short HEAD)
GIT_DESCRIBE=$(git describe --always --dirty)
echo "📦 Deploy version: v${VERSION}"
echo "🔖 Git ref: ${GIT_REF}"
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
# 4. Set outputs for downstream steps
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
# ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets + host .env (SAFE)
#
# Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after
# compose operations complete.
#
# OWNER_PASSWORD is read from the host's persistent .env
# to ensure it stays the single source of truth. Other
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
# come from Gitea secrets.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file)
run: |
set -euo pipefail
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
echo " The host .env is the single source of truth for the owner password."
echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying."
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Code synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy
run: |
set -euo pipefail
BUILD_ARGS=""
SERVICE_ARG=""
# Write the deploy script to a file to avoid nested quoting issues
cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
#!/bin/sh
set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT
cat > /tmp/nexus-deploy-env
# ── Clean up zombie containers ──
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true
# ── WAL recovery ──
PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)
if [ -n "$PG_VOL" ]; then
echo "Checking postgres WAL integrity..."
docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'" 2>&1 || echo "pg_resetwal failed (may be benign)"
else
echo "Postgres volume not found - will be created fresh"
fi
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
SERVICE="${DEPLOY_SERVICE:-}"
if [ -n "$SERVICE" ]; then
echo "Deploying service: $SERVICE"
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS $SERVICE
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate $SERVICE
else
echo 'Deploying all services'
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate
fi
echo 'Waiting for services to become healthy (up to 180s)...'
for i in $(seq 1 36); do
STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)
if echo "$STATUS" | grep -q 'unhealthy'; then
echo " [$i/36] Unhealthy containers - failing fast"
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=30
exit 1
elif echo "$STATUS" | grep -q 'starting'; then
echo " [$i/36] Still starting..."
sleep 5
else
echo 'All containers healthy'
docker compose --env-file /tmp/nexus-deploy-env ps -a
exit 0
fi
done
echo 'Timeout waiting for services'
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=20
exit 1
DEPLOYSCRIPT
docker run --rm \
-e "DEPLOY_BUILD_ARGS=${BUILD_ARGS:-}" \
-e "DEPLOY_SERVICE=${SERVICE_ARG:-}" \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/nexus-deploy-script.sh:/deploy.sh:ro \
-w /workspace/nexus \
-i \
docker:cli \
sh /deploy.sh < "${ENV_TMPFILE}"
rm -f /tmp/nexus-deploy-script.sh
echo "✅ Docker compose up completed"
# ═══════════════════════════════════════════════════
# Step 7: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 8: Health Check (exponential backoff)
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
# Fibonacci-ish backoff: 1,2,3,5,8,13
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 9: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ═══════════════════════════════════════════════════
# Step 10: Deployment Summary
# ═══════════════════════════════════════════════════
- name: Deployment Summary
if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: main"
echo " Service: all"
echo " Trigger: ${TRIGGER}"
echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 11: Failure → Reviewer Handoff
#
# On failure: DevOps (Architekt) analyses the log,
# notifies Reviewer (Code-Fixer) with the exact error.
# This output provides a ready-to-copy message.
# ═══════════════════════════════════════════════════
- name: 🔴 Failure — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Version: v${{ steps.version.outputs.version }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
- name: Deploy main
run: sh .gitea/scripts/deploy-nexus.sh
+10 -19
View File
@@ -18,9 +18,12 @@ run-name: 🔙 Rollback by @${{ gitea.actor }}
# migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps.
# ───────────────────────────────────────────────────────
# Rollback wins over queued/in-progress deploys.
# It shares deploy-production with deploy.yaml so rollback and deploy never run together,
# but cancel-in-progress=true prevents a queued auto-deploy from running after rollback.
concurrency:
group: deploy-production
cancel-in-progress: false
cancel-in-progress: true
on:
workflow_dispatch:
@@ -37,9 +40,9 @@ on:
jobs:
rollback:
name: Rollback Nexus
runs-on: ubuntu-latest
runs-on: linux
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
@@ -94,22 +97,12 @@ jobs:
fi
# ═══════════════════════════════════════════════════
# Step 3: Prepare .env from secrets + host .env (safe temp file)
# Step 3: Prepare .env from secrets (safe temp file)
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file)
- name: Prepare .env (secrets → temp file)
run: |
set -euo pipefail
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus
@@ -118,9 +111,7 @@ jobs:
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
@@ -280,7 +271,7 @@ jobs:
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │"
echo "│ cd /home/projekte_bao/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
-1
View File
@@ -6,7 +6,6 @@
# Environment
.env
!.env.example
!.env.template
# IDE
+6 -5
View File
@@ -31,7 +31,7 @@
│ 127.0.0.1:18880 │
│ │ │
│ ┌───────────────────────────┼───────────────────┐ │
│ │ Host nginx reverse proxy │ │ │
│ │ Traefik v3 reverse proxy │ │ │
│ │ nexus.noveria.net :443 ───┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
ss -tlnp | grep 18880
```
### Host nginx Reverse Proxy
### Traefik Reverse Proxy
Falls `nexus.noveria.net` nicht erreichbar:
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880`
- TLS-Zertifikat gültig?
- Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
- `web` hängt am externen `proxy`-Netzwerk
- TLS-Zertifikat/Let's-Encrypt-Resolver in Traefik gültig?
---
@@ -151,5 +152,5 @@ Falls `nexus.noveria.net` nicht erreichbar:
| backend/Dockerfile | ✅ Multi-Stage .NET 10 |
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
| Host nginx Reverse Proxy | ⚠️ Muss auf Port 18880 zeigen |
| Traefik Reverse Proxy | ✅ Per Compose-Labels auf `web:80` |
| Docker installiert auf VPS | ⚠️ Vorausgesetzt |
+82 -25
View File
@@ -7,10 +7,10 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
> Backend-Brücke und Gateway-Integration geprüft. Siehe
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
> CI runs automatically on every push. CD can run **automatically after successful CI**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
> stay read-only. Rollback and database backup are separate manual workflows.
> CI runs automatically on every push. CD runs **inside the green CI run**
> on main or can be triggered **manually** (workflow_dispatch). Deploy reads
> `VERSION` but does not mutate Git or create tags. Rollback and database backup
> are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation
@@ -26,16 +26,16 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
## Local/container start
```bash
cp .env.example .env
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
# OWNER_EMAIL and OWNER_PASSWORD.
cp .env.template .env
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
docker compose up --build -d
curl http://127.0.0.1:18880/health
```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
characters. Existing databases are never overwritten by the bootstrap process.
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
derives the initial display name from that email, and logs a generated temporary password once.
After first seed the password lives only in PostgreSQL. Existing databases are
never overwritten by the bootstrap process.
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
Health checks, rate limiting, and security headers are active.
@@ -174,6 +174,63 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
## API endpoints
### MCP Agent Data Plane
Nexus exposes an MCP endpoint at `/mcp` for agent-facing board operations.
It uses the official `ModelContextProtocol.AspNetCore` SDK with stateless
streamable HTTP transport. Tools are a thin facade over `ITaskBridgeService`;
they must not duplicate board business logic.
Auth follows the bridge rules: requests provide `X-Agent-Id` and/or
`X-Nexus-Api-Key`. Secrets stay in OpenClaw/Gateway config and are never
embedded in frontend code.
Registered tools:
| Tool | Purpose |
|---|---|
| `nexus_get_board` | Full task board |
| `nexus_agent_overview` | Waiting/stale workflow overview |
| `nexus_get_task` | Single task |
| `nexus_get_children` | Child tasks for a parent |
| `nexus_get_activity` | Task activity history |
| `nexus_create_task` | Create parent/standalone task |
| `nexus_create_child_task` | Create visible delegation child task |
| `nexus_update_status` | Update status using the canonical enum only |
| `nexus_append_activity` | Append checkpoint/activity |
| `nexus_handoff` | Handoff to a known agent |
The compatible `/api/bridge` HTTP facade remains available for internal
diagnostics and transition clients. New agent integrations should use MCP;
`/api/dashboard` is UI/admin surface, not an agent contract.
### Mission Control Gateway Plane
Nexus keeps the Browser -> Nexus -> OpenClaw boundary: the frontend never talks
to OpenClaw directly. Read-only Gateway status is exposed through
`GET /api/dashboard/gateway`; it reports reachability, discovered Gateway
version and the optional `Integrations:OpenClaw:RequiredVersion` pin. A set pin
does not mutate production config, but makes protocol drift visible in the UI.
Agent activity shown as "Thinking" is redacted before display. Lines containing
token, password, bearer, authorization, API key or secret markers are replaced
with a redaction marker. Persisted audit-worthy events should be written as
short Activity entries, not raw session transcripts.
Nexus activity updates stream live through the Dashboard SSE channel and are
filtered by explicit `agentIds`. Gateway session history is read-only fallback
data: it is fetched on demand, redacted before display and not persisted as a
long-term raw transcript. Agent "Now" and "Today" summaries are deterministic
derivations from redacted Nexus activity plus redacted Gateway history; Nexus
does not call an LLM to summarize this feed.
Config writes and approval actions are owner-only. Config saves validate before
replacement, keep a `.bak` when an existing file is replaced, write audit events
without file contents or secrets and return structured `validation`, `backup`
and `reloadCheck` results. Workspace Markdown hot reload is currently reported
truthfully as `not_supported`; JSON validation exists in the save path but JSON
files are not exposed unless they are explicitly allowlisted for editing.
### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
@@ -250,11 +307,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
|---|---|---|
| `GET` | `/api/v1/tasks` | List all tasks |
| `POST` | `/api/v1/tasks` | Create task |
| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour |
| `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals |
| `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) |
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress backlog) |
| `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
### Agents
@@ -264,10 +321,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
| `GET` | `/api/v1/agents` | List all agents |
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
| `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) |
| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary |
| `POST` | `/api/v1/agents/{id}/command` | Send command to agent |
| `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) |
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result |
### Memory & Docs
@@ -351,24 +409,23 @@ Every push to `main` triggers `.gitea/workflows/ci.yaml`:
CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3)
### CD — Auto + Manual (CD v4)
Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main)
#### Auto-Deploy (after successful CI jobs on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
- Uses safe defaults: `patch` bump, all services, main ref
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
- Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
- Starts only after backend, frontend, and security jobs succeed on `main`
- Deploys the current `main` version after CI succeeds.
- This replaces `workflow_run`, which did not create deploy runs in this Gitea 1.26.3 installation.
- The deploy script reads `VERSION`; it does not mutate Git, bump versions, or create tags
#### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
2. Workflow validates `VERSION`, builds and deploys `main`
3. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`)
+108 -6
View File
@@ -45,6 +45,94 @@ public class AgentServiceTests
Assert.Null(agent);
}
[Fact]
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
Assert.Contains("product-owner", ids);
Assert.Contains("programmer-fast", ids);
}
[Fact]
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("Developer", agent.Role);
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
}
[Fact]
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "deepseek/deepseek-v4-flash"
},
"list": [
{
"id": "iris",
"name": "iris",
"model": "openai/gpt-5.5"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5", agent!.Model);
}
[Fact]
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "openai/gpt-5.5-mini"
},
"list": [
{
"id": "reviewer",
"name": "reviewer"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
}
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -53,10 +141,10 @@ public class AgentServiceTests
})
.Build();
private static string CreateAgentConfigFile()
private static string CreateAgentConfigFile(string? json = null)
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
File.WriteAllText(path, json ??
"""
{
"agents": {
@@ -69,19 +157,33 @@ public class AgentServiceTests
"list": [
{
"id": "iris",
"name": "iris"
"name": "iris",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "product-owner",
"name": "product-owner",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "programmer",
"name": "programmer"
"name": "programmer",
"model": { "primary": "openai/gpt-5.4" }
},
{
"id": "programmer-fast",
"name": "programmer-fast",
"model": { "primary": "openai/gpt-5.3-codex-spark" }
},
{
"id": "reviewer",
"name": "reviewer"
"name": "reviewer",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "architekt",
"name": "architekt"
"name": "architekt",
"model": { "primary": "openai/gpt-5.5" }
}
]
}
+397
View File
@@ -0,0 +1,397 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Tests for AuthService login, change-password, admin-reset, and related flows.
/// These are unit-level tests using an in-memory EF Core database so no
/// external PostgreSQL instance is needed.
/// </summary>
public sealed class AuthServiceTests
{
// ── Fixture helpers ─────────────────────────────────────────────────
/// <summary>
/// Creates a test fixture with an in-memory database, a UserRepository,
/// and an AuthService backed by an in-memory configuration.
/// </summary>
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
var repo = new UserRepository(db);
// In-memory config with minimum required JWT settings
var config = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
var auth = new AuthService(repo, config, logger);
return (db, repo, auth);
}
private static LoginRequest Login(string email, string password)
=> new() { Email = email, Password = password };
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
{
var user = new NexusUser
{
Email = email,
NormalizedEmail = AuthService.NormalizeEmail(email),
DisplayName = email.Split('@')[0],
PasswordHash = PasswordSecurity.Hash(password),
Role = role
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
// ══════════════════════════════════════════════════════════════════
// Password Security Unit Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public void Hash_And_Verify_RoundTrip_Succeeds()
{
const string password = "MyTestPassword123!";
var hash = PasswordSecurity.Hash(password);
Assert.NotNull(hash);
Assert.StartsWith("v1.", hash);
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
Assert.True(ok);
Assert.False(needsUpgrade);
}
[Fact]
public void Verify_WrongPassword_Fails()
{
var hash = PasswordSecurity.Hash("CorrectPassword123!");
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalse()
{
Assert.False(PasswordSecurity.Verify("password", "", out _));
}
[Fact]
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
{
const string password = "OldFormatPassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
Assert.True(ok);
Assert.True(needsUpgrade);
}
// ══════════════════════════════════════════════════════════════════
// Login Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task Login_WithValidCredentials_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string password = "ValidPassword123!";
await SeedUserAsync(db, "test@example.com", password);
var session = await auth.LoginAsync(Login("test@example.com", password));
Assert.NotNull(session);
Assert.Equal("test", session.User.DisplayName);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
}
[Fact]
public async Task Login_WithNonexistentEmail_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
}
[Fact]
public async Task Login_UpdatesLastLoginAt()
{
var (db, repo, auth) = CreateFixture();
const string password = "TestPassword123!";
var user = await SeedUserAsync(db, "test@example.com", password);
var beforeLogin = user.LastLoginAt;
await Task.Delay(10);
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated!.LastLoginAt);
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
}
/// <summary>
/// Validates that LoginAsync persists a password hash upgrade AND login
/// timestamps even when there are NO expired refresh tokens. Previously
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
/// but that only happens when oldTokens.Count > 0.
/// </summary>
[Fact]
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
{
var (db, repo, auth) = CreateFixture();
const string password = "LegacyUpgradePassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var user = new NexusUser
{
Email = "legacy@example.com",
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
DisplayName = "Legacy",
PasswordHash = legacyHash,
Role = "user"
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Login triggers hash upgrade
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.StartsWith("v1.", updated.PasswordHash);
Assert.NotEqual(legacyHash, updated.PasswordHash);
// Second login with the upgraded hash should also work
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
}
[Fact]
public async Task Login_WithExistingHash_DoesNotChangeHash()
{
var (db, repo, auth) = CreateFixture();
const string password = "StablePassword123!";
var user = await SeedUserAsync(db, "stable@example.com", password);
var originalHash = user.PasswordHash;
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(originalHash, updated.PasswordHash);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string oldPw = "OldPassword123!";
const string newPw = "NewPassword456!";
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = oldPw,
NewPassword = newPw
});
Assert.True(result);
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
}
[Fact]
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = "WrongPassword456!",
NewPassword = "NewPassword789!"
}));
}
// ══════════════════════════════════════════════════════════════════
// Admin Reset Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task AdminResetPassword_WithValidToken_Succeeds()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
const string oldPw = "OldPassword123!";
const string newPw = "NewAdminPassword456!";
await SeedUserAsync(db, "adminreset@example.com", oldPw);
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
}
[Fact]
public async Task AdminResetPassword_WithInvalidToken_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
}
[Fact]
public async Task AdminResetPassword_NonexistentUser_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
}
[Fact]
public async Task AdminResetPassword_ShortPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
}
// ══════════════════════════════════════════════════════════════════
// Profile Update Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateProfile_ChangesDisplayName()
{
var (db, repo, auth) = CreateFixture();
const string password = "Password123!";
var user = await SeedUserAsync(db, "profile@example.com", password);
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
{
DisplayName = "New Name"
});
Assert.NotNull(updated);
Assert.Equal("New Name", updated.DisplayName);
}
// ══════════════════════════════════════════════════════════════════
// NormalizeEmail
// ══════════════════════════════════════════════════════════════════
[Fact]
public void NormalizeEmail_TrimsAndUppercases()
{
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
}
}
/// <summary>
/// Minimal in-memory IConfiguration implementation for unit tests.
/// Reads from a case-insensitive dictionary.
/// </summary>
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly Dictionary<string, string?> _data;
private readonly Dictionary<string, MemoryConfigSection> _sections;
public MemoryConfig(Dictionary<string, string?> data)
{
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
}
public string? this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : null;
set => _data[key] = value ?? string.Empty;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (!_sections.TryGetValue(key, out var section))
{
section = new MemoryConfigSection(key, this);
_sections[key] = section;
}
return section;
}
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
{
public string Key => path.Split(':').Last();
public string Path => path;
public string? Value { get => root[path]; set => root[path] = value; }
public string? this[string key]
{
get => root[$"{path}:{key}"];
set => root[$"{path}:{key}"] = value;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
=> root.GetSection($"{path}:{key}");
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
internal sealed class NeverToken : IChangeToken
{
public static readonly NeverToken Instance = new();
public bool HasChanged => false;
public bool ActiveChangeCallbacks => false;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
}
internal sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
+17
View File
@@ -0,0 +1,17 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ChatControllerTests
{
[Fact]
public void ChatController_RequiresAuthorization()
{
var attribute = typeof(ChatController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
}
}
+496
View File
@@ -0,0 +1,496 @@
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Data;
using Nexus.Api.Controllers;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class MissionControlPhaseTests
{
[Fact]
public void AgentConfigSave_IsBaoOwnerOnly()
{
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal("owner", authorize!.Roles);
}
[Fact]
public void TaskApprovalEndpoints_AreOwnerOnly()
{
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
}
[Fact]
public void GatewayActivityRedaction_RemovesSensitiveLines()
{
var text = OpenClawGatewayClient.RedactSensitiveText("""
Status: ok
Authorization: Bearer abc.def.ghi
Next step ready
X-Nexus-Api-Key: secret
""");
Assert.Contains("Status: ok", text);
Assert.Contains("Next step ready", text);
Assert.DoesNotContain("Bearer abc", text);
Assert.DoesNotContain("secret", text);
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
}
[Fact]
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
{
var now = DateTimeOffset.UtcNow;
var activity = new[]
{
new ActivityEvent
{
Type = "agent_task",
Message = "programmer completed repo scan",
CreatedAt = now.AddHours(-3)
}
};
var gateway = new[]
{
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
};
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
Assert.Equal("gateway-session-history", summary.Now.Source);
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
Assert.Contains("Working on redaction", summary.Now.Text);
Assert.Equal("derived-mixed", summary.Today.Source);
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
Assert.Contains("Working on redaction", summary.Today.Text);
Assert.Contains("Checking task mapping", summary.Today.Text);
Assert.Contains("programmer completed repo scan", summary.Today.Text);
}
[Fact]
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var liveUpdates = new LiveUpdateService();
var subscription = await liveUpdates.SubscribeAsync();
var repository = new ActivityRepository(db, liveUpdates);
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
});
var stored = await repository.GetRecentAsync(1);
Assert.Single(stored);
Assert.DoesNotContain("secret-token", stored[0].Message);
Assert.Contains("programmer", stored[0].Message);
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
var envelope = await subscription.Reader.ReadAsync();
Assert.Equal("activity.created", envelope.Type);
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
using var doc = JsonDocument.Parse(payloadJson);
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Contains("programmer", agentIds);
}
[Fact]
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var repository = new ActivityRepository(db, new LiveUpdateService());
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: compile module"
});
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent reviewer: inspect module"
});
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
Assert.Single(programmerEvents);
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GatewayInfo_ReportsVersionDrift()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("2026.07.08", info.Version);
Assert.Equal("2026.07.09", info.RequiredVersion);
Assert.Equal("drift", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.08", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Null(info.Version);
Assert.Equal("missing", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.09", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
{
var client = CreateClient(request =>
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
};
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
return response;
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("matched", info.VersionStatus);
Assert.True(info.VersionMatches);
Assert.Null(info.Warning);
}
[Fact]
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
var client = CreateClient(request =>
{
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
{
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
object status = agentId switch
{
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
_ => new { status = "ready", model = "openai/gpt-5.5" }
};
return ToolResult(status);
}
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
var agents = await client.GetAgentsAsync();
Assert.Collection(agents.OrderBy(a => a.Id),
architekt =>
{
Assert.Equal("architekt", architekt.Id);
Assert.Equal("unsupported", architekt.StatusKind);
Assert.Equal("Unsupported", architekt.StatusLabel);
Assert.Equal("tool not available", architekt.StatusDetail);
},
iris =>
{
Assert.Equal("iris", iris.Id);
Assert.Equal("connected", iris.StatusKind);
Assert.Equal("Arbeitet", iris.StatusLabel);
},
programmer =>
{
Assert.Equal("programmer", programmer.Id);
Assert.Equal("stale", programmer.StatusKind);
Assert.Equal("Stale", programmer.StatusLabel);
Assert.NotNull(programmer.StatusDetail);
Assert.Contains("40m", programmer.StatusDetail!);
},
reviewer =>
{
Assert.Equal("reviewer", reviewer.Id);
Assert.Equal("error", reviewer.StatusKind);
Assert.Equal("Fehler", reviewer.StatusLabel);
Assert.Equal("gateway timeout", reviewer.StatusDetail);
});
}
[Fact]
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "original");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
Assert.NotNull(attempt.Failure);
Assert.Equal("validation_failed", attempt.Failure!.Code);
Assert.Equal("failed", attempt.Failure.Validation.Status);
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "before");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
Assert.NotNull(attempt.SaveResult);
var result = attempt.SaveResult!;
Assert.Equal("passed", result.Validation.Status);
Assert.Equal("markdown", result.Validation.FileKind);
Assert.Equal("created", result.Backup.Status);
Assert.True(result.Backup.BackupCreated);
Assert.Equal("not_supported", result.ReloadCheck.Status);
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
{
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"validation_failed",
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
new AgentConfigBackupResult("not_applicable", false),
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
var activityRepo = new CapturingActivityRepository();
var controller = new AgentsController(
new FakeAgentService(),
new FakeAgentRuntime(),
activityRepo,
configService,
new FakeDashboardService(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "bao"),
new Claim(ClaimTypes.Role, "owner")
], "TestAuth"))
}
}
};
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
var audit = Assert.Single(activityRepo.Added);
Assert.Equal("config_audit", audit.Type);
Assert.Contains("validation=failed", audit.Message);
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
}
private static OpenClawGatewayClient CreateClient(
Func<HttpRequestMessage, HttpResponseMessage> responder,
string? requiredVersion = null,
string[]? agentIds = null)
{
var configValues = new Dictionary<string, string?>
{
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
};
if (agentIds is not null)
{
var configPath = Path.GetTempFileName();
File.WriteAllText(configPath, JsonSerializer.Serialize(new
{
agents = new
{
list = agentIds.Select(id => new { id }).ToArray()
}
}));
configValues["AgentConfigPath"] = configPath;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
{
BaseAddress = new Uri("http://gateway.local")
};
return new OpenClawGatewayClient(httpClient, configuration);
}
private static HttpResponseMessage ToolResult(object payload)
=> new(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new { ok = true, result = payload }),
Encoding.UTF8,
"application/json")
};
}
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
{
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
=> Task.FromResult<AgentConfigFileContent?>(null);
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
=> Task.FromResult(attempt);
}
file sealed class CapturingActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeAgentService : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AgentDetail?>(null);
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
{
public string Name => "fake";
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+1
View File
@@ -10,6 +10,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
+149
View File
@@ -0,0 +1,149 @@
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class NexusMcpToolsTests
{
[Fact]
public void NexusMcpTools_RegistersExpectedToolNames()
{
var toolNames = typeof(NexusMcpTools)
.GetMethods()
.Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false)
.OfType<McpServerToolAttribute>()
.FirstOrDefault())
.Where(attribute => attribute is not null)
.Select(attribute => attribute!.Name ?? string.Empty)
.Order()
.ToArray();
Assert.Equal(
[
"nexus_agent_overview",
"nexus_append_activity",
"nexus_create_child_task",
"nexus_create_task",
"nexus_get_activity",
"nexus_get_board",
"nexus_get_children",
"nexus_get_task",
"nexus_handoff",
"nexus_update_status"
], toolNames);
}
[Fact]
public void NexusMcpTaskState_OnlyContainsCanonicalStates()
{
Assert.Equal(
[
nameof(NexusMcpTaskState.Backlog),
nameof(NexusMcpTaskState.InProgress),
nameof(NexusMcpTaskState.Blocked),
nameof(NexusMcpTaskState.Done),
nameof(NexusMcpTaskState.Review)
], Enum.GetNames<NexusMcpTaskState>());
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
}
[Fact]
public async Task McpTools_ReadAndWrite_UseBridgeService()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("iris");
var tools = CreateTools(fixture);
var createResult = await tools.CreateTask(
title: "MCP parent",
detail: "Created through MCP tool facade",
priority: "High",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(createResult.Ok);
Assert.NotNull(createResult.Data);
Assert.Equal("MCP parent", createResult.Data!.Title);
var activityResult = await tools.AppendActivity(
createResult.Data.Id,
"MCP checkpoint",
"comment",
CancellationToken.None);
Assert.True(activityResult.Ok);
Assert.Equal("MCP checkpoint", activityResult.Data!.Message);
var statusResult = await tools.UpdateStatus(
createResult.Data.Id,
NexusMcpTaskState.InProgress,
CancellationToken.None);
Assert.True(statusResult.Ok);
Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State);
var board = await tools.GetBoard(CancellationToken.None);
Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id);
var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None);
Assert.True(taskResult.Ok);
Assert.Equal("MCP parent", taskResult.Data!.Title);
}
[Fact]
public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var task = await fixture.TaskService.CreateDashboardTaskAsync(
"Programmer cannot move",
"State changes stay with Iris/Bao.",
"iris",
"Normal",
"programmer",
null,
CancellationToken.None);
fixture.SetCallerAgent("programmer");
var tools = CreateTools(fixture);
var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("nexus_update_status", result.Command);
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task McpTools_ServiceKey_ResolvesAsNexusSystem()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext(
headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
});
var tools = CreateTools(fixture);
var result = await tools.CreateTask(
title: "System MCP task",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("bao", result.Data!.Source);
}
private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture)
=> new(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.HttpContextAccessor,
fixture.Configuration,
NullLogger<NexusMcpTools>.Instance);
}
+2
View File
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
+348
View File
@@ -0,0 +1,348 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class StaleTaskRecoveryTests
{
[Fact]
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stale in progress",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
await fixture.ActivityRepository.AddAsync(new ActivityEvent
{
Type = "comment",
Message = "Previous agent note",
TaskId = staleInProgress.Id,
CreatedAt = staleTimestamp.AddMinutes(15)
}, CancellationToken.None);
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Blocked task",
State = "Blocked",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Review task",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Done task",
State = "Done",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Backlog task",
State = "Backlog",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
CreatedAt = staleTimestamp
}, CancellationToken.None);
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(1, resetCount);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
Assert.NotNull(resetActivity);
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var taskId = Guid.NewGuid();
var staleCandidate = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var currentTask = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
var activityRepository = new FakeActivityRepository();
var liveUpdateService = new FakeLiveUpdateService();
var recoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(0, resetCount);
Assert.Equal("Review", currentTask.State);
Assert.Equal(0, taskRepository.ResetCount);
Assert.Empty(activityRepository.Added);
Assert.Equal(0, liveUpdateService.PublishCount);
}
[Fact]
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 4,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
Assert.Equal(7, resetCount);
}
[Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 2,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await backgroundService.StartAsync(cts.Token);
await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
}
[Fact]
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
{
const string key = "TaskRecovery__StaleHours";
var originalValue = Environment.GetEnvironmentVariable(key);
try
{
Environment.SetEnvironmentVariable(key, "5");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
})
.AddEnvironmentVariables()
.Build();
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
Assert.NotNull(options);
Assert.Equal(5, options!.StaleHours);
Assert.Equal(30, options.IntervalMinutes);
}
finally
{
Environment.SetEnvironmentVariable(key, originalValue);
}
}
}
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
{
public int ResetCount { get; private set; }
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask> { staleCandidate });
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
public Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (id != currentTask.Id
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|| currentTask.UpdatedAt >= staleBefore)
{
return Task.FromResult(false);
}
ResetCount++;
currentTask.State = "Backlog";
currentTask.UpdatedAt = updatedAt;
return Task.FromResult(true);
}
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask>());
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
=> Task.FromResult(task);
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<int> CountAsync(CancellationToken ct = default)
=> Task.FromResult(0);
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> Task.FromResult<WorkTask?>(null);
}
file sealed class FakeActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type,
string? sort,
int page,
int pageSize,
CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeLiveUpdateService : ILiveUpdateService
{
public int PublishCount { get; private set; }
public long CurrentSequence => PublishCount;
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
PublishCount++;
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
}
}
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{
public int CallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; }
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
CallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
+558
View File
@@ -0,0 +1,558 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class TaskWorkflowTests
{
[Fact]
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"PO spec",
"Prepare specification",
"iris",
"Medium",
"product-owner",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
Assert.Equal("Backlog", child.State);
Assert.Equal("product-owner", child.AssignedTo);
Assert.Equal("programmer-fast", child.ExpectedFrom);
Assert.True(child.IsAgentTask);
}
[Fact]
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"Implement",
"Code changes",
"iris",
"High",
"programmer-fast",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
Assert.NotNull(dto);
Assert.True(dto!.HasVisibleDelegation);
Assert.NotNull(dto.LastActivityMessage);
Assert.Equal("programmer-fast", dto.AssignedTo);
Assert.Equal("programmer-fast", dto.ExpectedFrom);
}
[Fact]
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Review",
"Review implementation",
"iris",
"Medium",
"reviewer",
"reviewer",
startsInProgress: false,
ct: CancellationToken.None);
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
var child = Assert.Single(children);
Assert.True(child.HasVisibleDelegation);
Assert.NotNull(child.LastActivityMessage);
Assert.Equal("reviewer", child.AssignedTo);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new DashboardController(
new FakeDashboardService(),
fixture.TaskService,
fixture.ActivityRepository,
new HttpContextAccessor(),
fixture.AgentService,
fixture.Configuration,
fixture.NotificationService,
fixture.LiveUpdateService)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext()
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "unknown-agent"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("programmer-fast");
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Implement",
"Ship the change",
"programmer-fast",
"Medium",
"programmer-fast",
"programmer-fast",
startsInProgress: false,
ct: CancellationToken.None);
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
Assert.NotNull(updatedParent);
Assert.Equal("In progress", updatedParent!.State);
}
private static void AssertStatusCode(IResult result, int expectedStatusCode)
{
if (expectedStatusCode == StatusCodes.Status403Forbidden)
{
Assert.Equal("Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult", result.GetType().FullName);
return;
}
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(expectedStatusCode, statusResult.StatusCode);
}
}
internal sealed class TaskWorkflowFixture : IAsyncDisposable
{
private readonly NexusDbContext _db;
private TaskWorkflowFixture(
NexusDbContext db,
IConfiguration configuration,
ITaskRepository taskRepository,
IActivityRepository activityRepository,
INotificationService notificationService,
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService,
ITaskService taskService,
ITaskBridgeService taskBridgeService,
IAgentService agentService,
HttpContextAccessor httpContextAccessor)
{
_db = db;
Configuration = configuration;
TaskRepository = taskRepository;
ActivityRepository = activityRepository;
NotificationService = notificationService;
LiveUpdateService = liveUpdateService;
StaleTaskRecoveryService = staleTaskRecoveryService;
TaskService = taskService;
TaskBridgeService = taskBridgeService;
AgentService = agentService;
HttpContextAccessor = httpContextAccessor;
}
public IConfiguration Configuration { get; }
public ITaskRepository TaskRepository { get; }
public IActivityRepository ActivityRepository { get; }
public INotificationService NotificationService { get; }
public ILiveUpdateService LiveUpdateService { get; }
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
public ITaskService TaskService { get; }
public ITaskBridgeService TaskBridgeService { get; }
public IAgentService AgentService { get; }
public HttpContextAccessor HttpContextAccessor { get; }
public static async Task<TaskWorkflowFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath,
["NexusApiKey"] = "test-service-key"
})
.Build();
var agentService = new AgentService(configuration, new FakeRuntime());
var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db);
var notificationService = new NotificationService(db, liveUpdateService);
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
var taskService = new TaskService(
taskRepository,
activityRepository,
notificationService,
agentService,
httpContextAccessor,
liveUpdateService,
staleTaskRecoveryService);
var taskBridgeService = new TaskBridgeService(
taskService,
agentService,
activityRepository,
notificationService,
liveUpdateService);
return new TaskWorkflowFixture(
db,
configuration,
taskRepository,
activityRepository,
notificationService,
liveUpdateService,
staleTaskRecoveryService,
taskService,
taskBridgeService,
agentService,
httpContextAccessor);
}
public static DefaultHttpContext CreateHttpContext(
string? agentId = null,
Dictionary<string, string>? headers = null,
ClaimsPrincipal? user = null)
{
var httpContext = new DefaultHttpContext();
if (!string.IsNullOrWhiteSpace(agentId))
httpContext.Request.Headers["X-Agent-Id"] = agentId;
if (headers is not null)
{
foreach (var (key, value) in headers)
httpContext.Request.Headers[key] = value;
}
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
return httpContext;
}
public static ClaimsPrincipal CreateUser(string userId, string role)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
public void SetCallerAgent(string agentId)
{
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
}
public async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
}
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
]
}
}
""");
return path;
}
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
*.user
*.suo
.vs/
.vscode/
.git/
.gitignore
.env
*.log
+145 -8
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
@@ -14,6 +16,7 @@ public class AgentsController(
IAgentRuntime runtime,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
@@ -39,7 +42,25 @@ public class AgentsController(
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }));
var activity = items
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
.ToList();
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
foreach (var entry in gatewayEntries)
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
return Results.Ok(activity
.OrderByDescending(x => x.At)
.Take(50));
}
[HttpGet("{id}/summary")]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
}
[HttpPost("{id}/command")]
@@ -84,20 +105,48 @@ public class AgentsController(
}
[HttpPut("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
try
{
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
var caller = DescribeCaller(HttpContext.User);
if (attempt.Failure is not null)
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
}
var result = attempt.SaveResult!;
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
});
}
catch (UnauthorizedAccessException ex)
{
@@ -116,4 +165,92 @@ public class AgentsController(
statusCode: StatusCodes.Status500InternalServerError);
}
}
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
public sealed record AgentActivityResponse(
long? Id,
string Type,
string Message,
DateTimeOffset At,
string Source,
string? RelativeTime = null
);
public sealed record AgentSummaryResponse(
AgentSummaryItemResponse Now,
AgentSummaryItemResponse Today,
DateTimeOffset GeneratedAt
);
public sealed record AgentSummaryItemResponse(
string Text,
string Source,
DateTimeOffset? Timestamp
);
public static class AgentSummaryBuilder
{
public static AgentSummaryResponse Build(
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
DateTimeOffset nowUtc)
{
var points = activity
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
.OrderByDescending(point => point.Timestamp)
.ToList();
var current = points.FirstOrDefault();
var now = current is null
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
var windowStart = nowUtc.AddHours(-24);
var todayPoints = points
.Where(point => point.Timestamp >= windowStart)
.ToList();
AgentSummaryItemResponse today;
if (todayPoints.Count == 0)
{
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
}
else
{
var snippets = todayPoints
.Select(point => point.Text)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
if (extraCount > 0)
text += $" (+{extraCount} weitere)";
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
? todayPoints[0].Source
: "derived-mixed";
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
}
return new AgentSummaryResponse(now, today, nowUtc);
}
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
}
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.DTOs;
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/chat")]
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
+38 -5
View File
@@ -16,6 +16,8 @@ public class DashboardController(
ITaskService taskService,
IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor,
IAgentService agentService,
IConfiguration configuration,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ControllerBase
{
@@ -54,6 +56,10 @@ public class DashboardController(
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct);
[HttpGet("gateway")]
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
@@ -191,9 +197,15 @@ public class DashboardController(
// ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")]
public async Task<BoardResponse> GetBoard(CancellationToken ct)
=> await taskService.GetBoardAsync(ct);
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
if (!await CanReadBoardAsync(ct))
return Unauthorized();
return Ok(await taskService.GetBoardAsync(ct));
}
[HttpGet("live")]
public async Task Live(
@@ -320,8 +332,17 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var children = await taskService.GetChildTasksAsync(id, ct);
return Ok(children.Select(MapToDto).ToList());
var board = await taskService.GetBoardAsync(ct);
var children = board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done)
.Where(task => task.ParentTaskId == id)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
return Ok(children);
}
[HttpGet("tasks/{id:guid}")]
@@ -401,7 +422,7 @@ public class DashboardController(
var task = await taskService.CreateAgentTaskAsync(
request.Title, request.Detail, request.Source ?? "iris",
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, ct);
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
@@ -415,4 +436,16 @@ public class DashboardController(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
}
+12 -5
View File
@@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers;
public class GatewayBridgeController(
ITaskBridgeService bridge,
IAgentService agentService,
IConfiguration configuration,
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
@@ -101,6 +102,7 @@ public class GatewayBridgeController(
priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo,
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
startsInProgress: command.StartsInProgress,
ct: ct);
return MapResult(result, "create_child_task");
@@ -232,12 +234,13 @@ public class GatewayBridgeController(
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
{
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedAgentIds.Contains(normalizedHeader))
if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
@@ -248,14 +251,17 @@ public class GatewayBridgeController(
if (User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return (true, normalizedClaim, null);
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
// Browser JWT fallback is intentionally restricted to board owners/admins.
// Agent/service traffic should authenticate as an allowed agent or service principal.
if (User.IsInRole("owner") || User.IsInRole("admin"))
return (true, "bao", null);
}
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
allowedActorIds.Contains("nexus-system"))
return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
@@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand(
string? Detail = null,
string? Priority = null,
string? AssignedTo = null,
string? ExpectedFrom = null
string? ExpectedFrom = null,
bool StartsInProgress = false
);
public sealed record BridgeUpdateStatusCommand(string State);
+50 -18
View File
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -10,7 +12,11 @@ namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/tasks")]
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
public class TasksController(
ITaskService taskService,
IAgentService agentService,
IConfiguration configuration,
IActivityRepository activityRepository) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
@@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
}
[HttpGet("pending-approval")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetPendingApproval(CancellationToken ct)
{
var pending = await taskService.GetPendingApprovalAsync(ct);
@@ -34,9 +41,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
}
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = "owner")]
public async Task<IResult> Approve(Guid id, CancellationToken ct)
{
var result = await taskService.ApproveAsync(id, ct);
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -49,9 +58,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
}
[HttpPost("{id:guid}/reject")]
[Authorize(Roles = "owner")]
public async Task<IResult> Reject(Guid id, CancellationToken ct)
{
var result = await taskService.RejectAsync(id, ct);
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -117,12 +128,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
{
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
var isApiKey = HttpContext.User.IsInRole("Service");
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
@@ -136,33 +147,54 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// Wird vom Iris Autonomous Worker genutzt.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
/// X-Nexus-Api-Key / JWT-authenticated user.
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
var isApiKey = HttpContext.User.IsInRole("Service");
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isService && !isPrivilegedUser)
{
// A presented but unrecognized agent header is an invalid credential, not a missing one.
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
return Results.Forbid();
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isApiKey && !isAuth)
return Results.Unauthorized();
}
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
}
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
TaskOperationOutcome outcome,
string? state,
CancellationToken ct)
{
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return null;
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
TaskId = taskId
}, ct);
}
var normalized = headerValue.Trim().ToLowerInvariant();
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
return allowed.Contains(normalized) ? normalized : null;
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
+6
View File
@@ -6,6 +6,12 @@ COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
LABEL org.opencontainers.image.title="Nexus API" \
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
org.opencontainers.image.version="${NEXUS_VERSION}" \
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
WORKDIR /app
COPY --from=build /app/publish .
RUN apk add --no-cache curl
@@ -15,6 +15,10 @@ public static class ApplicationBuilderExtensions
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
/// are deleted — the DB is the single source of truth for the owner password after first seed.
///
/// Single-transaction guarantee: if the seed block is entered at all (user creation needed
/// or just the audit-log write), the SeedAudit row is written inside the same transaction
/// so that a crash mid-way can never leave the DB in a re-seedable state.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
@@ -30,46 +34,49 @@ public static class ApplicationBuilderExtensions
if (alreadySeeded)
return;
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
// ── Double-check SeedAudit after the migration — if another pod wrote it
// while we were reading, bail out early. ──
alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
// ── Use a strategy-based transaction so the user + audit row are
// persisted atomically. If the DB crashes after SaveChanges the
// entire transaction is rolled back, preventing partial-seed states.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
await using var tx = await db.Database.BeginTransactionAsync();
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
if (!hasUsers)
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
// Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync();
// Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync();
await tx.CommitAsync();
});
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
@@ -202,6 +203,12 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>();
@@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
+2 -2
View File
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
return true;
}
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
public static bool IsValidConfigFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return false;
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
}
}
+21 -2
View File
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
string? Goal = null,
string RoleBadge = "badge-slate",
string StatusLabel = "Bereit",
string StatusKind = "ready",
string? StatusDetail = null,
string? Elapsed = null,
string? Think = null,
string? Next = null
@@ -116,7 +118,9 @@ public sealed record CreateAgentTaskRequest(
string? Priority,
string? AssignedTo,
string? ExpectedFrom,
Guid? ParentTaskId = null
Guid? ParentTaskId = null,
bool StartsInProgress = true,
string? InitialState = null
);
public sealed record UpdateDashboardTaskRequest(
@@ -134,7 +138,22 @@ public sealed record UpdateDashboardTaskStatusRequest(
public sealed record AgentActivityEntry(
string Time,
string Text
string Text,
DateTimeOffset Timestamp,
string Source = "gateway-session-history"
);
public sealed record GatewayRuntimeInfo(
bool Reachable,
string BaseUrl,
string? Version,
string? RequiredVersion,
bool VersionPinned,
bool VersionMatches,
string VersionStatus,
DateTimeOffset CheckedAt,
string? Message,
string? Warning = null
);
// ── Task Board DTOs ──
+1 -1
View File
@@ -10,9 +10,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup>
</Project>
+1
View File
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
// --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment);
app.MapMcp();
app.MapControllers();
app.Run();
+23 -5
View File
@@ -3,7 +3,7 @@ using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
@@ -39,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
return (items, totalCount);
}
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking()
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent")
public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
{
var candidateCount = Math.Max(take * 8, 100);
var recent = await db.Activity.AsNoTracking()
.OrderByDescending(x => x.CreatedAt)
.Take(take)
.Take(candidateCount)
.ToListAsync(ct);
return recent
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
.Take(take)
.ToList();
}
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity);
await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
activity.Id,
activity.Type,
activity.Message,
activity.TaskId,
activity.CreatedAt,
agentIds
}, "activity");
return activity;
}
}
+1
View File
@@ -8,6 +8,7 @@ public interface ITaskRepository
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default);
+35
View File
@@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
return task;
}
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (!db.Database.IsRelational())
{
var task = await db.Tasks
.FirstOrDefaultAsync(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore, ct);
if (task is null)
{
return false;
}
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
await db.SaveChangesAsync(ct);
return true;
}
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
return affectedRows > 0;
}
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
task.UpdatedAt = DateTimeOffset.UtcNow;
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public static class AgentActivityText
{
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
[
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
];
private static readonly Regex[] ResidualSensitivePatterns =
[
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
];
private static readonly string[] KnownActorIds =
[
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
"bao",
"nexus-system"
];
public static string RedactForDisplay(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return content ?? string.Empty;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var sanitized = lines[i];
foreach (var (pattern, replacement) in InlineRedactions)
{
sanitized = pattern.Replace(sanitized, replacement);
}
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
sanitized = "[redacted sensitive line]";
lines[i] = sanitized;
}
return string.Join('\n', lines).Trim();
}
public static bool MatchesAgent(string? content, string agentId)
{
if (string.IsNullOrWhiteSpace(agentId))
return false;
var normalized = agentId.Trim().ToLowerInvariant();
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
public static string[] ExtractAgentIds(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return [];
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var actorId in KnownActorIds)
{
if (BuildActorRegex(actorId).IsMatch(content))
matches.Add(actorId);
}
return matches
.Select(actorId => actorId.ToLowerInvariant())
.OrderBy(actorId => actorId, StringComparer.Ordinal)
.ToArray();
}
private static Regex BuildActorRegex(string actorId)
=> ActorPatternCache.GetOrAdd(actorId, static key =>
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
}
+88 -5
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
if (!AllowedFiles.Contains(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
@@ -37,18 +40,44 @@ public sealed class AgentConfigService : IAgentConfigService
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var fileKind = DetermineFileKind(fileName);
var validation = Validate(fileName, content, fileKind);
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
var reload = CreateReloadCheck();
if (validation.Errors.Count > 0)
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"workspace_not_found",
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
backup,
reload));
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null;
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"invalid_path",
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
backup,
reload));
var tempPath = safePath + ".tmp";
var backupPath = safePath + ".bak";
var backupCreated = false;
try
{
if (File.Exists(safePath))
{
File.Copy(safePath, backupPath, overwrite: true);
backupCreated = true;
}
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
}
var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
return new AgentConfigSaveAttempt(
new AgentConfigFileSaveResult(
fileName,
fi.Length,
fi.LastWriteTimeUtc,
new AgentConfigValidationResult("passed", fileKind, []),
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
CreateReloadCheck()),
null);
}
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
{
var errors = new List<string>();
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
errors.Add("Filename is invalid.");
else if (!AllowedFiles.Contains(fileName))
errors.Add("File is not allowed for Mission Control editing.");
if (content.IndexOf('\0') >= 0)
errors.Add("Content contains null bytes.");
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
{
try
{
JsonDocument.Parse(content);
}
catch (JsonException ex)
{
errors.Add($"JSON validation failed: {ex.Message}");
}
}
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
}
private static string DetermineFileKind(string fileName)
{
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return "json";
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return "markdown";
return "text";
}
private static AgentConfigReloadCheckResult CreateReloadCheck()
=> new(
"not_supported",
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
}
+44
View File
@@ -0,0 +1,44 @@
namespace Nexus.Api.Services;
public static class AgentIdentityCatalog
{
public static readonly string[] DefaultConfiguredAgentIds =
[
"main",
"iris",
"product-owner",
"programmer",
"programmer-fast",
"reviewer",
"architekt",
"researcher",
"executor"
];
private static readonly string[] WorkflowActorIds =
[
"bao",
"nexus-system"
];
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
foreach (var configuredAgentId in configuredAgentIds)
{
if (!string.IsNullOrWhiteSpace(configuredAgentId))
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
}
return ids;
}
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+82 -16
View File
@@ -20,7 +20,8 @@ public sealed record AgentConfig
public string? AgentDir { get; init; }
[JsonPropertyName("model")]
public string? Model { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; }
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
public string Theme { get; init; } = string.Empty;
}
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primaryModel = reader.GetString();
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo(
string Id,
string Name,
@@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs)
{
var model = config.Model ?? "deepseek/deepseek-v4-flash";
var model = ResolveModel(config);
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
@@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: config.Model ?? "deepseek/deepseek-v4-flash",
Model: ResolveModel(config),
Status: runtimeStatus.Status,
LastSeen: now,
Workspace: config.Workspace,
@@ -165,30 +220,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"product-owner" => "Product Owner",
"programmer" => "Developer",
"programmer-fast" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"main" => "Assistant",
_ => "Custom"
};
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
if (!File.Exists(path))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
@@ -204,29 +264,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
// Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
config = config with { Model = defaults.Model.Primary };
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace };
configs.Add(config);
}
return configs.AsReadOnly();
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
}
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults
{
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("model")]
public AgentDefaultModel? Model { get; init; }
}
private sealed record AgentDefaultModel
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
}
}
+5
View File
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
user.LastLoginAt = DateTimeOffset.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Persist user changes (password upgrade, login timestamp) immediately.
// Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to
// trigger SaveChangesAsync is fragile — if zero tokens are expired the
// tracked changes might not be flushed before the response is produced.
await _users.UpdateAsync(user, ct);
await _users.RemoveExpiredTokensAsync(user.Id, ct);
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
}
+13
View File
@@ -112,6 +112,19 @@ public sealed class DashboardService(
}
}
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
try
{
return await gateway.GetGatewayInfoAsync(ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Gateway info fetch failed");
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
+29 -2
View File
@@ -4,11 +4,38 @@ public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime Mo
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
public sealed record AgentConfigFileSaveResult(
string FileName,
long Size,
DateTime ModifiedAt,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveFailure(
string Code,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveAttempt(
AgentConfigFileSaveResult? SaveResult,
AgentConfigSaveFailure? Failure
);
public interface IAgentConfigService
{
const int MaxConfigFileBytes = 500 * 1024;
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+1
View File
@@ -16,6 +16,7 @@ public interface IDashboardService
Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService
{
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+1
View File
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default);
/// <summary>
+2 -1
View File
@@ -23,9 +23,10 @@ public interface ITaskService
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
+232
View File
@@ -0,0 +1,232 @@
using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
[McpServerToolType]
public sealed class NexusMcpTools(
ITaskBridgeService bridge,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetBoardAsync(ct);
}
[McpServerTool(Name = "nexus_agent_overview")]
[Description("Get agent workflow overview, including waiting and stale task groups.")]
public async Task<AgentWorkflowOverview> GetAgentOverview(
[Description("Stale threshold in hours. Defaults to 2.")]
int staleHours = 2,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
}
[McpServerTool(Name = "nexus_get_task")]
[Description("Get one Nexus task by ID.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
}
[McpServerTool(Name = "nexus_get_children")]
[Description("Get child tasks for a Nexus parent task.")]
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetChildTasksAsync(parentTaskId, ct);
}
[McpServerTool(Name = "nexus_get_activity")]
[Description("Get activity entries for a Nexus task.")]
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateTaskAsync(
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo ?? caller,
ct: ct);
return ToResponse(result, "nexus_create_task");
}
[McpServerTool(Name = "nexus_create_child_task")]
[Description("Create a visible child task under a Nexus parent task for delegation.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
Guid parentTaskId,
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo,
expectedFrom: expectedFrom ?? assignedTo,
startsInProgress: startsInProgress,
ct: ct);
return ToResponse(result, "nexus_create_child_task");
}
[McpServerTool(Name = "nexus_update_status")]
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
Guid taskId,
NexusMcpTaskState state,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
return ToResponse(result, "nexus_update_status");
}
[McpServerTool(Name = "nexus_append_activity")]
[Description("Append an activity/checkpoint entry to a Nexus task.")]
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
return ToActivityResponse(result, "nexus_append_activity");
}
[McpServerTool(Name = "nexus_handoff")]
[Description("Mark a task handoff to another known agent and append handoff activity.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
return ToResponse(result, "nexus_handoff");
}
private async Task<string> ResolveCallerAsync(CancellationToken ct)
{
var context = httpContextAccessor.HttpContext
?? throw new UnauthorizedAccessException("MCP request context is not available.");
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return normalizedHeader;
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
normalizedHeader,
context.Connection.RemoteIpAddress);
}
if (context.User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return normalizedClaim;
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
return "bao";
}
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
allowedActorIds.Contains("nexus-system"))
return "nexus-system";
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static string ToStateString(NexusMcpTaskState state) => state switch
{
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
};
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
TaskBridgeResult<ActivityEvent> result,
string command)
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Data is null
? null
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
}
public enum NexusMcpTaskState
{
Backlog,
InProgress,
Blocked,
Done,
Review
}
+247 -17
View File
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
private static readonly string[] SensitiveMarkers =
[
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
];
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Extract activity from session_status
var isActive = false;
string? currentTask = null;
var statusText = status?["status"]?.GetValue<string>();
if (status is not null)
{
// Check explicit isActive field
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 8. Calculate workload from queue items
var workload = CalculateAgentWorkload(id, queueItems);
var statusKind = DeriveStatusKind(status, isActive);
var statusDetail = DeriveStatusDetail(status, statusKind);
agents.Add(new DashboardAgentInfo(
Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Workload: workload,
Goal: goal,
RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status),
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
StatusKind: statusKind,
StatusDetail: statusDetail,
Elapsed: FormatElapsed(status),
Think: null,
Next: DeriveNext(isActive, currentTask)
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
}
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
{
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
ApplyAuth(request);
using var response = await httpClient.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
? headerValues.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
version = TryGetString(root, "version")
?? TryGetString(root, "gatewayVersion")
?? TryGetString(root, "openclawVersion");
}
catch
{
// Health endpoint may be plain text.
}
}
version = NormalizeOptional(version);
var pinned = requiredVersion is not null;
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
var matches = versionStatus is "matched" or "unpinned";
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
return new GatewayRuntimeInfo(
response.IsSuccessStatusCode,
baseUrl,
version,
requiredVersion,
pinned,
response.IsSuccessStatusCode && matches,
versionStatus,
DateTimeOffset.UtcNow,
message,
warning);
}
catch
{
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
return new GatewayRuntimeInfo(
false,
baseUrl,
null,
requiredVersion,
requiredVersion is not null,
false,
"error",
DateTimeOffset.UtcNow,
"Gateway nicht erreichbar",
warning);
}
}
public async Task<bool> DeleteCronJobAsync(string id)
{
try
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
continue;
// Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200
? msg.Content[..200] + "…"
: msg.Content;
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
var text = redacted.Length > 200
? redacted[..200] + "…"
: redacted;
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text));
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
}
}
catch
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
_ => "badge-slate"
};
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
{
if (!isActive) return "Bereit";
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
return statusText switch
return statusKind switch
{
"thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert",
_ => "Arbeitet"
"connected" => isActive ? "Arbeitet" : "Verbunden",
"thinking" => "Plant",
"blocked" => "Blockiert",
"stale" => "Stale",
"error" => "Fehler",
"unsupported" => "Unsupported",
"ready" => "Bereit",
_ => statusText?.ToLowerInvariant() switch
{
"thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert",
_ => isActive ? "Arbeitet" : "Bereit"
}
};
}
private static string DeriveStatusKind(JsonNode? status, bool isActive)
{
if (status is null)
return "error";
var statusText = status["status"]?.GetValue<string>()?.Trim();
var errorText = status["error"]?.GetValue<string>()?.Trim()
?? status["message"]?.GetValue<string>()?.Trim();
var normalized = statusText?.ToLowerInvariant();
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
if (detail.Contains("unsupported", StringComparison.Ordinal))
return "unsupported";
if (!string.IsNullOrWhiteSpace(errorText)
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
return "error";
if (normalized is "blocked" or "block")
return "blocked";
if (normalized is "thinking" or "think")
return "thinking";
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
return "stale";
if (isActive || normalized is "active" or "running" or "connected" or "online")
return "connected";
return "ready";
}
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
{
if (status is null)
return "Gateway-Status nicht abrufbar";
var message = NormalizeOptional(status["message"]?.GetValue<string>())
?? NormalizeOptional(status["error"]?.GetValue<string>())
?? NormalizeOptional(status["detail"]?.GetValue<string>());
if (message is not null)
return message;
return statusKind switch
{
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
"error" => "Session-Status konnte nicht gelesen werden",
_ => null
};
}
private static string? FormatElapsed(JsonNode? status)
{
var lastActivity = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>();
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is null) return null;
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
var diff = DateTimeOffset.UtcNow - ts;
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
"main" => "Assistant",
_ => "Custom"
};
private static string? TryGetString(JsonElement root, string property)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
public static string RedactSensitiveText(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var lower = lines[i].ToLowerInvariant();
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
{
lines[i] = "[redacted sensitive line]";
}
}
return string.Join('\n', lines);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
{
var raw = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>()
?? status?["updatedAt"]?.GetValue<string>();
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
}
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
{
if (!reachable)
return "error";
if (requiredVersion is null)
return version is null ? "unknown" : "unpinned";
if (version is null)
return "missing";
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
}
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
{
if (!reachable)
return "Gateway nicht erreichbar";
return versionStatus switch
{
"matched" => "Gateway erreichbar und Version gepinnt",
"missing" => requiredVersion is null
? "Gateway erreichbar"
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
"unpinned" => "Gateway erreichbar",
"unknown" => "Gateway erreichbar, Version nicht erkannt",
_ => "Gateway erreichbar"
};
}
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
{
if (!reachable)
return fallback ?? "Gateway nicht erreichbar";
return versionStatus switch
{
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
_ => null
};
}
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
{
if (lastActivity is null)
return "Letzte Aktivität ist veraltet";
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalMinutes < 60)
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24)
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
}
}
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Primitives;
namespace Nexus.Api.Services;
public static class RequestAuthorizationHelper
{
public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized);
public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) =>
httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration);
public static bool IsPrivilegedUser(HttpContext httpContext) =>
httpContext.User.Identity?.IsAuthenticated == true &&
(httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin"));
public static async Task<string?> ResolveAllowedAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
public static async Task<AgentHeaderResolution> ResolveAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
{
var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false);
var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed);
return new AgentHeaderResolution(
normalized,
HeaderProvided: true,
IsRecognized: normalized is not null);
}
public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration)
{
var configuredApiKey = configuration["NexusApiKey"];
if (string.IsNullOrWhiteSpace(configuredApiKey))
return false;
if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey))
return false;
return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryBackgroundService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
if (resetCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Stale task recovery run failed.");
}
try
{
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
{
await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
}
}
@@ -0,0 +1,13 @@
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryOptions
{
public const string SectionName = "TaskRecovery";
public int StaleHours { get; set; } = 2;
public int IntervalMinutes { get; set; } = 30;
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
}
@@ -0,0 +1,209 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
{
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = await GetStaleTasksAsync(threshold, ct);
if (staleTasks.Count == 0)
return 0;
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
var now = DateTimeOffset.UtcNow;
var resetCount = 0;
foreach (var task in staleTasks)
{
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
continue;
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
currentTask.Id,
threshold,
now,
ct);
if (!updated)
continue;
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task",
Message = message,
TaskId = task.Id
}, ct);
resetCount++;
}
if (resetCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return resetCount;
}
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
return allTasks
.Where(task => IsStaleInProgress(task, threshold))
.ToList();
}
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
&& task.UpdatedAt < threshold;
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
IEnumerable<Guid> taskIds,
CancellationToken ct)
{
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
return activities
.Where(activity => activity.TaskId.HasValue)
.GroupBy(activity => activity.TaskId!.Value)
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
}
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList();
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
var backlog = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in allTasks)
{
var dto = MapToDtoWithChildren(task, allTasks, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": backlog.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: backlog.Add(dto); break;
}
}
backlog.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(backlog, inProgress, review, blocked, done);
}
private static DashboardTaskDto MapToDtoWithChildren(
WorkTask task,
IReadOnlyList<WorkTask> allTasks,
IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks
.Where(candidate => candidate.ParentTaskId == task.Id)
.OrderByDescending(candidate => candidate.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(entry => entry.TaskId == task.Id)
.OrderByDescending(entry => entry.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
task.ParentTaskId.HasValue || task.IsAgentTask);
}
private static string BuildActivityMessage(
WorkTask task,
TimeSpan staleThreshold,
DateTimeOffset now,
DateTimeOffset? lastActivityAt)
{
var staleAge = now - task.UpdatedAt;
var details = new List<string>
{
"reason=stale-recovery",
"previous status In progress",
$"stale reference {now:O}",
$"stale age {FormatDuration(staleAge)}",
$"threshold {FormatDuration(staleThreshold)}"
};
if (lastActivityAt.HasValue)
details.Add($"last activity {lastActivityAt.Value:O}");
details.Add($"last update {task.UpdatedAt:O}");
details.Add("new status Backlog");
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
}
private static string FormatDuration(TimeSpan duration)
=> duration.ToString(@"dd\.hh\:mm\:ss");
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
}
+34 -18
View File
@@ -15,6 +15,7 @@ namespace Nexus.Api.Services;
/// </summary>
public sealed class TaskBridgeService(
ITaskService taskService,
IAgentService agentService,
IActivityRepository activityRepo,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService
@@ -37,12 +38,11 @@ public sealed class TaskBridgeService(
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -56,6 +56,7 @@ public sealed class TaskBridgeService(
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
@@ -66,19 +67,23 @@ public sealed class TaskBridgeService(
if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct);
// If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct);
if (parentTransition.Outcome != TaskOperationOutcome.Success)
{
return Error<DashboardTaskDto>(
TaskBridgeOutcome.InvalidState,
$"Parent task {parentTaskId} could not be moved to In progress for coordination.");
}
}
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -107,7 +112,7 @@ public sealed class TaskBridgeService(
if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = MapToDto(result.Task!);
var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
return Success(dto);
}
@@ -157,7 +162,10 @@ public sealed class TaskBridgeService(
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
if (normalizedTarget is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}";
@@ -186,7 +194,7 @@ public sealed class TaskBridgeService(
task.Id,
ct);
var dto = MapToDto(task);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
@@ -207,8 +215,11 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
return children.Select(MapToDto).ToList();
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
@@ -233,14 +244,19 @@ public sealed class TaskBridgeService(
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo)
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
+69 -57
View File
@@ -9,12 +9,11 @@ public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo,
INotificationService notificationService,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
{
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
@@ -90,12 +89,7 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
@@ -204,7 +198,7 @@ public sealed class TaskService(
}
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo);
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask
@@ -250,14 +244,14 @@ public sealed class TaskService(
public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
{
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = normalizedExpectedFrom;
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
@@ -322,7 +316,7 @@ public sealed class TaskService(
}
if (assignedTo is not null)
{
var validated = ValidateAssignedTo(assignedTo);
var validated = await NormalizeActorAsync(assignedTo, ct);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
@@ -373,12 +367,24 @@ public sealed class TaskService(
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
}
public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(
task,
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
actor: "nexus-system",
activityType: "delegation",
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
ct: ct);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
@@ -481,12 +487,7 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
@@ -495,30 +496,8 @@ public sealed class TaskService(
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
}
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
var prevState = task.State;
task.State = "Backlog";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
TaskId = task.Id
}, ct);
}
if (staleTasks.Count > 0)
await PublishBoardSnapshotAsync(ct);
return staleTasks.Count;
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
@@ -577,11 +556,25 @@ public sealed class TaskService(
t.ParentTaskId.HasValue || t.IsAgentTask);
}
private static string? ValidateAssignedTo(string? assignedTo)
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var lower = assignedTo.Trim().ToLowerInvariant();
return ValidAssignees.Contains(lower) ? lower : null;
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
{
if (!string.IsNullOrWhiteSpace(initialState))
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
if (canonical is not null)
return canonical;
}
return startsInProgress
? TaskStateHelper.ToStateString(TaskState.InProgress)
: TaskStateHelper.ToStateString(TaskState.Backlog);
}
private string ResolveCaller()
@@ -598,10 +591,29 @@ public sealed class TaskService(
return nameClaim?.ToLowerInvariant() ?? "";
}
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
WorkTask task,
string canonical,
string actor,
string activityType,
string? activityMessage,
CancellationToken ct)
{
var caller = ResolveCaller();
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = activityType,
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
TaskId = task.Id
}, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
{
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
+5
View File
@@ -5,6 +5,7 @@
"Integrations": {
"OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789",
"RequiredVersion": "",
"Token": "",
"Password": ""
},
@@ -21,5 +22,9 @@
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"TaskRecovery": {
"StaleHours": 2,
"IntervalMinutes": 30
},
"AllowedHosts": "*"
}
+11 -24
View File
@@ -1,9 +1,8 @@
name: nexus
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
restart: always
deploy:
resources:
limits:
@@ -29,22 +28,19 @@ services:
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./backend
restart: unless-stopped
args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
@@ -52,12 +48,8 @@ services:
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
Jwt__Issuer: ${JWT_ISSUER:-nexus}
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
# OWNER_PASSWORD is only used during initial seed (first deploy).
# After that the DB is the single source of truth, enforced by SeedAudit.
# Default: empty (seed uses a random password if unset on first run).
Owner__Password: ${OWNER_PASSWORD:-}
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
# Initial owner password is generated once at first seed and then lives only in the DB.
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
@@ -91,22 +83,19 @@ services:
options:
max-size: "10m"
max-file: "3"
web:
build:
context: ./frontend
restart: unless-stopped
args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
labels:
- "traefik.enable=true"
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
@@ -133,13 +122,11 @@ services:
options:
max-size: "10m"
max-file: "3"
networks:
nexus:
openclaw_default:
external: true
proxy:
external: true
volumes:
nexus-postgres:
+21 -3
View File
@@ -10,6 +10,7 @@ Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
- **Sub-Agenten** als ausführende Spezialisten
- **OpenClaw** als Agent-Runtime
- **Nexus Task Board** als sichtbare Aufgabenquelle
- **MCP `/mcp`** als Agent Data Plane fuer Board-Operationen
---
@@ -24,6 +25,8 @@ Das bedeutet:
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
- **OpenClaw** = Ausführungspfad für Agentenarbeit
- **MCP** = bevorzugter Agentenpfad zu Nexus; `/api/bridge` bleibt
kompatible interne Fassade, `/api/dashboard` bleibt UI/Admin
---
@@ -57,7 +60,7 @@ flowchart LR
Iris -->|delegiert konkrete Arbeit| OC
OC -->|führt Agenten-Task aus| Agents
Iris -->|legt Child-Tasks an| Board
Agents -->|arbeiten gegen Child-Tasks| Board
Agents -->|MCP Tools /mcp| Board
Agents -->|liefern Ergebnis / melden Blocker| Iris
Iris -->|integriert Ergebnis| Board
Board -->|Review für Bao| Bao
@@ -89,6 +92,13 @@ flowchart LR
- liefert Nachrichten, Status und Arbeitsergebnisse zurück
- ersetzt nicht das Board als Aufgabenwahrheit
### MCP Agent Data Plane
- stellt `nexus_get_board`, `nexus_agent_overview`, Task-, Child-,
Activity-, Status-, Checkpoint- und Handoff-Tools bereit
- nutzt nur kanonische States: `Backlog`, `In progress`, `Blocked`,
`Done`, `Review`
- ist Fassade ueber `ITaskBridgeService`, keine zweite Board-Domaenenlogik
### Nexus Task Board
- ist die **sichtbare operative Quelle** für Aufgaben
- zeigt Parent-Task, Child-Tasks, Ownership und Status
@@ -266,8 +276,10 @@ Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Do
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
### Mögliche Child-Tasks
- **Backend-State-Handling anpassen** — Owner: `developer`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer`
- **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner`
- **Schnelle Voranalyse / kleiner Patch** — Owner: `programmer-fast`
- **Backend-State-Handling anpassen** — Owner: `programmer`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `programmer`
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
@@ -309,9 +321,15 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
- `AssignedTo` zeigt den operativen Owner
- Child-Tasks dürfen geplant in `Backlog` erstellt werden; nur aktiv gestartete Delegationen beginnen direkt in `In progress`
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
- UI und Doku müssen dieselbe Sprache sprechen
- Mission-Control-Gateway-Daten bleiben read-only im Browser: Nexus proxyt Status,
Version und redigierte Activity; Gateway-Token und direkte Gateway-URLs bleiben
im Backend.
- Config-Writes sind Bao/Owner-only, legen vor dem Austausch ein `.bak` an und
schreiben einen `config_audit` Activity-Eintrag.
---
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.pnpm-store/
.pnpm-home/
.corepack-home/
.git/
.gitignore
.env
*.log
+6
View File
@@ -7,6 +7,12 @@ COPY . .
RUN pnpm build
FROM nginx:1.27-alpine
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
LABEL org.opencontainers.image.title="Nexus Web" \
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
org.opencontainers.image.version="${NEXUS_VERSION}" \
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+5 -1
View File
@@ -32,7 +32,11 @@ const navigate = (label: string) => {
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
const standaloneViews = computed(() => {
if (route.name === 'Dashboard') return true
if (route.meta?.standalone) return true
return false
})
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
+113 -2
View File
@@ -4,7 +4,8 @@ import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, C
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api'
import { useOperationsStore } from '../stores/operations'
import { useAuthStore } from '../stores/auth'
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
@@ -13,8 +14,13 @@ const emit = defineEmits<{
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() {
if (agentsLoading.value) return
@@ -23,12 +29,43 @@ async function loadAgents() {
agentsLoading.value = false
}
async function loadPendingApprovals() {
if (!canModerateApprovals.value) {
pendingApprovals.value = []
pendingApprovalsError.value = ''
return
}
pendingApprovalsLoading.value = true
pendingApprovalsError.value = ''
try {
pendingApprovals.value = await store.fetchPendingApprovals()
} catch (e) {
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
} finally {
pendingApprovalsLoading.value = false
}
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
if (v === 'Task Board') void loadPendingApprovals()
})
watch(canModerateApprovals, (value) => {
if (props.view !== 'Task Board') return
if (value) {
void loadPendingApprovals()
return
}
pendingApprovals.value = []
pendingApprovalsError.value = ''
})
const newProject = ref('')
@@ -51,6 +88,7 @@ async function handleApproveTask(id: string) {
taskActionError.value = ''
try {
await store.approveTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally {
@@ -63,6 +101,7 @@ async function handleRejectTask(id: string) {
taskActionError.value = ''
try {
await store.rejectTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally {
@@ -187,6 +226,31 @@ async function sendMessage() {
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
<span class="kicker">Owner approvals</span>
<h3>Pending approvals</h3>
</div>
<span class="badge">{{ pendingApprovals.length }}</span>
</header>
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue</p>
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
<div v-else class="approval-list">
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
<div>
<strong>{{ task.title }}</strong>
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
</section>
<div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
@@ -214,7 +278,7 @@ async function sendMessage() {
<div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions">
<template v-if="task.state === 'In progress'">
<template v-if="task.state === 'In progress' && canModerateApprovals">
<button
class="task-approve-btn"
title="Approve"
@@ -338,6 +402,53 @@ async function sendMessage() {
</template>
<style scoped>
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.approval-strip-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.approval-strip-head h3 {
margin: 2px 0 0;
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
}
.approval-strip-note.error {
color: #e16e75;
}
.approval-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.approval-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
font-size: 12px;
}
.approval-actions {
display: flex;
gap: 8px;
}
.task-card-head {
display: flex;
align-items: flex-start;
@@ -10,6 +10,9 @@ defineProps<{
saving: boolean
saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
}>()
defineEmits<{
@@ -60,6 +63,12 @@ function onInput(event: Event) {
</div>
</div>
<div v-if="reloadMessage" class="editor-health">
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
<span class="health-note">{{ reloadMessage }}</span>
</div>
<!-- Text editor -->
<textarea
class="config-editor"
@@ -89,6 +98,17 @@ function onInput(event: Event) {
border-bottom: 1px solid var(--line, #1e2030);
gap: 12px;
}
.editor-health {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--line, #1e2030);
background: rgba(255,255,255,.015);
color: #8e96a8;
font-size: 10.5px;
flex-wrap: wrap;
}
.editor-file-info {
display: flex;
align-items: center;
@@ -165,6 +185,30 @@ function onInput(event: Event) {
opacity: 0.4;
cursor: not-allowed;
}
.health-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line, #1e2030);
border-radius: 999px;
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.health-pill.created {
color: #51d49a;
border-color: rgba(81,212,154,.3);
}
.health-pill.not_applicable {
color: #d4b26a;
border-color: rgba(212,178,106,.25);
}
.health-pill.not_supported {
color: #9aa4bb;
border-color: rgba(154,164,187,.25);
}
.health-note {
color: #7e8799;
}
.config-editor {
width: 100%;
+19
View File
@@ -1,5 +1,24 @@
import type { AgentNodeData } from '../types/agentNode'
export const TASK_AGENT_OPTIONS = [
{ id: '', label: 'Nicht zugewiesen' },
{ id: 'bao', label: '👤 Bao' },
{ id: 'iris', label: '🤖 Iris' },
{ id: 'product-owner', label: '📋 Product Owner' },
{ id: 'programmer', label: '🛠 Programmer' },
{ id: 'programmer-fast', label: '⚡ Programmer Fast' },
{ id: 'reviewer', label: '🔎 Reviewer' },
{ id: 'architekt', label: '🏛 Architekt' },
{ id: 'researcher', label: '🔬 Researcher' },
{ id: 'executor', label: '🚀 Executor' },
] as const
export const TASK_AGENT_LABELS: Record<string, string> = Object.fromEntries(
TASK_AGENT_OPTIONS
.filter(option => option.id)
.map(option => [option.id, option.label])
) as Record<string, string>
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
{
id: 'qa',
+4
View File
@@ -17,7 +17,9 @@ interface CatalogEntry {
const AGENT_CATALOG: Record<string, CatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
'product-owner': { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
'programmer-fast': { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
@@ -33,7 +35,9 @@ function resolveStatus(isActive: boolean, currentTask: string | null): AgentNode
function resolveAvatar(id: string, name: string): string {
if (id === 'iris') return 'IR'
if (id === 'product-owner') return 'PO'
if (id === 'programmer' || id === 'developer') return '</>'
if (id === 'programmer-fast') return 'PF'
return name.slice(0, 2).toUpperCase()
}
+12 -12
View File
@@ -28,22 +28,22 @@ const routes = [
],
},
{ path: '/memory', name: 'Memory', component: MemoryView },
{ path: '/docs', name: 'Docs', component: DocsView },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: '/security', name: 'Security', component: SecurityView },
{ path: '/incidents', name: 'Incidents', component: IncidentsView },
{ path: '/calendar', name: 'Calendar', component: CalendarView },
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
{ path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView },
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView },
{ path: '/settings', name: 'Settings', component: SettingsView },
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
+22 -2
View File
@@ -2,6 +2,15 @@ import { defineStore } from 'pinia'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { apiFetch } from '../services/api'
export interface PendingApprovalTask {
id: string
title: string
state: string
priority: string
projectId?: string | null
updatedAt: string
}
const fallback: OperationsSnapshot = {
generatedAt: new Date().toISOString(),
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
@@ -22,6 +31,11 @@ export const useOperationsStore = defineStore('operations', {
connected: false,
}),
actions: {
async fetchPendingApprovals(): Promise<PendingApprovalTask[]> {
const response = await apiFetch('/api/v1/tasks/pending-approval')
if (!response.ok) throw new Error('Pending approvals could not be loaded')
return await response.json()
},
async createProject(name: string) {
const response = await apiFetch('/api/v1/projects', {
method: 'POST',
@@ -145,7 +159,10 @@ export const useOperationsStore = defineStore('operations', {
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
method: 'POST',
})
if (!response.ok) throw new Error('Task could not be approved')
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be approved' }))
throw new Error(err.detail || 'Task could not be approved')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) {
this.snapshot.tasks.splice(index, 1)
@@ -161,7 +178,10 @@ export const useOperationsStore = defineStore('operations', {
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
method: 'POST',
})
if (!response.ok) throw new Error('Task could not be rejected')
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Task could not be rejected' }))
throw new Error(err.detail || 'Task could not be rejected')
}
const index = this.snapshot.tasks.findIndex(task => task.id === id)
if (index !== -1) {
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
+8
View File
@@ -29,6 +29,10 @@ export interface DashboardTaskDto {
expectedFrom?: string | null
lastActivityMessage?: string | null
lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null
childTaskCount?: number
openChildTaskCount?: number
hasVisibleDelegation?: boolean
}
export interface BoardGroup {
@@ -319,6 +323,8 @@ export const useTaskStore = defineStore('tasks', {
assignedTo?: string
expectedFrom?: string
parentTaskId?: string | null
startsInProgress?: boolean
initialState?: string | null
}) {
try {
const res = await apiFetch('/api/dashboard/tasks/agent', {
@@ -331,6 +337,8 @@ export const useTaskStore = defineStore('tasks', {
assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null,
parentTaskId: data.parentTaskId ?? null,
startsInProgress: data.startsInProgress ?? true,
initialState: data.initialState ?? null,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
+435 -5
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { onMounted, onUnmounted, ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity } from '@lucide/vue'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
import { apiFetch } from '../services/api'
import type { AgentDetail } from '../types'
import ConfigTabs from '../components/config/ConfigTabs.vue'
import ConfigEditor from '../components/config/ConfigEditor.vue'
import { openDashboardLiveStream } from '../services/live'
const route = useRoute()
const router = useRouter()
@@ -18,6 +19,19 @@ const configFiles = ref<ConfigFileInfo[]>([])
const activeTab = ref(0)
const configsLoading = ref(false)
const configsError = ref('')
const activityItems = ref<AgentActivityItem[]>([])
const activityLoading = ref(false)
const activityError = ref('')
const summaryLoading = ref(false)
const summaryError = ref('')
const summary = ref<AgentSummary | null>(null)
const liveConnected = ref(false)
const liveUnavailable = ref(false)
let liveAbort: AbortController | null = null
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
let liveStreamStopped = false
let lastLiveSequence = 0
const initLoading = ref(true)
@@ -28,6 +42,9 @@ interface EditorState {
dirty: boolean
saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
}
interface ConfigFileInfo {
@@ -40,6 +57,46 @@ interface ConfigFileDetail extends ConfigFileInfo {
content: string
}
interface AgentActivityItem {
id: number | null
type: string
message: string
at: string
source: string
relativeTime?: string | null
}
interface AgentSummary {
now: AgentSummaryItem
today: AgentSummaryItem
generatedAt: string
}
interface AgentSummaryItem {
text: string
source: string
timestamp?: string | null
}
interface SaveConfigResult {
fileName: string
size: number
modifiedAt: string
validation: {
status: string
fileKind: string
errors: string[]
}
backup: {
status: string
backupCreated: boolean
}
reloadCheck: {
status: string
message: string
}
}
const editorState = ref<EditorState>({
content: '',
savedContent: '',
@@ -47,6 +104,9 @@ const editorState = ref<EditorState>({
dirty: false,
saveStatus: 'idle',
saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
})
const agentId = route.params.id as string
@@ -99,6 +159,22 @@ function formatLastSeen(dateStr?: string): string {
})
}
function formatActivityTime(item: AgentActivityItem): string {
if (item.relativeTime) return item.relativeTime
const d = new Date(item.at)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function activityTypeLabel(type: string): string {
if (type === 'thinking') return 'Thinking'
if (type === 'handoff') return 'Handoff'
if (type === 'task') return 'Task'
return 'Activity'
}
async function loadAgent() {
loading.value = true
error.value = ''
@@ -113,6 +189,110 @@ async function loadAgent() {
}
}
async function loadActivity() {
activityLoading.value = true
activityError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
if (!response.ok) throw new Error('Failed to load activity')
activityItems.value = await response.json()
} catch (e) {
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
} finally {
activityLoading.value = false
}
}
async function loadSummary() {
summaryLoading.value = true
summaryError.value = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
if (!response.ok) throw new Error('Failed to load summary')
summary.value = await response.json()
} catch (e) {
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
} finally {
summaryLoading.value = false
}
}
function scheduleActivityReload() {
if (activityReloadTimer) return
activityReloadTimer = setTimeout(async () => {
activityReloadTimer = null
await loadActivity()
await loadSummary()
}, 250)
}
function formatSummaryTimestamp(value?: string | null): string {
if (!value) return 'No timestamp'
const d = new Date(value)
return d.toLocaleDateString('de-DE', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function summarySourceLabel(source: string): string {
switch (source) {
case 'nexus-activity': return 'Nexus activity'
case 'gateway-session-history': return 'Gateway history'
case 'derived-mixed': return 'Derived from mixed feed'
case 'none': return 'No data'
default: return source
}
}
function scheduleStreamReconnect() {
if (liveStreamStopped || liveReconnectTimer) return
liveReconnectTimer = setTimeout(() => {
liveReconnectTimer = null
void connectActivityStream()
}, 1500)
}
async function connectActivityStream() {
liveAbort?.abort()
liveAbort = new AbortController()
try {
const stream = await openDashboardLiveStream((event, data) => {
const cursor = (data as any)?.cursor
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
if (event === 'snapshot') {
liveConnected.value = true
liveUnavailable.value = false
return
}
if (event !== 'update') return
const envelope = (data as any)?.envelope
if (envelope?.type !== 'activity.created') return
const agentIds = Array.isArray(envelope?.payload?.agentIds)
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
: []
if (agentIds.includes(agentId.toLowerCase())) {
scheduleActivityReload()
}
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
await stream.closed
if (!liveStreamStopped) {
liveConnected.value = false
scheduleStreamReconnect()
}
} catch {
liveConnected.value = false
liveUnavailable.value = true
if (!liveStreamStopped) scheduleStreamReconnect()
}
}
async function loadConfigFiles() {
configsLoading.value = true
configsError.value = ''
@@ -147,6 +327,9 @@ async function loadFileContent(fileName: string) {
dirty: false,
saveStatus: 'idle',
saveMessage: '',
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
} catch (e) {
editorState.value = {
@@ -156,6 +339,9 @@ async function loadFileContent(fileName: string) {
dirty: false,
saveStatus: 'error',
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
backupStatus: 'not_applicable',
reloadStatus: 'not_supported',
reloadMessage: '',
}
}
}
@@ -180,6 +366,9 @@ async function saveFile() {
editorState.value.saving = true
editorState.value.saveStatus = 'idle'
editorState.value.saveMessage = ''
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
editorState.value.reloadMessage = ''
try {
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
@@ -190,14 +379,21 @@ async function saveFile() {
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error((err as any).error || 'Failed to save file')
const problem = err as { error?: string; errors?: Record<string, string[]> }
const detail = problem.error
|| Object.values(problem.errors ?? {}).flat().join(' ')
|| 'Failed to save file'
throw new Error(detail)
}
const result: { fileName: string; size: number; modifiedAt: string } = await response.json()
const result: SaveConfigResult = await response.json()
editorState.value.savedContent = editorState.value.content
editorState.value.dirty = false
editorState.value.saveStatus = 'saved'
editorState.value.saveMessage = 'Gespeichert'
editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
editorState.value.backupStatus = result.backup.status
editorState.value.reloadStatus = result.reloadCheck.status
editorState.value.reloadMessage = result.reloadCheck.message
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
if (idx >= 0) {
@@ -213,19 +409,33 @@ async function saveFile() {
} catch (e) {
editorState.value.saveStatus = 'error'
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
editorState.value.backupStatus = 'not_applicable'
editorState.value.reloadStatus = 'not_supported'
} finally {
editorState.value.saving = false
}
}
onMounted(async () => {
liveStreamStopped = false
initLoading.value = true
await Promise.allSettled([
loadAgent(),
loadConfigFiles(),
loadActivity(),
loadSummary(),
])
connectActivityStream()
initLoading.value = false
})
onUnmounted(() => {
liveStreamStopped = true
liveAbort?.abort()
liveAbort = null
if (activityReloadTimer) clearTimeout(activityReloadTimer)
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
})
</script>
<template>
@@ -269,6 +479,85 @@ onMounted(async () => {
</div>
</div>
<section class="thinking-section">
<header class="section-head">
<div>
<span class="eyebrow">LIVE</span>
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
<p class="section-note">
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
</p>
</div>
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
</button>
</header>
<div v-if="summaryLoading && !summary" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading summaries...
</div>
<div v-else-if="summaryError && !summary" class="status-message compact error">
<AlertCircle :size="16" />
{{ summaryError }}
</div>
<div v-else-if="summary" class="summary-row">
<div class="summary-card">
<span>Now</span>
<p>{{ summary.now.text }}</p>
<small>{{ summarySourceLabel(summary.now.source) }} · {{ formatSummaryTimestamp(summary.now.timestamp) }}</small>
</div>
<div class="summary-card">
<span>Today</span>
<p>{{ summary.today.text }}</p>
<small>{{ summarySourceLabel(summary.today.source) }} · {{ formatSummaryTimestamp(summary.today.timestamp) }}</small>
</div>
</div>
<div v-else class="status-message compact">
No summary available.
</div>
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
<AlertCircle :size="14" />
{{ summaryError }}
</div>
<div v-if="liveUnavailable" class="status-message compact">
Live stream reconnecting
</div>
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading activity...
</div>
<div v-else-if="activityError" class="status-message compact error">
<AlertCircle :size="16" />
{{ activityError }}
</div>
<div v-else-if="activityItems.length" class="thinking-list">
<article
v-for="item in activityItems"
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
class="thinking-item"
>
<div class="thinking-meta">
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
<span>{{ formatActivityTime(item) }}</span>
</div>
<p>{{ item.message }}</p>
</article>
</div>
<div v-else class="status-message compact">
No recent activity.
</div>
</section>
<!-- Config section -->
<div class="config-section">
<div v-if="configsLoading" class="status-message">
@@ -297,6 +586,9 @@ onMounted(async () => {
:saving="editorState.saving"
:save-status="editorState.saveStatus"
:save-message="editorState.saveMessage"
:backup-status="editorState.backupStatus"
:reload-status="editorState.reloadStatus"
:reload-message="editorState.reloadMessage"
@update-content="onContentChange"
@save="saveFile"
/>
@@ -343,6 +635,9 @@ onMounted(async () => {
.status-message.error {
color: #e16e75;
}
.status-message.compact {
padding: 20px;
}
.spin {
animation: spin 1s linear infinite;
}
@@ -416,9 +711,144 @@ onMounted(async () => {
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
.status-sep { color: #3d4152; font-size: 11px; }
.thinking-section {
border: 1px solid var(--line);
border-radius: 9px;
background: var(--panel);
margin-bottom: 16px;
overflow: hidden;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.section-head .eyebrow {
display: block;
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--accent, #7b6ef2);
}
.section-head h2 {
margin: 2px 0 0;
color: #e8eaf0;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 6px;
}
.section-note {
margin: 6px 0 0;
color: #6f788b;
font-size: 10px;
line-height: 1.45;
max-width: 560px;
}
.live-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: #6b7385;
}
.live-dot.on {
background: #51d49a;
}
.icon-button {
width: 30px;
height: 30px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(255,255,255,.03);
color: #9ba3b5;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-button:disabled {
opacity: .65;
cursor: default;
}
.thinking-list {
display: grid;
}
.summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: rgba(255,255,255,.05);
border-bottom: 1px solid var(--line);
}
.summary-row > div {
background: var(--panel);
padding: 12px 16px;
}
.summary-card small {
display: block;
margin-top: 7px;
color: #6f788b;
font-size: 9.5px;
line-height: 1.4;
}
.summary-row span {
display: block;
color: #6f788b;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 5px;
}
.summary-row p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.thinking-item {
padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.thinking-item:last-child {
border-bottom: 0;
}
.thinking-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: #6f788b;
font-size: 10px;
}
.type-pill {
padding: 2px 6px;
border-radius: 999px;
border: 1px solid rgba(123,110,242,.24);
color: #aaa1ff;
background: rgba(123,110,242,.08);
font-size: 9px;
}
.thinking-item p {
margin: 0;
color: #cbd0dc;
font-size: 11px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.summary-inline-error {
border-bottom: 1px solid var(--line);
}
@media (max-width: 640px) {
.detail-page {
max-width: 100%;
}
.summary-row {
grid-template-columns: 1fr;
}
}
</style>
+283 -4
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Bot, Code2, Server, Shield, Search, Terminal, Users } from '@lucide/vue'
import { Bot, Code2, Server, Shield, Search, Terminal, Users, Wifi, WifiOff } from '@lucide/vue'
import { apiFetch } from '../services/api'
const router = useRouter()
@@ -12,9 +14,27 @@ interface AgentCard {
tags: string[]
color: string
icon: string
model?: string
statusLabel?: string
statusKind?: 'connected' | 'thinking' | 'blocked' | 'ready' | 'stale' | 'error' | 'unsupported'
statusDetail?: string | null
isActive?: boolean
progress?: number
currentTask?: string | null
}
const agents: AgentCard[] = [
interface GatewayRuntimeInfo {
reachable: boolean
version?: string | null
requiredVersion?: string | null
versionPinned: boolean
versionMatches: boolean
versionStatus: 'matched' | 'drift' | 'missing' | 'unpinned' | 'unknown' | 'error'
message?: string | null
warning?: string | null
}
const fallbackAgents: AgentCard[] = [
{
id: 'iris',
name: 'Iris',
@@ -71,6 +91,97 @@ const agents: AgentCard[] = [
},
]
const agents = ref<AgentCard[]>([])
const gateway = ref<GatewayRuntimeInfo | null>(null)
const loading = ref(false)
const error = ref('')
const agentCount = computed(() => agents.value.length)
const hasAgents = computed(() => agents.value.length > 0)
const gatewayWarning = computed(() => gateway.value?.warning || '')
const gatewayLabel = computed(() => {
if (!gateway.value) return 'Gateway wird geprüft'
if (!gateway.value.reachable) return gateway.value.message || 'Gateway offline'
switch (gateway.value.versionStatus) {
case 'matched':
return `Pinned ${gateway.value.requiredVersion}`
case 'drift':
return 'Version drift'
case 'missing':
return 'Version fehlt'
case 'unknown':
return 'Version unbekannt'
case 'unpinned':
return gateway.value.version ? `Detected ${gateway.value.version}` : 'Unpinned'
default:
return gateway.value.message || 'Gateway online'
}
})
const gatewayChipClass = computed(() => {
if (!gateway.value) return 'neutral'
if (!gateway.value.reachable) return 'error'
if (gateway.value.warning) return 'warn'
return 'ok'
})
async function loadMissionControl() {
loading.value = true
error.value = ''
try {
const [agentsResponse, gatewayResponse] = await Promise.all([
apiFetch('/api/dashboard/agents'),
apiFetch('/api/dashboard/gateway'),
])
if (agentsResponse.ok) {
const data = await agentsResponse.json()
agents.value = data.map((item: any) => enrichAgent(item))
} else {
error.value = await readErrorMessage(agentsResponse, 'Agenten konnten nicht geladen werden')
}
if (gatewayResponse.ok) {
gateway.value = await gatewayResponse.json()
} else {
const gatewayError = await readErrorMessage(gatewayResponse, 'Gateway-Status konnte nicht geladen werden')
error.value = error.value ? `${error.value} · ${gatewayError}` : gatewayError
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Mission Control konnte nicht geladen werden'
} finally {
loading.value = false
}
}
function enrichAgent(item: any): AgentCard {
const fallback = fallbackAgents.find(a => a.id === item.id)
return {
id: item.id,
name: item.name || fallback?.name || item.id,
role: item.role || fallback?.role || 'Agent',
description: item.description || fallback?.description || 'OpenClaw agent',
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
color: fallback?.color ?? '#7e8799',
icon: fallback?.icon ?? 'bot',
model: item.model,
statusLabel: item.statusLabel,
statusKind: item.statusKind,
statusDetail: item.statusDetail,
isActive: item.isActive,
progress: item.progress,
currentTask: item.currentTask,
}
}
async function readErrorMessage(response: Response, fallback: string) {
try {
const payload = await response.json()
return payload?.error || payload?.message || fallback
} catch {
return fallback
}
}
function goToAgent(id: string) {
router.push(`/agents/${id}`)
}
@@ -86,6 +197,34 @@ function resolveIcon(iconName: string) {
default: return Bot
}
}
function statusTone(agent: AgentCard) {
switch (agent.statusKind) {
case 'connected': return 'connected'
case 'thinking': return 'thinking'
case 'blocked': return 'blocked'
case 'stale': return 'stale'
case 'error': return 'error'
case 'unsupported': return 'unsupported'
default: return agent.isActive ? 'connected' : 'ready'
}
}
function statusCopy(agent: AgentCard) {
if (agent.statusDetail) return agent.statusDetail
if (agent.currentTask) return agent.currentTask
switch (agent.statusKind) {
case 'connected': return 'Session ist erreichbar.'
case 'thinking': return 'Agent plant den nächsten Schritt.'
case 'blocked': return 'Agent wartet auf Entblockung.'
case 'stale': return 'Es gab länger kein neues Signal.'
case 'error': return 'Gateway konnte den Session-Status nicht lesen.'
case 'unsupported': return 'Session meldet einen nicht unterstützten Zustand.'
default: return 'Keine aktive Aufgabe gemeldet.'
}
}
onMounted(loadMissionControl)
</script>
<template>
@@ -97,16 +236,28 @@ function resolveIcon(iconName: string) {
</div>
<div class="header-text">
<h1>Agents</h1>
<p class="header-subtitle">{{ agents.length }} AI agents each with a real role and a real personality.</p>
<p class="header-subtitle">{{ agentCount }} agents · {{ gatewayLabel }}</p>
</div>
<div class="gateway-chip" :class="gatewayChipClass">
<Wifi v-if="gateway?.reachable" :size="13" />
<WifiOff v-else :size="13" />
{{ gateway?.version || gatewayLabel }}
</div>
</div>
<div v-if="loading" class="load-error">Lade Gateway-Status...</div>
<div v-else-if="error" class="load-error">{{ error }}</div>
<div v-if="gatewayWarning" class="gateway-warning">
{{ gatewayWarning }}
</div>
<!-- Agent grid -->
<div class="agents-grid">
<div v-if="hasAgents" class="agents-grid">
<article
v-for="agent in agents"
:key="agent.id"
class="agent-card"
:class="`status-${statusTone(agent)}`"
:style="{ '--card-color': agent.color }"
@click="goToAgent(agent.id)"
>
@@ -122,6 +273,15 @@ function resolveIcon(iconName: string) {
</div>
</div>
<p class="card-desc">{{ agent.description }}</p>
<div class="agent-runtime">
<span :class="['runtime-dot', statusTone(agent)]"></span>
<span>{{ agent.statusLabel || (agent.isActive ? 'Arbeitet' : 'Bereit') }}</span>
<span v-if="agent.model" class="runtime-model">{{ agent.model }}</span>
</div>
<p class="runtime-detail">{{ statusCopy(agent) }}</p>
<div class="progress-track">
<span :style="{ width: `${agent.progress ?? 0}%`, background: agent.color }"></span>
</div>
<div class="card-tags">
<span
v-for="tag in agent.tags"
@@ -139,6 +299,10 @@ function resolveIcon(iconName: string) {
</div>
</article>
</div>
<div v-else-if="!loading" class="empty-state">
<h3>Keine Agenten sichtbar</h3>
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
</div>
</div>
</template>
@@ -177,6 +341,58 @@ function resolveIcon(iconName: string) {
font-size: 11px;
color: #7e8799;
}
.gateway-chip {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 9px;
border: 1px solid var(--line);
border-radius: 7px;
color: #9ba3b5;
font-size: 10px;
}
.gateway-chip.ok {
color: #51d49a;
border-color: rgba(81, 212, 154, .25);
}
.gateway-chip.warn {
color: #e5b05e;
border-color: rgba(229, 176, 94, .28);
}
.gateway-chip.error {
color: #f29b9b;
border-color: rgba(242, 155, 155, .3);
}
.load-error {
margin-bottom: 14px;
color: #e5b05e;
font-size: 11px;
}
.gateway-warning,
.empty-state {
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 11px;
border: 1px solid rgba(229, 176, 94, .24);
background: rgba(229, 176, 94, .08);
color: #f1d7aa;
font-size: 11px;
line-height: 1.5;
}
.empty-state {
border-color: var(--line);
background: rgba(255,255,255,.03);
color: #aab2c3;
}
.empty-state h3 {
margin: 0 0 6px;
font-size: 14px;
color: #e8eaf0;
}
.empty-state p {
margin: 0;
}
/* Agent grid */
.agents-grid {
@@ -201,6 +417,15 @@ function resolveIcon(iconName: string) {
box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent);
transform: translateY(-2px);
}
.agent-card.status-error {
border-color: rgba(242, 155, 155, .22);
}
.agent-card.status-unsupported {
border-color: rgba(229, 176, 94, .22);
}
.agent-card.status-stale {
border-color: rgba(244, 164, 96, .22);
}
.card-stripe {
height: 3px;
@@ -256,6 +481,60 @@ function resolveIcon(iconName: string) {
margin: 0 0 10px;
flex: 1;
}
.agent-runtime {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
color: #8a92a5;
font-size: 9.5px;
min-width: 0;
}
.runtime-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #6b7385;
flex-shrink: 0;
}
.runtime-dot.on {
background: #51d49a;
}
.runtime-dot.connected { background: #51d49a; }
.runtime-dot.thinking { background: #79aaff; }
.runtime-dot.blocked { background: #f87171; }
.runtime-dot.stale { background: #f59e0b; }
.runtime-dot.error { background: #f29b9b; }
.runtime-dot.unsupported { background: #e5b05e; }
.runtime-dot.ready { background: #6b7385; }
.runtime-model {
margin-left: auto;
max-width: 46%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: #6f788b;
}
.runtime-detail {
margin: 0 0 10px;
min-height: 28px;
color: #7e8799;
font-size: 10px;
line-height: 1.4;
}
.progress-track {
height: 3px;
border-radius: 999px;
background: rgba(255,255,255,.06);
overflow: hidden;
margin-bottom: 10px;
}
.progress-track span {
display: block;
height: 100%;
border-radius: inherit;
}
.card-tags {
display: flex;
+16 -27
View File
@@ -17,7 +17,8 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useTaskStore } from '../stores/tasks'
import { useLiveSyncStore } from '../stores/liveSync'
import { useLiveSyncStore } from '../stores/live-sync'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
type BoardTask = ReturnType<typeof flattenBoard>[number]
@@ -222,16 +223,7 @@ const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth
function expectedFromLabel(expected: string | null | undefined): string {
if (!expected) return ''
const map: Record<string, string> = {
'bao': '👤 Bao',
'iris': '🤖 Iris',
'programmer': '🛠 Programmer',
'reviewer': '🔎 Reviewer',
'architekt': '🏛 Architekt',
'researcher': '🔬 Researcher',
'executor': '⚡ Executor',
}
return map[expected.toLowerCase()] ?? expected
return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
}
function hoursSince(dateStr: string): number {
@@ -272,8 +264,10 @@ function childStatusSummary(taskId: string): string {
}
function activityHint(task: BoardTask): string {
const childSummary = childStatusSummary(task.id)
if (childSummary) return childSummary
return task.lastActivityMessage?.trim()
|| childStatusSummary(task.id)
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
@@ -827,13 +821,13 @@ onUnmounted(() => {
<div class="field">
<label for="task-assignee">Zugewiesen an</label>
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
<option value="bao">👤 Bao</option>
<option value="iris">🤖 Iris</option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
<option
v-for="option in TASK_AGENT_OPTIONS.filter(entry => entry.id)"
:key="option.id"
:value="option.id"
>
{{ option.label }}
</option>
</select>
</div>
</div>
@@ -951,14 +945,9 @@ onUnmounted(() => {
<label class="sidebar-field">
<span>Zuständig</span>
<select v-model="detailForm.assignedTo" class="field-input field-select slim">
<option value="">Nicht zugewiesen</option>
<option value="bao">👤 Bao</option>
<option value="iris">🤖 Iris</option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
{{ option.label }}
</option>
</select>
</label>
<label class="sidebar-field">
+13 -10
View File
@@ -16,6 +16,7 @@ import {
} from '@lucide/vue'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
/* ── Types ──────────────────────────────────── */
interface TaskDto {
@@ -166,7 +167,9 @@ function childStatusSummary(taskId: string): string {
}
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
return taskLike.lastActivityMessage?.trim()
|| childStatusSummary(taskLike.id)
|| (taskLike.expectedFrom ? `Wartet auf ${TASK_AGENT_LABELS[taskLike.expectedFrom.toLowerCase()] ?? taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
}
function delegationSummary(taskLike: TaskDto): string | null {
@@ -451,7 +454,11 @@ function handleKeydown(e: KeyboardEvent) {
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris, researcher…)" />
<select v-model="subtaskAssign" class="galaxy-input galaxy-select narrow">
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
{{ option.label }}
</option>
</select>
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
</button>
@@ -565,14 +572,9 @@ function handleKeydown(e: KeyboardEvent) {
<label class="sidebar-field">
<span>Zuständig</span>
<select v-model="form.assignedTo" class="galaxy-input galaxy-select">
<option value="">Nicht zugewiesen</option>
<option value="bao">👤 Bao</option>
<option value="iris">🤖 Iris</option>
<option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
{{ option.label }}
</option>
</select>
</label>
<label class="sidebar-field">
@@ -590,6 +592,7 @@ function handleKeydown(e: KeyboardEvent) {
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
<div v-if="task.expectedFrom"><dt>Erwartet von</dt><dd>{{ TASK_AGENT_LABELS[task.expectedFrom.toLowerCase()] ?? task.expectedFrom }}</dd></div>
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
</dl>
</section>
-70
View File
@@ -1,70 +0,0 @@
#!/bin/bash
# Nexus Deployment Script
# Auf dem VPS-HOST ausführen, nicht im Container!
set -e
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
NEXUS_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Nexus Deployment ==="
echo "Verzeichnis: $NEXUS_DIR"
cd "$NEXUS_DIR"
echo ""
echo "[1/4] Prüfe Konfiguration..."
docker compose config --quiet && echo " ✅ Konfiguration gültig"
echo ""
echo "[2/4] Starte Stack (mit Healthchecks)..."
docker compose up -d --wait
echo ""
echo "[3/4] Status nach Deployment..."
docker compose ps
echo ""
echo "[4/4] Verifikation..."
check_code() {
local path="$1"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
}
HEALTH_CODE=$(check_code /health)
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
WEB_CID="$(docker compose ps -q web || true)"
if [ -n "$WEB_CID" ]; then
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
if [ "$WEB_STATE" = "created" ]; then
echo " ️ API healthy, aber web noch im Status 'created' — starte web nach"
docker compose up -d web
sleep 2
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
fi
fi
fi
echo " /health -> ${HEALTH_CODE}"
echo " /dashboard -> ${DASHBOARD_CODE}"
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
echo " ❌ Verifikation fehlgeschlagen"
exit 1
fi
echo " ✅ Health-Check bestanden"
echo " ✅ Dashboard erreichbar"
echo " ✅ Operations API fordert Auth an"
echo ""
echo "=== Deployment abgeschlossen ==="
echo "Dashboard: https://nexus.noveria.net/dashboard"
echo "Health-API: https://nexus.noveria.net/health"
echo ""
echo "Login-Informationen: docker compose logs api | grep 'Initial owner'"
echo "Status: docker compose ps"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL="${OLLAMA_MODEL:-qwen3:4b}"
BIND_ADDRESS="${OLLAMA_BIND_ADDRESS:-172.18.0.1:11434}"
BACKUP_DIR="/root/security-backups/ollama-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run this script as root on the Ubuntu host." >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
if systemctl cat ollama.service >/dev/null 2>&1; then
systemctl cat ollama.service > "${BACKUP_DIR}/ollama.service.before.txt"
fi
if [[ -d /etc/systemd/system/ollama.service.d ]]; then
cp -a /etc/systemd/system/ollama.service.d "${BACKUP_DIR}/"
fi
if ! command -v ollama >/dev/null 2>&1; then
curl -fsSL https://ollama.com/install.sh -o /tmp/ollama-install.sh
sh /tmp/ollama-install.sh
fi
install -d -m 755 /etc/systemd/system/ollama.service.d
cat > /etc/systemd/system/ollama.service.d/10-openclaw.conf <<OVERRIDE
[Service]
Environment="OLLAMA_HOST=${BIND_ADDRESS}"
Environment="OLLAMA_KEEP_ALIVE=15m"
OVERRIDE
systemctl daemon-reload
systemctl enable --now ollama
systemctl restart ollama
max_attempts=30
attempt=1
while [[ "${attempt}" -le "${max_attempts}" ]]; do
if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
break
fi
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
systemctl status ollama --no-pager
exit 1
fi
attempt=$((attempt + 1))
sleep 2
done
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama pull "${MODEL}"
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama show "${MODEL}" >/dev/null
curl -fsS "http://${BIND_ADDRESS}/api/tags"
echo
echo "Ollama ${MODEL} is ready on ${BIND_ADDRESS}. Backup: ${BACKUP_DIR}"
-55
View File
@@ -1,55 +0,0 @@
# ==============================================================================
# Noveria.net Landingpage — Nginx Server Block
# ==============================================================================
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
#
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
# ==============================================================================
server {
listen 443 ssl http2;
server_name noveria.net www.noveria.net;
# SSL (gleiche Zertifikate wie nexus)
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
# Security Header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
location / {
proxy_pass http://127.0.0.1:18881;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name noveria.net www.noveria.net;
return 301 https://$host$request_uri;
}
# ==============================================================================
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
# ==============================================================================
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
# grep -rn "noveria.net" /etc/nginx/sites-available/
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
#
# 2. Config testen nach Änderung
# nginx -t
#
# 3. Nginx neuladen
# systemctl reload nginx
# ==============================================================================
-81
View File
@@ -1,81 +0,0 @@
# /etc/nginx/sites-available/nexus.noveria.net
# Symlink: ln -s /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
server {
listen 80;
server_name nexus.noveria.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name nexus.noveria.net;
# SSL wird per certbot automatisch befüllt
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
# Security-Header
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
client_max_body_size 16m;
# Bridge-Endpunkte: Gateway-zu-Backend-Agent-Pfad
# X-Agent-Id wird durchgereicht für Agent-Identität
location /api/bridge/ {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Agent-Id $http_x_agent_id;
proxy_buffering off;
proxy_read_timeout 120s;
}
# Dashboard SSE stream: single dedicated non-buffered block.
location = /api/dashboard/live {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Accel-Buffering no always;
}
location / {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API-Direktzugriff falls nötig
location /api/ {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-107
View File
@@ -1,107 +0,0 @@
#!/bin/bash
# HTTPS-Setup für nexus.noveria.net
# Auf dem VPS-HOST ausführen!
set -e
echo "=== HTTPS Setup für nexus.noveria.net ==="
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# 1. Zuerst nur HTTP-Config ausrollen (keine SSL-Referenz!)
echo "[1/5] Installiere HTTP-only Nginx-Config..."
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXEOF'
server {
listen 80;
server_name nexus.noveria.net;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
NGINXEOF
sudo ln -sf /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
echo " ✅ HTTP-Config aktiv"
# 2. Firewall
echo "[2/5] Firewall..."
if command -v ufw &>/dev/null; then
sudo ufw allow 80/tcp 2>/dev/null || true
sudo ufw allow 443/tcp 2>/dev/null || true
echo " ✅ ufw: 80+443 offen"
else
echo " ⏭ ufw nicht installiert"
fi
# 3. HTTP-Test
echo "[3/5] Teste HTTP..."
sleep 1
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://nexus.noveria.net)
echo " HTTP-Status: $STATUS"
# 4. Zertifikat holen
echo "[4/5] Fordere Let's-Encrypt-Zertifikat an..."
sudo certbot certonly --webroot -w /var/www/html -d nexus.noveria.net --non-interactive --agree-tos --email vmbao62@hotmail.de 2>&1 || {
echo " ⚠️ certbot fehlgeschlagen manuell nachholen:"
echo " sudo certbot --nginx -d nexus.noveria.net"
exit 1
}
echo " ✅ Zertifikat erhalten"
# 5. HTTPS-Config ausrollen
echo "[5/5] Aktiviere HTTPS-Config..."
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXSSL'
server {
listen 80;
server_name nexus.noveria.net;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name nexus.noveria.net;
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
client_max_body_size 16m;
location / {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
NGINXSSL
sudo nginx -t && sudo systemctl reload nginx
echo " ✅ HTTPS aktiv"
# Test
echo ""
sleep 2
curl -s -o /dev/null -w "HTTPS-Status: %{http_code}\n" https://nexus.noveria.net
echo ""
echo "=== Fertig ==="
echo "Nexus: https://nexus.noveria.net"
+10 -4
View File
@@ -1,12 +1,18 @@
# Changelog
> Letzte Aktualisierung: 2026-06-21
> Letzte Aktualisierung: 2026-06-24
- 2026-06-24: **Gitea CI/CD auf CD v4 repariert.**
- Root Cause: Gitea 1.26.3 erzeugte nach grünen CI-Runs 303/304 keinen `workflow_run`-Deploy; `ubuntu-latest` passte zudem nicht zum belegbar funktionierenden Runner-Label `linux`.
- Fix: Auto-Deploy läuft jetzt als `needs`-Job in `ci.yaml` nach Backend, Frontend und Security. `deploy.yaml` ist ein kleiner manueller Fallback via `workflow_dispatch`.
- Deploy-Logik wurde in `.gitea/scripts/deploy-nexus.sh` zusammengeführt. Secrets bleiben in `/tmp`, es wird kein Owner-Passwort injiziert, und Deploy mutiert Git-History nicht.
- Rollback- und Backup-Workflow nutzen ebenfalls das Runner-Label `linux`.
- 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).**
- Root Cause: Dual-Source-Architektur (Gitea-Secret vs Host-.env) verursachte Passwort-Drift nach DB-Reseed.
- Root Cause: Passwort-Injektion über Deploy-Runtime erzeugte einen unnötigen zweiten Pfad neben der DB und verursachte Drift nach DB-Reseed.
- Code-Fix: `SeedAudit`-Entity + Migration (`20260621081500_AddSeedAudit`) eingebaut. `EnsureDatabaseAsync` prueft jetzt `SeedAudit` VOR dem Seeden. Key `owner_created` blockiert erneutes Seeden permanent.
- Workflow-Fix: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` aus dem Host-`.env` (Single Source of Truth), nicht mehr aus Gitea-Secret.
- `compose.yaml`: Kommentar hinzugefuegt dass OWNER_PASSWORD nur beim initialen Seed verwendet wird.
- Workflow-Fix: Deploy- und Rollback-Workflows injizieren kein `OWNER_PASSWORD` mehr.
- `compose.yaml`: `Owner__Password` entfernt; Bootstrap-Konfig auf `BOOTSTRAP_OWNER_EMAIL` reduziert; Initialpasswort wird nur noch einmalig beim ersten Seed generiert.
- Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`.
- Git: Commit `f95463e`, manuell ausgerollt.
- Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`.
+36 -42
View File
@@ -1,53 +1,46 @@
# Deployment
> Letzte Aktualisierung: 2026-06-21
> Status: ✅ CD v3 (Auto + Manual) + Owner-Passwort-Persistenz (SeedAudit)
> Letzte Aktualisierung: 2026-06-24
> Status: ✅ CD v4 (Auto inside CI + Manual) + Owner-Passwort-Persistenz (SeedAudit)
> Live-URL: https://nexus.noveria.net
## CD-Philosophie (v3)
## CD-Philosophie (v4)
- **CI läuft automatisch** bei jedem Push → darf nie brechen
- **CD auto + manuell**: Automaticher Deploy nach CI-Success auf main (patch default), manueller Deploy mit voller Kontrolle via `workflow_dispatch`
- **Loop-Schutz**: Version-Bump-Commits enthalten `[skip ci]` — kein Re-Trigger der CI, kein Infinite-Loop
- **Main-Deploys** duerfen VERSION bumpen und einen Git-Tag setzen
- **Nicht-Main-Deploys** (anderer `git_ref`) deployen read-only und mutieren Git nicht
- **CD auto + manuell**: Automatischer Deploy als abschließender CI-Job auf main; manueller Deploy via `workflow_dispatch`
- **Loop-Schutz**: Deploy mutiert Git nicht und erzeugt deshalb keine Deploy-Schleifen
- Deploy liest und validiert `VERSION`, mutiert aber weder Git noch Tags
- **Rollback** als eigener Workflow, manuell triggerbar
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)
## Workflows
### Deploy (`.gitea/workflows/deploy.yaml`)
### Auto-Deploy (`.gitea/workflows/ci.yaml`)
**Trigger**:
- **Automatisch**: Der Job `Deploy Nexus` läuft nach `backend`, `frontend` und `security`.
- Bedingung: Push auf `main`, alle CI-Jobs grün.
- Grund: Gitea 1.26.3 hat für grüne CI-Runs 303/304 keinen `workflow_run`-Deploy erzeugt; der zuverlässigste Ersatz ist ein `needs`-gesteuerter Job im selben Workflow.
### Manual Deploy (`.gitea/workflows/deploy.yaml`)
**Trigger**:
- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`)
→ Default-Parameter: patch bump, all services, main ref
- **Manuell**: Via Gitea Actions → `workflow_dispatch`
**Loop-Schutz**:
- Version-Bump-Commits enthalten `[skip ci]` → Gitea startet keine neue CI
- Auto-Deploy prüft zusätzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]`
- Beide Mechanismen zusammen verhindern Endlosschleife: CI → Deploy → Bump → CI …
- Deploy erstellt keine Commits, Tags oder Version-Bumps. Dadurch entsteht keine CI/CD-Schleife.
**Inputs** (nur bei `workflow_dispatch`):
| Input | Typ | Default | Beschreibung |
|---|---|---|---|
| `version_bump` | choice (patch/minor/major) | patch | Version-Bump-Typ |
| `service` | string | (all) | Einzelner Service oder alle |
| `no_cache` | boolean | false | Docker-Build-Cache deaktivieren |
| `git_ref` | string | main | Branch/Tag/Commit zum Deployen |
**Inputs**: keine. Der manuelle Deploy nutzt denselben Main-Deploy-Pfad wie der Auto-Deploy.
**Ablauf**:
1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet
2. Checkout des gewählten Git-Refs
3. Wenn `git_ref = main`: Version-Bump + Git-Tag + Push
4. Wenn `git_ref != main`: VERSION nur lesen, kein Push, kein Tag
5. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
6. Code-Sync zum Host-Deploy-Pfad
7. `docker compose build && up -d --wait --force-recreate`
8. `.env`-Tempfile wird mit `shred` gelöscht
9. Health-Check (exponentieller Backoff, 6 Versuche)
10. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
11. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL
1. Checkout von `main` (beim manuellen Deploy) oder Nutzung des geprüften CI-Checkouts (Auto-Deploy)
2. `.gitea/scripts/deploy-nexus.sh`
3. `VERSION` lesen und SemVer validieren
4. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
5. Code-Sync zum Host-Deploy-Pfad
6. `docker compose build && up -d --force-recreate --remove-orphans --wait`
7. `.env`-Tempfile wird mit `shred` gelöscht
8. Health-Check und Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
### Backup (`.gitea/workflows/backup.yaml`)
@@ -87,6 +80,8 @@ schedule:
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`
**Concurrency**: Rollback nutzt dieselbe `deploy-production`-Gruppe wie Deploy, aber mit `cancel-in-progress: true`. Dadurch gewinnt Rollback gegenüber laufenden oder wartenden Deploys und verhindert, dass ein Auto-Deploy direkt nach einem Rollback den Rollback wieder überschreibt.
**Inputs**:
| Input | Typ | Beschreibung |
|---|---|---|
@@ -109,12 +104,12 @@ schedule:
### Owner Password Persistence (2026-06-21, permanent fix)
**Root Cause**: Dual-Source-Architektur fuer das Owner-Passwort (Gitea-Secret `ENV_OWNER_PASSWORD` vs Host `.env` `OWNER_PASSWORD`) verursachte Drift wenn die DB jemals neu geseedet wurde.
**Root Cause**: Die fruehere Passwort-Injektion ueber Deploy-Runtime schuf einen unnötigen zweiten Pfad neben der DB und machte Passwort-Drift/Re-Seeding-Folgen möglich.
**Fix (3 Schichten)**:
1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird.
2. **Single Source of Truth**: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` jetzt aus dem persistenten Host-`.env` (via `grep` auf dem Deploy-Pfad), NICHT mehr aus separatem Gitea-Secret. Das Host-`.env` ist die kanonische Quelle.
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`).
2. **Single Source of Truth**: Deploy- und Rollback-Workflows injizieren gar kein `OWNER_PASSWORD` mehr. Nach dem ersten Seed ist ausschließlich die DB kanonisch.
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`). Bootstrap läuft nur noch über `BOOTSTRAP_OWNER_EMAIL`.
**Verifikation (2026-06-21)**:
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
@@ -122,7 +117,7 @@ schedule:
- Login funktioniert nach `docker compose restart`
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup
**Regel gegen Wiederholung**: `OWNER_PASSWORD` nur im Host-`.env` aendern. Das Host-`.env` wird von CI-Deploys gelesen. Niemals ein separates Gitea-Secret fuer OWNER_PASSWORD anlegen.
**Regel gegen Wiederholung**: Kein `OWNER_PASSWORD` mehr in Deploy-Runtime, Host-`.env` oder Secrets pflegen. Passwort-Änderungen laufen nur noch über App/DB-Pfade.
### Secrets in Gitea
@@ -134,8 +129,7 @@ Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfi
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
> **Hinweis**: `ENV_OWNER_PASSWORD` wurde aus den Gitea-Secrets ENTFERNT (2026-06-21).
> OWNER_PASSWORD kommt ausschliesslich aus dem Host-`.env` auf dem Deploy-Pfad.
> **Hinweis**: `ENV_OWNER_PASSWORD` bleibt entfernt. `OWNER_PASSWORD` wird auch nicht mehr aus Host-`.env` eingelesen.
### Safe Secret Handling (v3)
@@ -176,7 +170,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
2. Backup vor produktiven Infrastrukturarbeiten
3. Docker-Stack auf dem VPS deployen
4. Datenbankmigration läuft automatisch beim Start (via `MigrateAsync`)
5. Nginx Proxy Manager und `nexus.noveria.net` verbinden
5. Traefik-Router fuer `nexus.noveria.net` per Compose-Labels verbinden
6. HTTPS, Header, Cookies und externe Erreichbarkeit validieren
## Abgeschlossene Deployment-Arbeit
@@ -184,14 +178,14 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt (2026-06-08)
- [x] Datenbankmigration und kompletter Stack per Docker-Compose deployt
- [x] Nexus auf dem VPS deployt (Docker Compose)
- [x] Nginx mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
- [x] Traefik mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
- [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09)
- [x] CI/CD entkoppelt — Deploy darf automatisch (v3) oder manuell (2026-06-13)
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13)
- [x] Deploy liest und validiert `VERSION`, mutiert aber keine Git-Tags oder Version-Dateien (2026-06-23)
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
- [x] Live-Recheck nach Deploy-Stoerung: `/health`, SPA-Root und `GET /api/dashboard/tasks` wieder 200; Bao-Folgetask zur Agent-Progress-Visibility erstellt (2026-06-20)
@@ -213,7 +207,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- /dashboard, /login → SPA-Routing korrekt
- /api/v1/operations/snapshot → 401 Unauthorized (Auth-Schutz aktiv)
- Let's Encrypt TLS-Zertifikat aktiv
- Nginx-Proxy → 127.0.0.1:18880
- Traefik-Proxy → `nexus-web-1:80` ueber externes `proxy`-Netzwerk
## Incident-Hinweis (2026-06-14)
@@ -223,7 +217,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
2. `curl http://127.0.0.1:18880/health`
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
- Der manuelle Helper [`ops/deploy.sh`](/home/node/.openclaw/workspace/nexus/ops/deploy.sh) verifiziert deshalb jetzt nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
- Der CD-Pfad verifiziert deshalb nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
## Offene Arbeit
+29 -2
View File
@@ -1,6 +1,6 @@
# Phase 3
> Letzte Aktualisierung: 2026-06-21
> Letzte Aktualisierung: 2026-07-09
- [ ] Office View
- [ ] Kalender
@@ -13,4 +13,31 @@
## Fokus
Parent-Child-Delegation ist im Board umgesetzt. Offene Phase-3-Schwerpunkte sind jetzt Kalender, Office View und spaetere Reporting-/Visualisierungs-Ausbauten.
Parent-Child-Delegation ist im Board umgesetzt; Activity-Hints priorisieren sichtbaren Child-Fortschritt vor Parent-Aktivitaet. Das Mission-Control-Programm `df39cd02-b944-41a0-8cfc-400616460ef6` macht Nexus agent-first Mission Control: MCP fuer Agenten, Gateway-Read/Control-Plane und UI fuer Board, Agenten, Crons, Sessions, Live-Thinking und begrenzte owner-only Config-/Approval-Schreibpfade. P0-P5 sind implementiert und lokal verifiziert.
## Mission-Control-Programm
- P0 `9aac2bb0-46dd-47e9-8f48-e39ac66e9918`: Stale-Recovery BackgroundService, `Done`.
- P1 `c56ecd42-90d5-43f9-b21b-60e02ecd4977`: MCP-Fundament fuer Board/Data Plane. Offizielles C# MCP-SDK, streamable-http `/mcp`, Tools als Fassade ueber `ITaskBridgeService`, Worker-Migration auf MCP. Backend-Implementierung und Tests erledigt; Gateway-`mcp.servers`-Eintraege bleiben Produktionskonfiguration und werden nicht aus dem Repo heraus mutiert.
- P2 `b1613607-e417-44ce-a7b8-f90b610b2200`: Read-only Mission Control via GatewayConnector, Gateway-Version-Pinning, Agentenkacheln. Status-Semantik kommt aus dem Backend, Gateway-Versionen melden `matched`, `drift`, `missing`, `unpinned`, `unknown`, `error`; Agentenkacheln zeigen `connected`, `thinking`, `blocked`, `ready`, `stale`, `error`, `unsupported`. Verifiziert und erledigt.
- P3 `53b56788-51cd-4b4e-a1d8-98589c002fac`: Live-Thinking Feed und redigierte Now/Today-Summaries. Nexus-Activity streamt live via SSE-Reconnect; Gateway-Session-History bleibt read-only Fallback ohne direkte Live-Events. Now/Today-Summaries sind deterministisch aus redigierter Activity/History abgeleitet.
- P4 `b7b104d5-21a5-47d0-b5e9-609822354334`: Bao-only Config-Schreibpfad und Approvals mit Backup, Validate, Reload-Check und Audit. Owner-only Config-Writes und Approval-Aktionen, Validierung vor Replace, `.bak`, Audit ohne Content/Secrets und ehrlicher Reload-Status `not_supported` sind umgesetzt. JSON-Validierung ist im Save-Pfad vorbereitet, bleibt aber durch die aktuelle Editable-Allowlist nicht exposed.
- P5 `994c0d32-ec5a-408f-be45-870bf073c596`: Konsolidierung, Doku und API-Grenzen. MCP ist bevorzugter Agentenpfad, `/api/bridge` bleibt interne Kompatibilitaetsflaeche, `/api/dashboard` ist UI/Admin. P2-P4-Grenzen sind dokumentiert: Gateway-History ist Fallback-only, Raw-Feed wird nicht dauerhaft persistiert, Config-Hot-Reload ist `not_supported`, JSON-Validation bleibt ohne Allowlist nicht exposed.
Arbeitsreihenfolge abgeschlossen: P1 -> Review -> P2 -> Review -> P3 -> Review -> P4 -> Review -> P5.
## Abschlusslog
- 2026-06-24: TaskBoardView `activityHint` korrigiert, damit Parent-Tasks mit sichtbaren Child-Tasks zuerst den Child-Fortschritt anzeigen. Verifikation: Frontend-`pnpm typecheck` gruen.
- 2026-07-07: Vier delegated Iris Child-Tasks sequenziell per Nexus API verarbeitet und mit Activity-Hinweis versehen. Verifikation: `GET /api/dashboard/tasks/board` `200`, Counts `offen=1`, `inProgress=0`, `review=11`, `blocked=2`, `done=4`.
- 2026-07-07: Ready-Agent-Result-IDs nach Vorgabe verarbeitet: zwei bereits gesetzte Review-Tasks unveraendert gelassen, zwei Done-Tasks ueber `In progress` mit Result-Hinweis nach `Review` bewegt. Verifikation: `GET /api/dashboard/tasks/board` `200`, Counts `offen=1`, `inProgress=0`, `review=13`, `blocked=2`, `done=2`.
- 2026-07-08: Mission-Control-Programm als Backlog-Parent `df39cd02-b944-41a0-8cfc-400616460ef6` angelegt, PO-Child `81ddae64-e833-4d27-b96c-686c4d64296d` fuer Phasenschnitt erstellt und `3d300184-69fc-4215-bb8b-dc06c5c58f57` per Activity als von P1 MCP-Fundament absorbiert markiert. Verifikation: Bridge-API `create_task`, `create_child_task`, `append_activity` erfolgreich.
- 2026-07-08: Bao-Review-Gate geleert: 13 Review-Tasks mit Bao-Abnahme-Hinweis auf `Done` gesetzt. Verifikation: Bridge-Board `review=0`, `done=15`, `offen=5`, `inProgress=1`, `blocked=2`.
- 2026-07-08: Mission-Control-Programm in P1-P5 geschnitten. P0 ist erledigt, PO-Child `81ddae64-e833-4d27-b96c-686c4d64296d` auf `Done`, P2-P5 als Child-Tasks angelegt und Alt-Task `3d300184-69fc-4215-bb8b-dc06c5c58f57` als in P1 absorbiert geschlossen. Naechster Schritt: P1-Developer-Lauf.
- 2026-07-08: P1 MCP-Fundament implementiert: `ModelContextProtocol.AspNetCore` registriert, `/mcp` gemappt, zehn `nexus_*` Tools als Fassade ueber `ITaskBridgeService`, State-Enum ohne `Delegated`, MCP-Tooltests ergaenzt. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 119 Tests.
- 2026-07-08: P3 Teilfortschritt: Agent-Detailseite zeigt read-only Thinking/Activity-Feed aus Nexus-Activity plus Gateway-Session-History-Fallback. Offen bleiben echter Event-Subscribe mit <2s Sichtbarkeit, Now/Today-Summaries, Retention-Entscheidung und Redaction-Abnahme.
- 2026-07-08: P2-P5 integrierter Slice: Gateway-Info-Endpunkt mit optionalem Version-Pin, dynamische Agentenkacheln, Activity-SSE-Reload fuer Thinking, Gateway-Activity-Redaction, Bao/Owner-only Config-Writes mit `.bak` und `config_audit`, Doku/API-Grenzen aktualisiert.
- 2026-07-09: P2 abgenommen: Gateway-Info unterscheidet Pin-Zustaende (`matched`, `drift`, `missing`, `unpinned`, `unknown`, `error`), Agentenstatus wird backendseitig klassifiziert, Frontend zeigt Gateway-Warnungen, Empty/Error-State und Statusdetails ohne lokale Ratesemantik. Verifikation: `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen; `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 125 Tests.
- 2026-07-09: P3 vervollstaendigt und reviewed: Agent-Detailseite reconnectet auf Dashboard-SSE und filtert live `activity.created` ueber explizite `agentIds`; Now/Today-Summaries tragen `source` plus Timestamp und werden deterministisch aus redigierter Nexus-Activity und Gateway-History gebaut. Rohes Gateway-Feed bleibt unpersistiert, persistierte Activity wird vor Save/Publish redigiert. Review-Fix: `agentIds` werden vor Redaction aus dem Original bestimmt, bereits maskierte Bearer/API-Key-Zeilen bleiben erhalten statt komplett gekuerzt zu werden. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 128 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.
- 2026-07-09: P4 vervollstaendigt und reviewed: Config-Saves und Approval-Aktionen sind owner-only; Config-Saves validieren vor Replace, behalten `.bak`, geben strukturierte `validation`, `backup` und `reloadCheck`-Daten zurueck und auditieren ohne Inhalt/Secrets. Pending approvals sind im owner-only Task-Board-Flow sichtbar; Approve/Reject schreibt `task_approval_audit`. Reload-Check ist bewusst `not_supported`, weil Workspace-MD-Hot-Reload nicht verfuegbar ist. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 132 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.
- 2026-07-09: P5 Konsolidierung abgeschlossen: README und Phasenlog dokumentieren MCP/Bridge/Dashboard-Grenzen, P3/P4-Retention-/Reload-/Validation-Limits und owner-only Schreibpfade. Doku-Suche bestaetigt: aktive Agenten-Doku empfiehlt MCP/Bridge statt Dashboard-Curl; verbleibende `/api/dashboard`-Treffer sind UI-Code, Controller-Routen oder historische Changelog-/Phasenbelege. Verifikation: `dotnet test backend-tests/Nexus.Api.Tests.csproj` im .NET-SDK-Container gruen, 132 Tests; `COREPACK_HOME=/tmp/corepack pnpm typecheck` gruen.