Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbda764190 | |||
| 361a64f886 | |||
| a104acf160 | |||
| aaec3eb4ed | |||
| 436ddfee0f | |||
| 38954feb8f | |||
| f30cce4fb3 | |||
| 7216bfdeff | |||
| 16385d10cb | |||
| 250e730f33 | |||
| c9e22195ad | |||
| 8d8f8cc8a8 | |||
| 873c5d586c | |||
| 95495a8332 | |||
| 68b428e411 | |||
| 1214cf9a4d | |||
| 195c497c88 | |||
| a2272c5df6 |
@@ -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
@@ -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)
|
||||
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/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
|
||||
'
|
||||
|
||||
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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "========================================"
|
||||
@@ -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
|
||||
|
||||
@@ -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 "└─────────────────────────────────────────────────────────────┘"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
# Environment
|
||||
.env
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
# IDE
|
||||
|
||||
+6
-5
@@ -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 |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() => [];
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() => [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
*.log
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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>();
|
||||
|
||||
@@ -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)$");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapMcp();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
bool startsInProgress = false,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.pnpm-store/
|
||||
.pnpm-home/
|
||||
.corepack-home/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
*.log
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
# ==============================================================================
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user