Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4633e570e8 | |||
| f4bee442db | |||
| 7f1d5b706d | |||
| c3e0e6913b | |||
| b82d88563a | |||
| 7de12c6541 | |||
| b093b0c4b5 | |||
| 2ee1fe973f | |||
| 50d95fa7a9 | |||
| aef76d5f45 | |||
| f564ecfbc7 | |||
| 86ceb2bcce | |||
| 706ff82ccd | |||
| dbda764190 | |||
| 361a64f886 | |||
| a104acf160 | |||
| aaec3eb4ed | |||
| 436ddfee0f | |||
| 38954feb8f | |||
| f30cce4fb3 | |||
| 7216bfdeff | |||
| 16385d10cb | |||
| 250e730f33 | |||
| c9e22195ad | |||
| 8d8f8cc8a8 | |||
| 873c5d586c | |||
| 95495a8332 | |||
| 68b428e411 | |||
| 1214cf9a4d | |||
| 195c497c88 | |||
| a2272c5df6 | |||
| 5df5194651 | |||
| 39aeab62d6 | |||
| 8b1400da17 | |||
| dd17cefa3b | |||
| 2aa41e6366 | |||
| 696e9daf4d | |||
| 701e15ee9b | |||
| fe97d1aaf0 | |||
| df94ed3cd4 | |||
| de1fc198cb | |||
| f33e8c8b58 | |||
| dd38570c7b | |||
| 5dc00c1142 | |||
| cf10ca3ed8 | |||
| 1f8b45a327 | |||
| b1dcf51218 | |||
| 0fdc40a95c | |||
| 071cbe8ce5 | |||
| c750a5abcd | |||
| dd81070afd | |||
| be5c3884a0 | |||
| 7a2d5ded20 | |||
| 784cb4eeee | |||
| 6b2ab04f8d | |||
| ca4bad2ba7 | |||
| ac131f7f53 | |||
| b89289989a | |||
| f95463ef50 | |||
| 2d218853a5 | |||
| adae7ba26d | |||
| 3dd745586b | |||
| f0023ac033 | |||
| 73c5eb69d7 | |||
| 06eac66baa | |||
| b95bec7915 | |||
| 071be50977 | |||
| baf4008d97 | |||
| 83e072bc27 | |||
| a516353ae8 | |||
| 1df663f57c | |||
| e4091eee80 | |||
| dcc8450c62 | |||
| 12998170e3 | |||
| 691152f889 | |||
| 74ef58d274 | |||
| 5e7d074593 | |||
| c496608c86 | |||
| c040696d91 | |||
| 7ba0bd26fa | |||
| 4b1d140b53 | |||
| e0c88238da | |||
| b0e65e3980 | |||
| 648a5d2151 | |||
| 1a024eef96 | |||
| 6280e87078 | |||
| 64459ccdb3 | |||
| 38dc2efc6c | |||
| 390bffa208 | |||
| e034883abd | |||
| 6d4e8e7927 | |||
| 0f8939306d | |||
| 58675f0c69 | |||
| 88cafc7b8e | |||
| 485357c6dc | |||
| 36b32f0e88 | |||
| 8a556c25a0 |
@@ -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=
|
|
||||||
+4
-6
@@ -15,13 +15,11 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
|
|||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
|
|
||||||
# ── Owner Account ───────────────────────────────────────
|
# ── Bootstrap Owner (first seed only) ───────────────────
|
||||||
OWNER_EMAIL=***
|
BOOTSTRAP_OWNER_EMAIL=***
|
||||||
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
|
|
||||||
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
|
|
||||||
|
|
||||||
# ── OpenClaw Integration ────────────────────────────────
|
# ── OpenClaw Integration ────────────────────────────────
|
||||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
# Internal Docker-DNS URL of the OpenClaw gateway
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=***
|
OPENCLAW_GATEWAY_TOKEN=***
|
||||||
OPENCLAW_GATEWAY_PASSWORD=***
|
OPENCLAW_GATEWAY_PASSWORD=***
|
||||||
|
|||||||
Executable
+264
@@ -0,0 +1,264 @@
|
|||||||
|
#!/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}"
|
||||||
|
BOOTSTRAP_OWNER_EMAIL="${BOOTSTRAP_OWNER_EMAIL_DEPLOY:-vmbao62@hotmail.de}"
|
||||||
|
|
||||||
|
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=${BOOTSTRAP_OWNER_EMAIL}
|
||||||
|
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
NEXUS_VERSION=${VERSION}
|
||||||
|
NEXUS_GIT_SHA=${GIT_SHA}
|
||||||
|
EOF_ENV
|
||||||
|
|
||||||
|
echo "Syncing source to deploy path: $DEPLOY_PATH"
|
||||||
|
git archive --format=tar HEAD | docker run --rm -i \
|
||||||
|
-v "$DEPLOY_PATH:/dest" \
|
||||||
|
alpine:3.20 \
|
||||||
|
sh -c '
|
||||||
|
set -eu
|
||||||
|
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||||
|
mkdir -p /src-snapshot
|
||||||
|
tar -xf - -C /src-snapshot
|
||||||
|
|
||||||
|
is_protected_path() {
|
||||||
|
case "$1" in
|
||||||
|
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
cd /dest
|
||||||
|
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||||
|
if ! is_protected_path "$path"; then
|
||||||
|
rm -rf "$path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd /src-snapshot
|
||||||
|
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||||
|
if ! is_protected_path "$path"; then
|
||||||
|
cp -a "$path" /dest/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
chown -R "$dest_owner" /dest
|
||||||
|
'
|
||||||
|
|
||||||
|
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||||
|
echo "Generating sanitized agents config for Nexus"
|
||||||
|
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
||||||
|
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
||||||
|
if [ -f "$OPENCLAW_CONFIG" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
with open('$OPENCLAW_CONFIG') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
agents = data.get('agents')
|
||||||
|
if agents is None:
|
||||||
|
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open('$AGENTS_SANITIZED_PATH', 'w') as f:
|
||||||
|
json.dump({'agents': agents}, f, indent=2)
|
||||||
|
"
|
||||||
|
chmod 644 "$AGENTS_SANITIZED_PATH" 2>/dev/null || true
|
||||||
|
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||||
|
else
|
||||||
|
echo "WARNING: openclaw.json not found at $OPENCLAW_CONFIG — agents-sanitized.json NOT generated" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building and starting Docker compose stack"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$DEPLOY_PATH:/workspace/nexus" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
|
docker:cli \
|
||||||
|
sh -c 'set -eu
|
||||||
|
umask 077
|
||||||
|
cat > /tmp/nexus-deploy-env
|
||||||
|
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build
|
||||||
|
|
||||||
|
# ── Postgres: only recreate if image or config changed ──
|
||||||
|
# docker compose up -d (without --force-recreate) is smart enough
|
||||||
|
# to only recreate containers whose config or image has changed.
|
||||||
|
# We DROP --force-recreate so postgres persists across deploys
|
||||||
|
# unless its image tag or compose config actually changed.
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --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))
|
||||||
|
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
|
||||||
|
case "$health_body" in
|
||||||
|
'{"status":"Healthy"'*)
|
||||||
|
echo "Health check passed"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [ -n "$health_body" ]; then
|
||||||
|
echo "Health endpoint is reachable but not healthy: $health_body" >&2
|
||||||
|
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"
|
||||||
|
|
||||||
|
# ── Auth Smoke: SeedAudit owner_created exists in DB ──
|
||||||
|
echo ""
|
||||||
|
echo "Auth Smoke: SeedAudit owner_created"
|
||||||
|
seed_key="$(docker exec nexus-postgres-1 psql -U nexus -d nexus -t -A -c "SELECT key FROM \"SeedAudit\" WHERE key = 'owner_created'" 2>/dev/null || echo "")"
|
||||||
|
seed_key="$(echo "$seed_key" | tr -d '[:space:]')"
|
||||||
|
if [ "$seed_key" = "owner_created" ]; then
|
||||||
|
echo " SeedAudit owner_created: ✅ exists"
|
||||||
|
pass=$((pass + 1))
|
||||||
|
else
|
||||||
|
echo " SeedAudit owner_created: ❌ NOT FOUND (DB may not be seeded)" >&2
|
||||||
|
echo " Raw output: '$seed_key'" >&2
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Auth Smoke: Owner login flow returns 401 for unknown password ──
|
||||||
|
# This proves the user exists, auth pipeline is functional, and the DB is reachable.
|
||||||
|
# We POST with a WRONG password intentionally — a 401 means "user found, password wrong",
|
||||||
|
# which is the correct auth flow behavior. A 5xx or connection error means the stack is broken.
|
||||||
|
echo "Auth Smoke: Owner login flow"
|
||||||
|
login_body="$(curl -sS --max-time 10 \
|
||||||
|
-X POST \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"${BOOTSTRAP_OWNER_EMAIL}\",\"password\":\"smoke-test-wrong-password-$(date +%s)\"}" \
|
||||||
|
"$BASE_URL/api/v1/auth/login" 2>/dev/null || echo "CONNECTION_ERROR")"
|
||||||
|
|
||||||
|
if echo "$login_body" | grep -q '"error":"invalid_credentials"'; then
|
||||||
|
echo " Owner login flow: ✅ HTTP 401 with valid JSON (auth pipeline working)"
|
||||||
|
pass=$((pass + 1))
|
||||||
|
else
|
||||||
|
echo " Owner login flow: ❌ unexpected response" >&2
|
||||||
|
echo " Response: $(echo "$login_body" | head -c 200)" >&2
|
||||||
|
fail=$((fail + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "Smoke test failed: $fail failed, $pass passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Nexus v$VERSION deployed and verified"
|
||||||
@@ -33,7 +33,7 @@ on:
|
|||||||
host_backup_path:
|
host_backup_path:
|
||||||
description: 'Host path for backup (only if keep_on_host is true)'
|
description: 'Host path for backup (only if keep_on_host is true)'
|
||||||
required: false
|
required: false
|
||||||
default: '/opt/openclaw/backups'
|
default: '/home/projekte_bao/backups/nexus'
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
# Optional: uncomment to enable nightly automatic backups
|
# Optional: uncomment to enable nightly automatic backups
|
||||||
@@ -43,11 +43,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
backup:
|
backup:
|
||||||
name: Backup PostgreSQL
|
name: Backup PostgreSQL
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux
|
||||||
env:
|
env:
|
||||||
ENV_TMPFILE: /tmp/nexus-backup-env
|
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||||
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
echo "🗄️ Dumping PostgreSQL cluster..."
|
echo "🗄️ Dumping PostgreSQL cluster..."
|
||||||
|
|
||||||
docker exec "${BACKUP_CONTAINER_NAME}" \
|
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 }}"
|
| gzip > "${{ steps.meta.outputs.filename }}"
|
||||||
|
|
||||||
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ concurrency:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- main
|
||||||
|
- 'codex/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ─── Backend ───────────────────────────────────
|
# ─── Backend ───────────────────────────────────
|
||||||
@@ -51,7 +54,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@10.12.1 --activate
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -73,7 +76,7 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
name: Security Check
|
name: Security Check
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
if: github.ref == 'refs/heads/main'
|
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -97,3 +100,25 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "✅ No obvious secrets found"
|
echo "✅ No obvious secrets found"
|
||||||
fi
|
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,377 +1,28 @@
|
|||||||
name: Deploy to Production
|
name: Deploy Nexus Manual
|
||||||
run-name: 🚀 Deploy by @${{ gitea.actor }}
|
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-Bump / CI Loop Prevention:
|
|
||||||
# The version-bump commit includes "[skip ci]" in its message,
|
|
||||||
# which Gitea Actions respects. The auto-trigger additionally
|
|
||||||
# checks for "[skip ci]" as a second safety layer. Together
|
|
||||||
# they guarantee that a version-bump commit does NOT trigger
|
|
||||||
# another CI → Deploy → Bump → CI cycle.
|
|
||||||
# ───────────────────────────────────────────────────────
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# ── Auto-Trigger: after successful CI on main ──
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["CI - Build & Test"]
|
|
||||||
types: [completed]
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# ── Manual Trigger (full control) ──
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
version_bump:
|
|
||||||
description: 'Version bump type'
|
|
||||||
required: true
|
|
||||||
default: 'patch'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- patch
|
|
||||||
- minor
|
|
||||||
- major
|
|
||||||
service:
|
|
||||||
description: 'Service to deploy (empty = all)'
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
type: string
|
|
||||||
no_cache:
|
|
||||||
description: 'Disable Docker build cache'
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
git_ref:
|
|
||||||
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
|
|
||||||
required: false
|
|
||||||
default: 'main'
|
|
||||||
type: string
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Nexus
|
name: Deploy Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux
|
||||||
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 ──
|
|
||||||
env:
|
env:
|
||||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||||
ENV_TMPFILE: /tmp/nexus-deploy-env
|
|
||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
|
||||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════
|
- name: Checkout main
|
||||||
# Step 1: Checkout
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
- name: Deploy main
|
||||||
# Step 2: Set up Git identity
|
run: sh .gitea/scripts/deploy-nexus.sh
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
- name: Configure Git
|
|
||||||
run: |
|
|
||||||
git config user.email "devops@noveria.net"
|
|
||||||
git config user.name "DevOps"
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# Step 3: Resolve deploy version
|
|
||||||
#
|
|
||||||
# Deploying main: DevOps may bump VERSION and create a tag.
|
|
||||||
# Deploying any other ref: deploy exactly that ref, but DO NOT
|
|
||||||
# mutate main or create a version-bump commit on another branch.
|
|
||||||
#
|
|
||||||
# For auto-deploys (workflow_run): always "patch" bump on main.
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
- name: Resolve Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Determine bump type (auto-deploy → patch; manual → user choice)
|
|
||||||
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
|
|
||||||
|
|
||||||
# Read current version
|
|
||||||
if [ ! -f VERSION ]; then
|
|
||||||
echo "❌ VERSION file not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
CURRENT=$(cat VERSION | tr -d '[:space:]')
|
|
||||||
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
||||||
echo "❌ Invalid semver in VERSION: '$CURRENT'"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
|
||||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
|
||||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
|
||||||
|
|
||||||
case "$BUMP_TYPE" in
|
|
||||||
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
|
|
||||||
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
|
|
||||||
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
|
|
||||||
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Determine git ref — auto-deploy always uses main
|
|
||||||
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
|
||||||
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
|
|
||||||
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
|
||||||
echo "$NEW_VERSION" > VERSION
|
|
||||||
git add VERSION
|
|
||||||
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
|
|
||||||
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
|
||||||
git push origin HEAD:main --tags
|
|
||||||
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
|
|
||||||
echo "mutated_main=true" >> "$GITEA_OUTPUT"
|
|
||||||
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
|
|
||||||
else
|
|
||||||
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
|
|
||||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
|
||||||
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
# Step 4: Build .env from secrets (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.
|
|
||||||
# ═══════════════════════════════════════════════════
|
|
||||||
- name: Prepare .env (secrets → temp file)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cat > "${ENV_TMPFILE}" <<EOF
|
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
|
||||||
# Managed via Gitea Secrets → 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=${ENV_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
|
|
||||||
|
|
||||||
# Auto-deploy: always use cache. Manual: respect no_cache input.
|
|
||||||
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
|
||||||
BUILD_ARGS=""
|
|
||||||
if [ "$NO_CACHE" = "true" ]; then
|
|
||||||
BUILD_ARGS="--no-cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
|
|
||||||
|
|
||||||
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 -e
|
|
||||||
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
|
||||||
cat > /tmp/nexus-deploy-env
|
|
||||||
if [ -n '${SERVICE_ARG}' ]; then
|
|
||||||
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
|
||||||
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
|
|
||||||
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 --wait --force-recreate
|
|
||||||
fi
|
|
||||||
" < "${ENV_TMPFILE}"
|
|
||||||
|
|
||||||
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)' }}"
|
|
||||||
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════"
|
|
||||||
echo " 📦 Deploy Summary"
|
|
||||||
echo "═══════════════════════════════════════"
|
|
||||||
echo " Version: v${{ steps.version.outputs.version }}"
|
|
||||||
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
|
||||||
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
|
|
||||||
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
|
||||||
echo " Trigger: ${TRIGGER}"
|
|
||||||
echo " Bump type: ${VERSION_BUMP}"
|
|
||||||
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 "└─────────────────────────────────────────────────────────────┘"
|
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ run-name: 🔙 Rollback by @${{ gitea.actor }}
|
|||||||
# migrations). If the tag predates a destructive migration, manual
|
# migrations). If the tag predates a destructive migration, manual
|
||||||
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
# 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:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -37,13 +40,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
rollback:
|
rollback:
|
||||||
name: Rollback Nexus
|
name: Rollback Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux
|
||||||
env:
|
env:
|
||||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||||
ENV_TMPFILE: /tmp/nexus-rollback-env
|
ENV_TMPFILE: /tmp/nexus-rollback-env
|
||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
|
||||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -109,12 +111,12 @@ jobs:
|
|||||||
JWT_KEY=${ENV_JWT_KEY}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||||
OWNER_DISPLAY_NAME=
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||||
|
NEXUS_GIT_SHA=$(git rev-parse HEAD)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 600 "${ENV_TMPFILE}"
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
@@ -127,18 +129,38 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
docker run --rm \
|
git archive --format=tar HEAD | docker run --rm -i \
|
||||||
-v "${{ gitea.workspace }}:/src:ro" \
|
|
||||||
-v "${DEPLOY_PATH}:/dest" \
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -c "
|
sh -c '
|
||||||
cd /src && \
|
set -eu
|
||||||
find . -mindepth 1 -maxdepth 1 \
|
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||||
! -name .git \
|
mkdir -p /src-snapshot
|
||||||
-exec cp -r {} /dest/ \; && \
|
tar -xf - -C /src-snapshot
|
||||||
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
|
||||||
chown -R \"\$DEST_OWNER\" /dest
|
is_protected_path() {
|
||||||
"
|
case "$1" in
|
||||||
|
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./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 "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||||
|
|
||||||
@@ -151,16 +173,18 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v "/tmp:/tmp-host:ro" \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c '
|
||||||
set -e
|
set -eu
|
||||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
umask 077
|
||||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
cat > /tmp/nexus-rollback-env
|
||||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
|
||||||
"
|
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
|
||||||
|
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
|
||||||
|
' < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
echo "✅ Rollback redeploy completed"
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|
||||||
@@ -186,11 +210,14 @@ jobs:
|
|||||||
WAIT=1
|
WAIT=1
|
||||||
while [ $RETRY -lt $MAX ]; do
|
while [ $RETRY -lt $MAX ]; do
|
||||||
RETRY=$((RETRY + 1))
|
RETRY=$((RETRY + 1))
|
||||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
|
||||||
echo ""
|
case "$HEALTH_BODY" in
|
||||||
|
'{"status":"Healthy"'*)
|
||||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
|
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
|
||||||
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
sleep $WAIT
|
sleep $WAIT
|
||||||
NEXT=$((WAIT + RETRY))
|
NEXT=$((WAIT + RETRY))
|
||||||
@@ -271,7 +298,7 @@ jobs:
|
|||||||
echo "│ Letzter bekannter funktionierender Stand: │"
|
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||||
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||||
echo "│ → Manuellen Rollback erwägen: │"
|
echo "│ → Manuellen Rollback erwägen: │"
|
||||||
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
|
echo "│ cd /home/projekte_bao/nexus │"
|
||||||
echo "│ docker compose up -d (vorheriger Stand) │"
|
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||||
echo "│ │"
|
echo "│ │"
|
||||||
echo "└─────────────────────────────────────────────────────────────┘"
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
|
|||||||
+10
-2
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
!.env.example
|
|
||||||
!.env.template
|
!.env.template
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
@@ -30,7 +29,16 @@ docker-compose.override.yml
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
# Crash artefacts / Core dumps
|
||||||
|
**/core
|
||||||
|
**/core.*
|
||||||
|
|
||||||
|
# pnpm / corepack local caches (lockfile IS committed for reproducible CI builds)
|
||||||
|
frontend/.pnpm-home/
|
||||||
|
frontend/.corepack-home/
|
||||||
|
|
||||||
# Claude local config (per-developer, not repo-shared)
|
# Claude local config (per-developer, not repo-shared)
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Sanitized agent config (generated on host, not committed)
|
||||||
|
backend/agents-sanitized.json
|
||||||
|
|||||||
+6
-5
@@ -31,7 +31,7 @@
|
|||||||
│ 127.0.0.1:18880 │
|
│ 127.0.0.1:18880 │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌───────────────────────────┼───────────────────┐ │
|
│ ┌───────────────────────────┼───────────────────┐ │
|
||||||
│ │ Host nginx reverse proxy │ │ │
|
│ │ Traefik v3 reverse proxy │ │ │
|
||||||
│ │ nexus.noveria.net :443 ───┘ │ │
|
│ │ nexus.noveria.net :443 ───┘ │ │
|
||||||
│ └───────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
|
|||||||
ss -tlnp | grep 18880
|
ss -tlnp | grep 18880
|
||||||
```
|
```
|
||||||
|
|
||||||
### Host nginx Reverse Proxy
|
### Traefik Reverse Proxy
|
||||||
Falls `nexus.noveria.net` nicht erreichbar:
|
Falls `nexus.noveria.net` nicht erreichbar:
|
||||||
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880`
|
- Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
|
||||||
- TLS-Zertifikat gültig?
|
- `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 |
|
| backend/Dockerfile | ✅ Multi-Stage .NET 10 |
|
||||||
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
|
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
|
||||||
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
|
| 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 |
|
| Docker installiert auf VPS | ⚠️ Vorausgesetzt |
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
||||||
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||||
|
|
||||||
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
|
||||||
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
||||||
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||||
> 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.
|
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||||
|
|
||||||
## Current foundation
|
## Current foundation
|
||||||
@@ -15,28 +19,26 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
- ASP.NET Core 10 REST API (Minimal API pattern)
|
- ASP.NET Core 10 REST API (Minimal API pattern)
|
||||||
- Entity Framework Core and PostgreSQL
|
- Entity Framework Core and PostgreSQL
|
||||||
- JWT owner authentication with rotating refresh sessions
|
- JWT owner authentication with rotating refresh sessions
|
||||||
- `IAgentRuntime` abstraction with an OpenClaw adapter
|
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
|
||||||
- `IModelProvider` abstractions for Ollama and NVIDIA
|
|
||||||
- Responsive dark-mode operations dashboard
|
- Responsive dark-mode operations dashboard
|
||||||
- Container-only entry point on `127.0.0.1:18880`
|
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
|
||||||
|
|
||||||
## Local/container start
|
## Local/container start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.template .env
|
||||||
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
|
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
|
||||||
# OWNER_EMAIL and OWNER_PASSWORD.
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
curl http://127.0.0.1:18880/health
|
curl http://127.0.0.1:18880/health
|
||||||
```
|
```
|
||||||
|
|
||||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
|
||||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14
|
derives the initial display name from that email, and logs a generated temporary password once.
|
||||||
characters. Existing databases are never overwritten by the bootstrap process.
|
After first seed the password lives only in PostgreSQL. Existing databases are
|
||||||
|
never overwritten by the bootstrap process.
|
||||||
|
|
||||||
The web service is loopback-only. Public reverse-proxy activation for
|
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||||
`nexus.noveria.net` remains a separate infrastructure change and must terminate
|
Health checks, rate limiting, and security headers are active.
|
||||||
TLS before forwarding to port `18880`.
|
|
||||||
|
|
||||||
## Workspace mounts
|
## Workspace mounts
|
||||||
|
|
||||||
@@ -45,12 +47,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
|
|||||||
|
|
||||||
| Host path | Container mount |
|
| Host path | Container mount |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -165,13 +167,102 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
|||||||
| Route | Name | Description |
|
| Route | Name | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/projects` | Projects | Project portfolio |
|
| `/projects` | Projects | Project portfolio |
|
||||||
| `/tasks` | Task Board | Task board |
|
| `/tasks` | Task Board | Task board with visible parent/child agent flow |
|
||||||
| `/models` | Models | Provider routing status |
|
| `/models` | Models | Provider routing status |
|
||||||
| `/activity` | Activity | Audit timeline |
|
| `/activity` | Activity | Audit timeline |
|
||||||
| `/chat` | Mobile Chat | Owner-chat preview |
|
| `/chat` | Mobile Chat | Owner-chat preview |
|
||||||
|
|
||||||
## API endpoints
|
## 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
|
||||||
|
Agent-zu-Backend-Kommunikation. Kein Frontend-Code ruft diese Endpunkte auf.
|
||||||
|
|
||||||
|
Auth: `X-Agent-Id` Header, `X-Nexus-Api-Key`, oder JWT. Rate-Limited (30/min).
|
||||||
|
|
||||||
|
| Methode | Pfad | Kommando | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/bridge/health` | — | Bridge-Health-Check |
|
||||||
|
| `POST` | `/api/bridge/tasks` | `create_task` | Neue Top-Level-Task erstellen |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/children` | `create_child_task` | Child-Task unter Parent erstellen |
|
||||||
|
| `PATCH` | `/api/bridge/tasks/{id}/status` | `update_status` | Task-Status ändern |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/activity` | `append_activity` | Aktivitätseintrag anhängen |
|
||||||
|
| `POST` | `/api/bridge/tasks/{id}/handoff` | `handoff` | Task an anderen Agent übergeben |
|
||||||
|
| `GET` | `/api/bridge/board` | `get_board` | Vollständiges Task-Board |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}` | `get_task` | Einzelne Task abrufen |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}/children` | `get_children` | Child-Tasks abrufen |
|
||||||
|
| `GET` | `/api/bridge/tasks/{id}/activity` | `get_activity` | Task-Aktivität abrufen |
|
||||||
|
| `GET` | `/api/bridge/agent-overview` | `get_agent_overview` | Agent-Workflow-Übersicht |
|
||||||
|
|
||||||
|
Response-Format (TaskBridgeCommandResponse<T>):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"command": "create_task",
|
||||||
|
"data": { ... },
|
||||||
|
"error": null,
|
||||||
|
"timestamp": "2026-06-22T15:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Health & Auth (public or rate-limited)
|
### Health & Auth (public or rate-limited)
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
@@ -191,6 +282,15 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) |
|
| `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) |
|
||||||
|
|
||||||
|
### Parent/Child task flow
|
||||||
|
|
||||||
|
The Task Board now models OpenClaw delegation as a visible parent/child flow:
|
||||||
|
- Iris keeps the parent task `In progress` while delegated work is running.
|
||||||
|
- Delegated agent work is represented as visible child tasks linked via `parentTaskId`.
|
||||||
|
- Child tasks use the normal visible states (`Backlog`, `In progress`, `Review`, `Blocked`, `Done`) instead of a separate hidden delegation lane.
|
||||||
|
- Agent progress hints on parent tasks derive from recent activity and child-task status summaries.
|
||||||
|
- Full workflow documentation: [`docs/openclaw-task-board-flow.md`](docs/openclaw-task-board-flow.md)
|
||||||
|
|
||||||
### Projects
|
### Projects
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -207,11 +307,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `GET` | `/api/v1/tasks` | List all tasks |
|
| `GET` | `/api/v1/tasks` | List all tasks |
|
||||||
| `POST` | `/api/v1/tasks` | Create task |
|
| `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}` | Update task (title, priority, projectId) |
|
||||||
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
|
| `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}/approve` | Owner-only approve task (in-progress -> done) |
|
||||||
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress → backlog) |
|
| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
|
||||||
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
|
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
|
||||||
|
|
||||||
### Agents
|
### Agents
|
||||||
@@ -221,10 +321,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
|||||||
| `GET` | `/api/v1/agents` | List all agents |
|
| `GET` | `/api/v1/agents` | List all agents |
|
||||||
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
|
| `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}/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 |
|
| `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` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
|
||||||
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
|
| `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
|
### Memory & Docs
|
||||||
|
|
||||||
@@ -283,11 +384,16 @@ Backlog → Blocked → In progress / Done
|
|||||||
provider key. Conversation IDs are stable per browser and Iris is the default
|
provider key. Conversation IDs are stable per browser and Iris is the default
|
||||||
agent target.
|
agent target.
|
||||||
|
|
||||||
The configured model-routing policy is:
|
The configured model-routing policy routes through the OpenClaw Gateway only.
|
||||||
|
Ollama and NVIDIA providers have been removed. Currently active models:
|
||||||
|
|
||||||
1. `qwen3:4b` through Ollama for routine and monitoring work
|
| Agent | Model |
|
||||||
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
|
|-------|-------|
|
||||||
3. `gpt-5.5` through OpenClaw for strategic and critical review
|
| Iris | `openai/gpt-5.4` |
|
||||||
|
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
|
||||||
|
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
|
||||||
|
|
||||||
|
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
|
||||||
|
|
||||||
The Settings module reports runtime and provider state without exposing
|
The Settings module reports runtime and provider state without exposing
|
||||||
credentials.
|
credentials.
|
||||||
@@ -303,24 +409,23 @@ Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
|||||||
|
|
||||||
CI must never break. If it does, Reviewer fixes.
|
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:
|
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`
|
- Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
|
||||||
- Uses safe defaults: `patch` bump, all services, main ref
|
- Starts only after backend, frontend, and security jobs succeed on `main`
|
||||||
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
|
- Deploys the current `main` version after CI succeeds.
|
||||||
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
|
- 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`)
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
1. DevOps triggers `Deploy to Production` in Gitea Actions
|
1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
|
||||||
2. Chooses version bump type: patch (default) / minor / major
|
2. Workflow validates `VERSION`, builds and deploys `main`
|
||||||
3. Optionally scopes to a single service or specific git ref
|
3. Health check + smoke test verify the deployment
|
||||||
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
|
||||||
5. Health check + smoke test verify the deployment
|
|
||||||
|
|
||||||
#### Rollback (`workflow_dispatch`)
|
#### Rollback (`workflow_dispatch`)
|
||||||
|
|
||||||
@@ -332,7 +437,7 @@ Deployment can happen automatically or manually:
|
|||||||
#### Database Backup (`workflow_dispatch`)
|
#### Database Backup (`workflow_dispatch`)
|
||||||
|
|
||||||
1. DevOps triggers `Database Backup` in Gitea Actions
|
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||||
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
|
2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
|
||||||
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
|
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
|
||||||
4. Artifacts are retained for 90 days (configurable)
|
4. Artifacts are retained for 90 days (configurable)
|
||||||
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
||||||
|
|||||||
@@ -45,6 +45,94 @@ public class AgentServiceTests
|
|||||||
Assert.Null(agent);
|
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)
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
=> new ConfigurationBuilder()
|
=> new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
@@ -53,10 +141,10 @@ public class AgentServiceTests
|
|||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
private static string CreateAgentConfigFile()
|
private static string CreateAgentConfigFile(string? json = null)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
File.WriteAllText(path,
|
File.WriteAllText(path, json ??
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -69,19 +157,33 @@ public class AgentServiceTests
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"id": "iris",
|
"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",
|
"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",
|
"id": "reviewer",
|
||||||
"name": "reviewer"
|
"name": "reviewer",
|
||||||
|
"model": { "primary": "openai/gpt-5.5" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "architekt",
|
"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,232 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression tests for the SeedAudit-based owner-seed guard in EnsureDatabaseAsync.
|
||||||
|
/// Verifies that once SeedAudit contains "owner_created", subsequent calls to
|
||||||
|
/// EnsureDatabaseAsync (simulating pod restarts) do NOT reset the owner's password hash.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EnsureDatabaseSeedAuditTests
|
||||||
|
{
|
||||||
|
private const string SeedKey = "owner_created";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
var db = new NexusDbContext(options);
|
||||||
|
|
||||||
|
const string originalPassword = "InitialOwnerPassword123!";
|
||||||
|
var originalHash = PasswordSecurity.Hash(originalPassword);
|
||||||
|
|
||||||
|
var owner = new NexusUser
|
||||||
|
{
|
||||||
|
Email = "owner@nexus.internal",
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||||
|
DisplayName = "Nexus Owner",
|
||||||
|
PasswordHash = originalHash,
|
||||||
|
Role = "owner"
|
||||||
|
};
|
||||||
|
db.Users.Add(owner);
|
||||||
|
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (db, owner, originalHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulates the restart guard: if SeedAudit contains owner_created,
|
||||||
|
/// the owner password hash must not be changed to a newly generated hash.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash()
|
||||||
|
{
|
||||||
|
// Arrange — seed the DB with an owner and a SeedAudit row
|
||||||
|
var (db, owner, originalHash) = await CreateSeededFixtureAsync();
|
||||||
|
|
||||||
|
// Sanity check: password hash starts as expected
|
||||||
|
Assert.Equal(originalHash, owner.PasswordHash);
|
||||||
|
Assert.True(PasswordSecurity.Verify("InitialOwnerPassword123!", owner.PasswordHash, out _));
|
||||||
|
|
||||||
|
// Act — simulate a password change by the user
|
||||||
|
const string newPassword = "ChangedOwnerPassword456!";
|
||||||
|
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Detach and re-read to confirm the change persisted
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var afterChange = await db.Users.FirstAsync(u => u.Id == owner.Id);
|
||||||
|
Assert.NotEqual(originalHash, afterChange.PasswordHash);
|
||||||
|
Assert.True(PasswordSecurity.Verify(newPassword, afterChange.PasswordHash, out _));
|
||||||
|
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterChange.PasswordHash, out _));
|
||||||
|
|
||||||
|
// Act — simulate EnsureDatabaseAsync on restart:
|
||||||
|
// It checks SeedAudit first; if owner_created exists, it returns immediately.
|
||||||
|
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||||
|
Assert.True(alreadySeeded, "SeedAudit should contain owner_created after initial seed");
|
||||||
|
|
||||||
|
if (alreadySeeded)
|
||||||
|
{
|
||||||
|
// EnsureDatabaseAsync returns here — owner is NOT touched
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — after the "restart", the password hash must still be the changed one
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var afterRestart = await db.Users.FirstAsync(u => u.Id == owner.Id);
|
||||||
|
Assert.Equal(afterChange.PasswordHash, afterRestart.PasswordHash);
|
||||||
|
Assert.NotEqual(originalHash, afterRestart.PasswordHash);
|
||||||
|
Assert.True(PasswordSecurity.Verify(newPassword, afterRestart.PasswordHash, out _),
|
||||||
|
"Changed password must still work after simulated restart");
|
||||||
|
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterRestart.PasswordHash, out _),
|
||||||
|
"Original seed password must NOT work after simulated restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simulates a full restart: creates a completely new DbContext (simulating a new pod),
|
||||||
|
/// and verifies the SeedAudit guard prevents owner re-seeding.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword()
|
||||||
|
{
|
||||||
|
// Arrange — create and seed the first "instance"
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
Guid ownerId;
|
||||||
|
string changedHash;
|
||||||
|
|
||||||
|
// First "pod run": seed owner + SeedAudit, then change password
|
||||||
|
await using (var db1 = new NexusDbContext(options))
|
||||||
|
{
|
||||||
|
const string initialPassword = "SeedPassword123!";
|
||||||
|
var owner = new NexusUser
|
||||||
|
{
|
||||||
|
Email = "owner@nexus.internal",
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||||
|
DisplayName = "Nexus Owner",
|
||||||
|
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||||
|
Role = "owner"
|
||||||
|
};
|
||||||
|
db1.Users.Add(owner);
|
||||||
|
db1.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||||
|
await db1.SaveChangesAsync();
|
||||||
|
ownerId = owner.Id;
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
const string newPassword = "NewSecurePassword789!";
|
||||||
|
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||||
|
await db1.SaveChangesAsync();
|
||||||
|
changedHash = owner.PasswordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act — second "pod run": new DbContext, simulate EnsureDatabaseAsync
|
||||||
|
await using (var db2 = new NexusDbContext(options))
|
||||||
|
{
|
||||||
|
var alreadySeeded = await db2.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||||
|
Assert.True(alreadySeeded, "SeedAudit must persist across DbContext instances");
|
||||||
|
|
||||||
|
// EnsureDatabaseAsync would return here because alreadySeeded is true
|
||||||
|
// No user creation or password reset happens
|
||||||
|
|
||||||
|
var owner = await db2.Users.FirstAsync(u => u.Id == ownerId);
|
||||||
|
Assert.Equal(changedHash, owner.PasswordHash);
|
||||||
|
Assert.True(PasswordSecurity.Verify("NewSecurePassword789!", owner.PasswordHash, out _),
|
||||||
|
"Changed password must survive a full simulated restart (new DbContext)");
|
||||||
|
Assert.False(PasswordSecurity.Verify("SeedPassword123!", owner.PasswordHash, out _),
|
||||||
|
"Seed password must NOT work after restart");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that if all users are deleted but SeedAudit still has owner_created,
|
||||||
|
/// a restart will NOT re-create the owner (the SeedAudit guard is the single
|
||||||
|
/// source of truth — preventing password drift even if the user table is wiped).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var db = new NexusDbContext(options);
|
||||||
|
|
||||||
|
// SeedAudit exists from a prior run
|
||||||
|
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Users table is empty (simulating a wiped DB or fresh volume with existing SeedAudit)
|
||||||
|
var hasUsers = await db.Users.AnyAsync();
|
||||||
|
Assert.False(hasUsers);
|
||||||
|
|
||||||
|
// Act — simulate restart: SeedAudit check
|
||||||
|
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||||
|
Assert.True(alreadySeeded);
|
||||||
|
|
||||||
|
// EnsureDatabaseAsync returns early because alreadySeeded is true
|
||||||
|
if (alreadySeeded)
|
||||||
|
{
|
||||||
|
// No owner is created
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — owner was NOT created (SeedAudit prevents re-seed)
|
||||||
|
hasUsers = await db.Users.AnyAsync();
|
||||||
|
Assert.False(hasUsers,
|
||||||
|
"SeedAudit should prevent owner re-creation even when Users table is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync
|
||||||
|
/// would proceed to seed a new owner (this is the pre-guard behavior,
|
||||||
|
/// documented here for completeness).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
await using var db = new NexusDbContext(options);
|
||||||
|
|
||||||
|
// No SeedAudit, no users — this is a fresh DB
|
||||||
|
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||||
|
Assert.False(alreadySeeded);
|
||||||
|
|
||||||
|
var hasUsers = await db.Users.AnyAsync();
|
||||||
|
Assert.False(hasUsers);
|
||||||
|
|
||||||
|
// Act — simulate the seed path (what EnsureDatabaseAsync would do when !alreadySeeded && !hasUsers)
|
||||||
|
if (!alreadySeeded && !hasUsers)
|
||||||
|
{
|
||||||
|
// This is what EnsureDatabaseAsync would do: create owner + seed audit
|
||||||
|
db.Users.Add(new NexusUser
|
||||||
|
{
|
||||||
|
Email = "owner@nexus.internal",
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||||
|
DisplayName = "Nexus Owner",
|
||||||
|
PasswordHash = PasswordSecurity.Hash("GeneratedTempPassword"),
|
||||||
|
Role = "owner"
|
||||||
|
});
|
||||||
|
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — owner now exists
|
||||||
|
hasUsers = await db.Users.AnyAsync();
|
||||||
|
Assert.True(hasUsers);
|
||||||
|
Assert.True(await db.SeedAudits.AnyAsync(s => s.Key == SeedKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() => [];
|
||||||
|
}
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||||
|
|||||||
@@ -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 ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
public Task<List<WorkTask>> GetPendingApprovalAsync(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<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 UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
public Task DeleteAsync(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();
|
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
@@ -109,6 +111,9 @@ internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard
|
|||||||
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>(), ct);
|
||||||
|
|
||||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
@@ -140,4 +145,7 @@ internal sealed class SnapshotAgentServiceStub : IAgentService
|
|||||||
|
|
||||||
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
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,
|
||||||
|
new FakeNotificationService());
|
||||||
|
|
||||||
|
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 FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
|
||||||
|
{
|
||||||
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||||
|
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||||
|
|
||||||
|
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||||
|
{
|
||||||
|
Title = "Stalled agent task",
|
||||||
|
State = "In progress",
|
||||||
|
Source = "iris",
|
||||||
|
UpdatedAt = stalledTimestamp,
|
||||||
|
CreatedAt = stalledTimestamp
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||||
|
{
|
||||||
|
Title = "Fresh in progress",
|
||||||
|
State = "In progress",
|
||||||
|
Source = "iris",
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||||
|
CreatedAt = stalledTimestamp
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||||
|
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, flagged);
|
||||||
|
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
|
||||||
|
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
|
||||||
|
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
|
||||||
|
|
||||||
|
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
|
||||||
|
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
|
||||||
|
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
|
||||||
|
|
||||||
|
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
|
||||||
|
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||||
|
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||||
|
Assert.Equal(0, flaggedAgain);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
StalledMinutes = 45,
|
||||||
|
IntervalMinutes = 10
|
||||||
|
}),
|
||||||
|
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||||
|
|
||||||
|
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
|
||||||
|
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
|
||||||
|
Assert.Equal(7, flaggedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
StalledMinutes = 40,
|
||||||
|
IntervalMinutes = 10
|
||||||
|
}),
|
||||||
|
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.FlagCallCount >= 1);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(40), 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 FlagCallCount { get; private set; }
|
||||||
|
public int ResetCallCount { get; private set; }
|
||||||
|
public TimeSpan LastThreshold { get; private set; }
|
||||||
|
public Action? OnCall { get; set; }
|
||||||
|
|
||||||
|
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
FlagCallCount++;
|
||||||
|
LastThreshold = stalledThreshold;
|
||||||
|
OnCall?.Invoke();
|
||||||
|
return Task.FromResult(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ResetCallCount++;
|
||||||
|
LastThreshold = staleThreshold;
|
||||||
|
OnCall?.Invoke();
|
||||||
|
return Task.FromResult(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class FakeNotificationService : INotificationService
|
||||||
|
{
|
||||||
|
public List<Notification> Created { get; } = [];
|
||||||
|
|
||||||
|
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
|
||||||
|
Created.Add(notification);
|
||||||
|
return Task.FromResult(notification);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
|
||||||
|
|
||||||
|
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
|
||||||
|
|
||||||
|
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||||
|
|
||||||
|
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,245 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public class TaskBoardTests
|
||||||
|
{
|
||||||
|
// ── TaskStateHelper: BoardGroupKey ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", "offen")]
|
||||||
|
[InlineData("In progress", "inProgress")]
|
||||||
|
[InlineData("Review", "review")]
|
||||||
|
[InlineData("Blocked", "blocked")]
|
||||||
|
[InlineData("Done", "done")]
|
||||||
|
[InlineData("backlog", "offen")]
|
||||||
|
[InlineData("in progress", "inProgress")]
|
||||||
|
[InlineData("review", "review")]
|
||||||
|
[InlineData("blocked", "blocked")]
|
||||||
|
[InlineData("done", "done")]
|
||||||
|
[InlineData("", "offen")]
|
||||||
|
[InlineData(null, "offen")]
|
||||||
|
[InlineData("unknown", "offen")]
|
||||||
|
public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
|
||||||
|
{
|
||||||
|
var result = TaskStateHelper.BoardGroupKey(state);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: BoardGroupToState ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("offen", "Backlog")]
|
||||||
|
[InlineData("inProgress", "In progress")]
|
||||||
|
[InlineData("inprogress", "In progress")]
|
||||||
|
[InlineData("review", "Review")]
|
||||||
|
[InlineData("blocked", "Blocked")]
|
||||||
|
[InlineData("done", "Done")]
|
||||||
|
[InlineData("Offen", "Backlog")]
|
||||||
|
[InlineData("", null)]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
[InlineData("unknown", null)]
|
||||||
|
public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
|
||||||
|
{
|
||||||
|
var result = TaskStateHelper.BoardGroupToState(groupKey);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: AllStates has 5 entries ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllStates_ContainsAllFiveStates()
|
||||||
|
{
|
||||||
|
var states = TaskStateHelper.AllStates;
|
||||||
|
Assert.Equal(5, states.Length);
|
||||||
|
Assert.Contains("Backlog", states);
|
||||||
|
Assert.Contains("In progress", states);
|
||||||
|
Assert.Contains("Review", states);
|
||||||
|
Assert.Contains("Blocked", states);
|
||||||
|
Assert.Contains("Done", states);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsValidState ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", true)]
|
||||||
|
[InlineData("In progress", true)]
|
||||||
|
[InlineData("Review", true)]
|
||||||
|
[InlineData("Blocked", true)]
|
||||||
|
[InlineData("Done", true)]
|
||||||
|
[InlineData("backlog", true)]
|
||||||
|
[InlineData("offen", false)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
[InlineData("unknown", false)]
|
||||||
|
public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsValidState(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsInProgressOrBlocked ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("In progress", true)]
|
||||||
|
[InlineData("Blocked", true)]
|
||||||
|
[InlineData("Backlog", false)]
|
||||||
|
[InlineData("Review", false)]
|
||||||
|
[InlineData("Done", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsDoneOrBacklog ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Done", true)]
|
||||||
|
[InlineData("Backlog", true)]
|
||||||
|
[InlineData("In progress", false)]
|
||||||
|
[InlineData("Review", false)]
|
||||||
|
[InlineData("Blocked", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: ToDisplayString ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", "Offen")]
|
||||||
|
[InlineData("In progress", "In Bearbeitung")]
|
||||||
|
[InlineData("Review", "Review")]
|
||||||
|
[InlineData("Blocked", "Blockiert")]
|
||||||
|
[InlineData("Done", "Erledigt")]
|
||||||
|
[InlineData("backlog", "Offen")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData(null, "")]
|
||||||
|
[InlineData("unknown", "unknown")]
|
||||||
|
public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskState helper: ToStateString and ToTaskState roundtrip ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToStateString_And_ToTaskState_RoundTrip()
|
||||||
|
{
|
||||||
|
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Review, TaskState.Blocked, TaskState.Done };
|
||||||
|
foreach (var state in states)
|
||||||
|
{
|
||||||
|
var str = state.ToStateString();
|
||||||
|
var parsed = str.ToTaskState();
|
||||||
|
Assert.Equal(state, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToTaskState_DefaultsToBacklog_ForUnknownString()
|
||||||
|
{
|
||||||
|
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_Iris_CanChangeAnyTask()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_Bao_CanChangeAnyTask()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_SubAgents_NeverAllowed()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_NexusSystem_IsAllowed()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
|
||||||
|
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_UnknownCaller_Rejected()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState(null, task));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: CanEditContent ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_Iris_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("iris"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_Bao_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("bao"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_SubAgents_AreAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("programmer"));
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("architekt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_NexusSystem_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_UnknownCaller_Rejected()
|
||||||
|
{
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(""));
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(null));
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
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,
|
||||||
|
notificationService);
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin/User-Management – erreichbar für owner und admin-Rollen.
|
||||||
|
///
|
||||||
|
/// Sicherheitsregeln:
|
||||||
|
/// - Nur owner und admin dürfen User verwalten.
|
||||||
|
/// - Die Rolle "owner" kann weder vergeben noch überschrieben werden – sie ist
|
||||||
|
/// eine Sonderrolle, die nur bei der initialen Seed-Erstellung gesetzt wird.
|
||||||
|
/// - Über die API sind nur die Rollen "admin", "user" und "viewer" wählbar.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin")]
|
||||||
|
[Authorize(Roles = "owner,admin")]
|
||||||
|
public class AdminController(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<AdminController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly string[] SettableRoles = ["admin", "user", "viewer"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alle registrierten User auflisten.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IResult> GetUsers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var users = await userRepository.GetAllAsync(ct);
|
||||||
|
var result = users.Select(u => new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
Email = u.Email,
|
||||||
|
DisplayName = u.DisplayName,
|
||||||
|
Role = u.Role,
|
||||||
|
CreatedAt = u.CreatedAt,
|
||||||
|
LastLoginAt = u.LastLoginAt,
|
||||||
|
}).ToList();
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Neuen User anlegen.
|
||||||
|
/// Die Rolle "owner" kann NICHT gesetzt werden.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("users")]
|
||||||
|
public async Task<IResult> CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["request"] = ["Email and password are required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.Password.Length < 10)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["password"] = ["Password must be at least 10 characters."]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role validieren – owner ist nicht über API setzbar
|
||||||
|
var targetRole = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant();
|
||||||
|
if (!SettableRoles.Contains(targetRole))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = [$"Invalid role. Valid roles: {string.Join(", ", SettableRoles)}."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
||||||
|
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
||||||
|
if (existing is not null)
|
||||||
|
return Results.Conflict(new { error = "A user with this email already exists." });
|
||||||
|
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Email = request.Email.Trim(),
|
||||||
|
NormalizedEmail = normalizedEmail,
|
||||||
|
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||||
|
? request.Email.Split('@')[0]
|
||||||
|
: request.DisplayName.Trim(),
|
||||||
|
PasswordHash = PasswordSecurity.Hash(request.Password),
|
||||||
|
Role = targetRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
await userRepository.AddAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} created user {Email} with role {Role}", UserRole(), user.Email, user.Role);
|
||||||
|
|
||||||
|
return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User löschen. Eigene owner-User und der eigene Account sind geschützt.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("users/{id:guid}")]
|
||||||
|
public async Task<IResult> DeleteUser(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
|
||||||
|
|
||||||
|
if (user.Id.ToString() == CurrentUserId())
|
||||||
|
return Results.Problem("You cannot delete your own account.", statusCode: 403);
|
||||||
|
|
||||||
|
await userRepository.DeleteAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} deleted user {Email}", UserRole(), user.Email);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rolle eines Users ändern. "owner" kann weder gesetzt noch überschrieben werden.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("users/{id:guid}/role")]
|
||||||
|
public async Task<IResult> UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Role))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = ["Role is required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var newRole = request.Role.Trim().ToLowerInvariant();
|
||||||
|
if (!SettableRoles.Contains(newRole))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = [$"Invalid role. Valid: {string.Join(", ", SettableRoles)}. Owner is reserved."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
// Niemals owner überschreiben
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Owner role cannot be modified via API.", statusCode: 403);
|
||||||
|
|
||||||
|
// admin darf andere admins nicht ändern (nur owner)
|
||||||
|
var callerRole = UserRole();
|
||||||
|
if (callerRole == "admin" && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Admin users can only be managed by the owner.", statusCode: 403);
|
||||||
|
|
||||||
|
// admin darf sich nicht selbst herabstufen
|
||||||
|
if (callerRole == "admin" && user.Id.ToString() == CurrentUserId() && newRole != "admin")
|
||||||
|
return Results.Problem("You cannot demote yourself.", statusCode: 403);
|
||||||
|
|
||||||
|
user.Role = newRole;
|
||||||
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await userRepository.UpdateAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} changed role for {Email} from {OldRole} to {NewRole}",
|
||||||
|
callerRole, user.Email, user.Role, newRole);
|
||||||
|
|
||||||
|
return Results.Ok(new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
LastLoginAt = user.LastLoginAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liefert die Rolle des aufrufenden Users.</summary>
|
||||||
|
private string UserRole()
|
||||||
|
=> User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value?.ToLowerInvariant() ?? "unknown";
|
||||||
|
|
||||||
|
/// <summary>Liefert die Subject-ID des aufrufenden Users.</summary>
|
||||||
|
private string? CurrentUserId()
|
||||||
|
=> User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using System.Security.Claims;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
@@ -14,6 +16,7 @@ public class AgentsController(
|
|||||||
IAgentRuntime runtime,
|
IAgentRuntime runtime,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
IAgentConfigService agentConfigService,
|
IAgentConfigService agentConfigService,
|
||||||
|
IDashboardService dashboardService,
|
||||||
ILogger<AgentsController> logger) : ControllerBase
|
ILogger<AgentsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -39,7 +42,25 @@ public class AgentsController(
|
|||||||
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
|
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var items = await activityRepo.GetByAgentAsync(id, 50, 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")]
|
[HttpPost("{id}/command")]
|
||||||
@@ -84,17 +105,152 @@ public class AgentsController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/config/{fileName}")]
|
[HttpPut("{id}/config/{fileName}")]
|
||||||
|
[Authorize(Roles = "owner")]
|
||||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (request.Content is null)
|
if (request.Content is null)
|
||||||
return Results.BadRequest(new { error = "Content is required." });
|
return Results.BadRequest(new { error = "Content is required." });
|
||||||
|
|
||||||
if (request.Content.Length > 500 * 1024)
|
try
|
||||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
{
|
||||||
|
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||||
|
var caller = DescribeCaller(HttpContext.User);
|
||||||
|
|
||||||
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
if (attempt.Failure is not null)
|
||||||
return result is null
|
{
|
||||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
|
return Results.Problem(
|
||||||
|
title: "Permission denied",
|
||||||
|
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
|
return Results.Problem(
|
||||||
|
title: "File write error",
|
||||||
|
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.RateLimiting;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
@@ -14,7 +15,8 @@ public class AuthController(
|
|||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
IHostEnvironment env) : ControllerBase
|
IHostEnvironment env,
|
||||||
|
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("csrf")]
|
[HttpGet("csrf")]
|
||||||
public IActionResult GetCsrfToken()
|
public IActionResult GetCsrfToken()
|
||||||
@@ -30,11 +32,38 @@ public class AuthController(
|
|||||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
||||||
|
|
||||||
var session = await authService.LoginAsync(request, ct);
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
if (session is null) return Results.Unauthorized();
|
|
||||||
|
|
||||||
|
var session = await authService.LoginAsync(request, ct);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
var remaining = attemptTracker.RecordFailedAttempt(ip);
|
||||||
|
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
|
||||||
|
|
||||||
|
// Attach remaining info to the 401 response via headers only
|
||||||
|
// (the frontend can also parse the 429 body)
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
|
if (retryAfterSeconds > 0)
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||||
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
// Return a structured body so the frontend can display remaining attempts
|
||||||
|
return Results.Json(new
|
||||||
|
{
|
||||||
|
error = "invalid_credentials",
|
||||||
|
message = "Invalid email or password.",
|
||||||
|
remaining,
|
||||||
|
retryAfterSeconds
|
||||||
|
}, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — reset attempt counter
|
||||||
|
attemptTracker.Reset(ip);
|
||||||
SetRefreshCookie(Response, session.RefreshToken);
|
SetRefreshCookie(Response, session.RefreshToken);
|
||||||
Response.Headers.CacheControl = "no-store";
|
Response.Headers.CacheControl = "no-store";
|
||||||
|
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||||
|
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
return Results.Ok(ToAuthResponse(session));
|
return Results.Ok(ToAuthResponse(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +83,8 @@ public class AuthController(
|
|||||||
|
|
||||||
SetRefreshCookie(Response, session.RefreshToken);
|
SetRefreshCookie(Response, session.RefreshToken);
|
||||||
Response.Headers.CacheControl = "no-store";
|
Response.Headers.CacheControl = "no-store";
|
||||||
|
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||||
|
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
return Results.Ok(ToAuthResponse(session));
|
return Results.Ok(ToAuthResponse(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
|
|||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/chat")]
|
[Route("api/v1/chat")]
|
||||||
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
|
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
|
public class DashboardController(
|
||||||
|
IDashboardService dashboardService,
|
||||||
|
ITaskService taskService,
|
||||||
|
IActivityRepository activityService,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IAgentService agentService,
|
||||||
|
IConfiguration configuration,
|
||||||
|
INotificationService notificationService,
|
||||||
|
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
public async Task<DashboardStatus> GetStatus()
|
public async Task<DashboardStatus> GetStatus()
|
||||||
@@ -44,6 +56,10 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
|||||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||||
=> await dashboardService.GetQueueAsync(ct);
|
=> await dashboardService.GetQueueAsync(ct);
|
||||||
|
|
||||||
|
[HttpGet("gateway")]
|
||||||
|
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
|
||||||
|
=> await dashboardService.GetGatewayInfoAsync(ct);
|
||||||
|
|
||||||
[HttpDelete("queue/{id}")]
|
[HttpDelete("queue/{id}")]
|
||||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -115,17 +131,24 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
|||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
return BadRequest(new { error = "Title is required." });
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var task = await taskService.CreateDashboardTaskAsync(
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
|
||||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
}
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPut("tasks/{id:guid}")]
|
[HttpPut("tasks/{id:guid}")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||||
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await taskService.UpdateDashboardTaskAsync(
|
var result = await taskService.UpdateDashboardTaskAsync(
|
||||||
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
@@ -149,6 +172,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
|||||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||||
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
// Enforce workflow rules based on caller agent
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
// Resolve caller agent from header or JWT
|
||||||
|
var callerAgent = ResolveCallerAgent();
|
||||||
|
|
||||||
|
// Nur Iris und Bao dürfen Status ändern
|
||||||
|
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
|
||||||
|
{
|
||||||
|
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
|
||||||
|
}
|
||||||
|
|
||||||
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
@@ -158,6 +195,313 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
// ── Task Board Endpoints ──
|
||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("tasks/board")]
|
||||||
|
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(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int notificationLimit = 50,
|
||||||
|
[FromQuery] long? afterSequence = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Response.Headers.Append("Content-Type", "text/event-stream");
|
||||||
|
Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||||
|
Response.Headers.Append("Connection", "keep-alive");
|
||||||
|
Response.Headers.Append("X-Accel-Buffering", "no");
|
||||||
|
|
||||||
|
async Task WriteEventAsync(string eventName, object payload)
|
||||||
|
{
|
||||||
|
await Response.WriteAsync($"event: {eventName}\n", ct);
|
||||||
|
await Response.WriteAsync($"data: {System.Text.Json.JsonSerializer.Serialize(payload)}\n\n", ct);
|
||||||
|
await Response.Body.FlushAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentSequence = liveUpdateService.CurrentSequence;
|
||||||
|
var initial = new DashboardLiveSnapshotDto(
|
||||||
|
await taskService.GetBoardAsync(ct),
|
||||||
|
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
|
||||||
|
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
|
await WriteEventAsync("snapshot", initial);
|
||||||
|
|
||||||
|
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||||
|
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
|
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
|
||||||
|
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
|
||||||
|
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
|
||||||
|
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
|
||||||
|
// mit einer InvalidOperationException.
|
||||||
|
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||||
|
|
||||||
|
if (completed == readTask)
|
||||||
|
{
|
||||||
|
var envelope = await readTask;
|
||||||
|
readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
|
||||||
|
if (envelope.Type == "notifications.snapshot")
|
||||||
|
{
|
||||||
|
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||||
|
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||||
|
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
envelope = envelope with { Payload = snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Type == "tasks.board.snapshot")
|
||||||
|
{
|
||||||
|
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||||
|
envelope,
|
||||||
|
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ticked = await heartbeatTask;
|
||||||
|
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
if (!ticked) break;
|
||||||
|
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Client hat die Verbindung beendet — normal.
|
||||||
|
}
|
||||||
|
catch (System.Threading.Channels.ChannelClosedException)
|
||||||
|
{
|
||||||
|
// Subscription serverseitig geschlossen — Stream regulär beenden.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{id:guid}/move")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
||||||
|
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.State))
|
||||||
|
return BadRequest(new { error = "State is required." });
|
||||||
|
|
||||||
|
// Enforce workflow rules based on caller agent
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
// Resolve caller agent from header or JWT
|
||||||
|
var callerAgent = ResolveCallerAgent();
|
||||||
|
|
||||||
|
// Nur Iris und Bao dürfen Status ändern
|
||||||
|
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
|
||||||
|
{
|
||||||
|
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await taskService.MoveTaskAsync(id, request.State, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Review-Aktionen (Bao/Iris) ──
|
||||||
|
|
||||||
|
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
|
||||||
|
[HttpPost("tasks/{id:guid}/approve")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||||
|
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
|
||||||
|
|
||||||
|
var result = await taskService.ApproveReviewAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
|
||||||
|
[HttpPost("tasks/{id:guid}/request-changes")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
|
||||||
|
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
|
||||||
|
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||||
|
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
|
||||||
|
|
||||||
|
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||||
|
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveCallerAgent()
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext is null) return "";
|
||||||
|
|
||||||
|
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
|
return agentHeader.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var user = httpContext.User;
|
||||||
|
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
return nameClaim?.ToLowerInvariant() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New Endpoints: Reset Stale, Children, Activity ──
|
||||||
|
|
||||||
|
[HttpPost("tasks/reset-stale")]
|
||||||
|
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
|
||||||
|
[FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
|
||||||
|
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
|
||||||
|
return Ok(new ResetStaleResponse(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}/children")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||||
|
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
|
||||||
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
return Ok(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}/activity")]
|
||||||
|
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var events = await taskService.GetTaskActivityAsync(id, ct);
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{id:guid}/activity")]
|
||||||
|
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
|
||||||
|
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var task = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
return BadRequest(new { error = "Message is required." });
|
||||||
|
|
||||||
|
var ev = new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = request.Type ?? "comment",
|
||||||
|
Message = request.Message.Trim(),
|
||||||
|
TaskId = id
|
||||||
|
};
|
||||||
|
|
||||||
|
await activityService.AddAsync(ev, ct);
|
||||||
|
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Workflow Endpoints (Iris Overview) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns agent-tasks that are still open and waiting for input.
|
||||||
|
/// Iris uses this to see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("tasks/agent-waiting")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var waiting = await taskService.GetWaitingTasksAsync(ct);
|
||||||
|
return Ok(waiting.Select(MapToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a complete agent-workflow overview grouped by expected respondent
|
||||||
|
/// + stale detection. This is the main Iris dashboard data.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("tasks/agent-overview")]
|
||||||
|
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||||
|
CancellationToken ct, [FromQuery] int staleHours = 2)
|
||||||
|
{
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||||
|
return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an agent-task: a task that is tracked as originating from the agent workflow.
|
||||||
|
/// Sub-agents (programmer, reviewer) can only CREATE, not move state.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("tasks/agent")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> CreateAgentTask(
|
||||||
|
[FromBody] CreateAgentTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var task = await taskService.CreateAgentTaskAsync(
|
||||||
|
request.Title, request.Detail, request.Source ?? "iris",
|
||||||
|
request.Priority, request.AssignedTo, request.ExpectedFrom,
|
||||||
|
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
|
||||||
|
|
||||||
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-style (structured-command) backend bridge for agent-facing operations.
|
||||||
|
///
|
||||||
|
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
|
||||||
|
/// the Nexus task board, activity log, and delegation workflow.
|
||||||
|
///
|
||||||
|
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
|
||||||
|
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
|
||||||
|
///
|
||||||
|
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
|
||||||
|
/// calls these structured HTTP endpoints (same pattern, simpler transport).
|
||||||
|
///
|
||||||
|
/// COMMANDS:
|
||||||
|
/// create_task → POST /api/bridge/tasks
|
||||||
|
/// create_child_task → POST /api/bridge/tasks/{id}/children
|
||||||
|
/// update_status → PATCH /api/bridge/tasks/{id}/status
|
||||||
|
/// append_activity → POST /api/bridge/tasks/{id}/activity
|
||||||
|
/// handoff → POST /api/bridge/tasks/{id}/handoff
|
||||||
|
/// get_board → GET /api/bridge/board
|
||||||
|
/// get_task → GET /api/bridge/tasks/{id}
|
||||||
|
/// get_children → GET /api/bridge/tasks/{id}/children
|
||||||
|
/// get_activity → GET /api/bridge/tasks/{id}/activity
|
||||||
|
/// get_agent_overview → GET /api/bridge/agent-overview
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/bridge")]
|
||||||
|
[EnableRateLimiting("agents")]
|
||||||
|
public class GatewayBridgeController(
|
||||||
|
ITaskBridgeService bridge,
|
||||||
|
IAgentService agentService,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string ApikeyErrorMessage =
|
||||||
|
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
|
||||||
|
|
||||||
|
[HttpGet("health")]
|
||||||
|
public IResult Health()
|
||||||
|
{
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
status = "ok",
|
||||||
|
service = "nexus-bridge",
|
||||||
|
version = "1.0.0",
|
||||||
|
commands = new[]
|
||||||
|
{
|
||||||
|
"create_task", "create_child_task", "update_status",
|
||||||
|
"append_activity", "handoff", "get_board", "get_task",
|
||||||
|
"get_children", "get_activity", "get_agent_overview"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
|
||||||
|
[FromBody] BridgeCreateTaskCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.CreateTaskAsync(
|
||||||
|
title: command.Title,
|
||||||
|
detail: command.Detail,
|
||||||
|
source: ResolveSource(agentId),
|
||||||
|
priority: command.Priority ?? "Normal",
|
||||||
|
assignedTo: command.AssignedTo ?? agentId,
|
||||||
|
projectId: command.ProjectId,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "create_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{parentTaskId:guid}/children")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
|
||||||
|
Guid parentTaskId,
|
||||||
|
[FromBody] BridgeCreateChildTaskCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.CreateChildTaskAsync(
|
||||||
|
parentTaskId: parentTaskId,
|
||||||
|
title: command.Title,
|
||||||
|
detail: command.Detail,
|
||||||
|
source: ResolveSource(agentId),
|
||||||
|
priority: command.Priority ?? "Normal",
|
||||||
|
assignedTo: command.AssignedTo,
|
||||||
|
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||||
|
startsInProgress: command.StartsInProgress,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "create_child_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{taskId:guid}/status")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeUpdateStatusCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var agentId = resolution.AgentId;
|
||||||
|
var result = await bridge.UpdateStatusAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
state: command.State,
|
||||||
|
callerAgent: agentId,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "update_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{taskId:guid}/activity")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeAppendActivityCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.AppendActivityAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
message: command.Message,
|
||||||
|
type: command.Type ?? "comment",
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapActivityResult(result, "append_activity");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{taskId:guid}/handoff")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
|
||||||
|
Guid taskId,
|
||||||
|
[FromBody] BridgeHandoffCommand command,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.HandoffAsync(
|
||||||
|
taskId: taskId,
|
||||||
|
targetAgent: command.TargetAgent,
|
||||||
|
note: command.Note,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
return MapResult(result, "handoff");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("board")]
|
||||||
|
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
return Ok(await bridge.GetBoardAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var result = await bridge.GetTaskAsync(taskId, ct);
|
||||||
|
return MapResult(result, "get_task");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}/children")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{taskId:guid}/activity")]
|
||||||
|
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
|
||||||
|
Guid taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var events = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||||
|
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
|
||||||
|
|
||||||
|
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = "get_activity",
|
||||||
|
Data = entries
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("agent-overview")]
|
||||||
|
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||||
|
CancellationToken ct,
|
||||||
|
[FromQuery] int staleHours = 2)
|
||||||
|
{
|
||||||
|
var resolution = await TryResolveAgentAsync(ct);
|
||||||
|
if (!resolution.Success)
|
||||||
|
return resolution.ErrorResult!;
|
||||||
|
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||||
|
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (allowedActorIds.Contains(normalizedHeader))
|
||||||
|
return (true, normalizedHeader, null);
|
||||||
|
|
||||||
|
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||||
|
normalizedHeader,
|
||||||
|
HttpContext.Connection.RemoteIpAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
|
{
|
||||||
|
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
|
||||||
|
return (true, normalizedClaim, null);
|
||||||
|
|
||||||
|
// 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 (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
|
||||||
|
allowedActorIds.Contains("nexus-system"))
|
||||||
|
return (true, "nexus-system", null);
|
||||||
|
|
||||||
|
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||||
|
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
|
||||||
|
return (false, string.Empty, unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveSource(string agentId) => agentId switch
|
||||||
|
{
|
||||||
|
"bao" or "nexus-system" => "bao",
|
||||||
|
_ => agentId
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||||
|
{
|
||||||
|
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||||
|
return new OkObjectResult(new TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = command,
|
||||||
|
Data = result.Data
|
||||||
|
});
|
||||||
|
|
||||||
|
var statusCode = result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskBridgeOutcome.NotFound => 404,
|
||||||
|
TaskBridgeOutcome.InvalidState => 422,
|
||||||
|
TaskBridgeOutcome.Unauthorized => 403,
|
||||||
|
TaskBridgeOutcome.ValidationError => 400,
|
||||||
|
_ => 500
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ObjectResult(new TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
Ok = false,
|
||||||
|
Command = command,
|
||||||
|
Error = result.Error ?? "Unknown error"
|
||||||
|
}) { StatusCode = statusCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
|
||||||
|
{
|
||||||
|
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||||
|
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||||
|
{
|
||||||
|
Ok = true,
|
||||||
|
Command = command,
|
||||||
|
Data = result.Data is null ? null : new ActivityEntryDto(
|
||||||
|
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
var statusCode = result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskBridgeOutcome.NotFound => 404,
|
||||||
|
TaskBridgeOutcome.ValidationError => 400,
|
||||||
|
_ => 500
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||||
|
{
|
||||||
|
Ok = false,
|
||||||
|
Command = command,
|
||||||
|
Error = result.Error ?? "Unknown error"
|
||||||
|
}) { StatusCode = statusCode };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TaskBridgeCommandResponse<T>
|
||||||
|
{
|
||||||
|
public bool Ok { get; init; }
|
||||||
|
public string Command { get; init; } = string.Empty;
|
||||||
|
public T? Data { get; init; }
|
||||||
|
public string? Error { get; init; }
|
||||||
|
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BridgeCreateTaskCommand(
|
||||||
|
string Title,
|
||||||
|
string? Detail = null,
|
||||||
|
string? Priority = null,
|
||||||
|
string? AssignedTo = null,
|
||||||
|
Guid? ProjectId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeCreateChildTaskCommand(
|
||||||
|
string Title,
|
||||||
|
string? Detail = null,
|
||||||
|
string? Priority = null,
|
||||||
|
string? AssignedTo = null,
|
||||||
|
string? ExpectedFrom = null,
|
||||||
|
bool StartsInProgress = false
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeUpdateStatusCommand(string State);
|
||||||
|
|
||||||
|
public sealed record BridgeAppendActivityCommand(
|
||||||
|
string Message,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record BridgeHandoffCommand(
|
||||||
|
string TargetAgent,
|
||||||
|
string? Note = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ActivityEntryDto(
|
||||||
|
long Id,
|
||||||
|
string Type,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
@@ -7,6 +8,13 @@ namespace Nexus.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("/health/live")]
|
||||||
|
public IResult Live()
|
||||||
|
{
|
||||||
|
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
public async Task<IResult> Get(CancellationToken ct)
|
public async Task<IResult> Get(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -26,20 +34,25 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
|||||||
runtimeDetail = ex.Message;
|
runtimeDetail = ex.Message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static IReadOnlyDictionary<string, object?> NormalizeData(IReadOnlyDictionary<string, object> source)
|
||||||
|
{
|
||||||
|
return source.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);
|
||||||
|
}
|
||||||
|
|
||||||
var entries = report.Entries.ToDictionary(
|
var entries = report.Entries.ToDictionary(
|
||||||
e => e.Key,
|
e => e.Key,
|
||||||
e => new
|
e => new
|
||||||
{
|
{
|
||||||
status = e.Value.Status.ToString(),
|
status = e.Value.Status.ToString(),
|
||||||
description = e.Value.Description,
|
description = e.Value.Description,
|
||||||
data = e.Value.Data
|
data = NormalizeData(e.Value.Data)
|
||||||
});
|
});
|
||||||
|
|
||||||
entries["runtime"] = new
|
entries["runtime"] = new
|
||||||
{
|
{
|
||||||
status = runtimeStatus,
|
status = runtimeStatus,
|
||||||
description = runtimeDetail ?? "Runtime status checked",
|
description = runtimeDetail,
|
||||||
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
|
data = new Dictionary<string, object?>() as IReadOnlyDictionary<string, object?>
|
||||||
};
|
};
|
||||||
|
|
||||||
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/dashboard/notifications")]
|
||||||
|
public class NotificationsController(INotificationService notificationService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
[FromQuery] bool unreadOnly = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
|
||||||
|
return Ok(notifications.Select(MapToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
|
||||||
|
return Ok(new UnreadCountDto(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("snapshot")]
|
||||||
|
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
[FromQuery] bool unreadOnly = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/read")]
|
||||||
|
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ok = await notificationService.MarkAsReadAsync(id, ct);
|
||||||
|
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("read-all")]
|
||||||
|
public async Task<ActionResult> MarkAllAsRead(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
|
||||||
|
return Ok(new { marked = count });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationDto MapToDto(Notification n) => new(
|
||||||
|
n.Id, n.Type, n.Title, n.Message,
|
||||||
|
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/projects")]
|
[Route("api/v1/projects")]
|
||||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using System.Security.Claims;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskService taskService) : ControllerBase
|
public class TasksController(
|
||||||
|
ITaskService taskService,
|
||||||
|
IAgentService agentService,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IActivityRepository activityRepository) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
@@ -24,6 +33,7 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pending-approval")]
|
[HttpGet("pending-approval")]
|
||||||
|
[Authorize(Roles = "owner")]
|
||||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pending = await taskService.GetPendingApprovalAsync(ct);
|
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||||
@@ -31,9 +41,11 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/approve")]
|
[HttpPost("{id:guid}/approve")]
|
||||||
|
[Authorize(Roles = "owner")]
|
||||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await taskService.ApproveAsync(id, ct);
|
var result = await taskService.ApproveAsync(id, ct);
|
||||||
|
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
@@ -46,9 +58,11 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
|
[Authorize(Roles = "owner")]
|
||||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await taskService.RejectAsync(id, ct);
|
var result = await taskService.RejectAsync(id, ct);
|
||||||
|
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
|
||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
@@ -70,6 +84,10 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
return result.Outcome switch
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
|
title: "Action denied",
|
||||||
|
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
_ => Results.Ok(result.Task)
|
_ => Results.Ok(result.Task)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,4 +117,84 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
|||||||
_ => Results.NoContent()
|
_ => Results.NoContent()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||||
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
///
|
||||||
|
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
return Results.Unauthorized();
|
||||||
|
|
||||||
|
return Results.Ok(await taskService.GetBoardAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
|
||||||
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
///
|
||||||
|
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||||
|
/// 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 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();
|
||||||
|
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||||
|
return Results.Ok(new ResetStaleResponse(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteApprovalAuditAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string action,
|
||||||
|
TaskOperationOutcome outcome,
|
||||||
|
string? state,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ public sealed record UserInfo
|
|||||||
public string Role { get; init; } = string.Empty;
|
public string Role { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUserInfo
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Role { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUpdateRoleRequest
|
||||||
|
{
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record UpdateProfileRequest
|
public sealed record UpdateProfileRequest
|
||||||
{
|
{
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
|
|||||||
string? Title,
|
string? Title,
|
||||||
DateTimeOffset? Since
|
DateTimeOffset? Since
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+120
-4
@@ -19,7 +19,8 @@ public enum TaskState
|
|||||||
Backlog,
|
Backlog,
|
||||||
InProgress,
|
InProgress,
|
||||||
Blocked,
|
Blocked,
|
||||||
Done
|
Done,
|
||||||
|
Review
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TaskStateHelper
|
public static class TaskStateHelper
|
||||||
@@ -29,7 +30,8 @@ public static class TaskStateHelper
|
|||||||
[TaskState.Backlog] = "Backlog",
|
[TaskState.Backlog] = "Backlog",
|
||||||
[TaskState.InProgress] = "In progress",
|
[TaskState.InProgress] = "In progress",
|
||||||
[TaskState.Blocked] = "Blocked",
|
[TaskState.Blocked] = "Blocked",
|
||||||
[TaskState.Done] = "Done"
|
[TaskState.Done] = "Done",
|
||||||
|
[TaskState.Review] = "Review"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -37,11 +39,22 @@ public static class TaskStateHelper
|
|||||||
["Backlog"] = TaskState.Backlog,
|
["Backlog"] = TaskState.Backlog,
|
||||||
["In progress"] = TaskState.InProgress,
|
["In progress"] = TaskState.InProgress,
|
||||||
["Blocked"] = TaskState.Blocked,
|
["Blocked"] = TaskState.Blocked,
|
||||||
["Done"] = TaskState.Done
|
["Done"] = TaskState.Done,
|
||||||
|
["Review"] = TaskState.Review
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Mapping from state string to display label.</summary>
|
||||||
|
private static readonly Dictionary<string, string> DisplayLabels = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Backlog"] = "Offen",
|
||||||
|
["In progress"] = "In Bearbeitung",
|
||||||
|
["Review"] = "Review",
|
||||||
|
["Blocked"] = "Blockiert",
|
||||||
|
["Done"] = "Erledigt"
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Valid task-state string values for API validation.</summary>
|
/// <summary>Valid task-state string values for API validation.</summary>
|
||||||
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"];
|
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"];
|
||||||
|
|
||||||
/// <summary>Convert a TaskState enum to its API string representation.</summary>
|
/// <summary>Convert a TaskState enum to its API string representation.</summary>
|
||||||
public static string ToStateString(this TaskState state) => StateToString[state];
|
public static string ToStateString(this TaskState state) => StateToString[state];
|
||||||
@@ -54,6 +67,10 @@ public static class TaskStateHelper
|
|||||||
public static bool IsValidState(string? state) =>
|
public static bool IsValidState(string? state) =>
|
||||||
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
|
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
|
||||||
|
|
||||||
|
/// <summary>Returns the German display label for a state string.</summary>
|
||||||
|
public static string ToDisplayString(string? state) =>
|
||||||
|
state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? "";
|
||||||
|
|
||||||
public static bool IsInProgressOrBlocked(string? state) =>
|
public static bool IsInProgressOrBlocked(string? state) =>
|
||||||
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -61,6 +78,75 @@ public static class TaskStateHelper
|
|||||||
public static bool IsDoneOrBacklog(string? state) =>
|
public static bool IsDoneOrBacklog(string? state) =>
|
||||||
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the caller is allowed to change this task's state.
|
||||||
|
/// POLICY:
|
||||||
|
/// - **Iris und Bao** dürfen Status ändern / verschieben.
|
||||||
|
/// - Sub-agents (programmer, reviewer, architekt, researcher, executor) dürfen NIEMALS Status ändern.
|
||||||
|
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
|
||||||
|
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanChangeState(string? callerAgent, WorkTask task)
|
||||||
|
{
|
||||||
|
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||||
|
|
||||||
|
// Sub-agents must never move state
|
||||||
|
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt", "researcher", "executor" };
|
||||||
|
if (subAgents.Contains(caller)) return false;
|
||||||
|
|
||||||
|
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
|
||||||
|
if (caller == "nexus-system") return true;
|
||||||
|
|
||||||
|
// Iris und Bao dürfen Status ändern
|
||||||
|
return caller == "iris" || caller == "bao";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the caller is allowed to edit a task's content fields
|
||||||
|
/// (title, detail, priority, assignedTo, dueDate).
|
||||||
|
/// POLICY:
|
||||||
|
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
|
||||||
|
/// - Nur unbekannte/leere Caller werden abgewiesen.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanEditContent(string? callerAgent)
|
||||||
|
{
|
||||||
|
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(caller)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Group key for board responses (lowercased English state).</summary>
|
||||||
|
public static string BoardGroupKey(string? state)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state)) return "offen";
|
||||||
|
var lower = state.ToLowerInvariant();
|
||||||
|
return lower switch
|
||||||
|
{
|
||||||
|
"backlog" => "offen",
|
||||||
|
"in progress" => "inProgress",
|
||||||
|
"review" => "review",
|
||||||
|
"blocked" => "blocked",
|
||||||
|
"done" => "done",
|
||||||
|
_ => "offen"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a board group key back to the canonical state string.</summary>
|
||||||
|
public static string? BoardGroupToState(string? groupKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(groupKey)) return null;
|
||||||
|
var lower = groupKey.ToLowerInvariant();
|
||||||
|
return lower switch
|
||||||
|
{
|
||||||
|
"offen" => "Backlog",
|
||||||
|
"inprogress" => "In progress",
|
||||||
|
"review" => "Review",
|
||||||
|
"blocked" => "Blocked",
|
||||||
|
"done" => "Done",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Project
|
public sealed class Project
|
||||||
@@ -82,15 +168,45 @@ public sealed class WorkTask
|
|||||||
public string Priority { get; set; } = "Normal";
|
public string Priority { get; set; } = "Normal";
|
||||||
public string Source { get; set; } = "bao";
|
public string Source { get; set; } = "bao";
|
||||||
public string? AssignedTo { get; set; }
|
public string? AssignedTo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if this task was created programmatically by an agent (not manually by Bao).
|
||||||
|
/// Agent-tasks in the board are subject to stricter workflow rules.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAgentTask { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which agent/user is expected to respond next.
|
||||||
|
/// Helps Iris see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
public string? ExpectedFrom { get; set; }
|
||||||
|
|
||||||
|
public Guid? ParentTaskId { get; set; }
|
||||||
|
public WorkTask? ParentTask { get; set; }
|
||||||
|
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
|
||||||
public Guid? ProjectId { get; set; }
|
public Guid? ProjectId { get; set; }
|
||||||
|
public DateTimeOffset? DueDate { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class Notification
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
|
||||||
|
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
|
||||||
|
public string? Message { get; set; } // Detailtext
|
||||||
|
public required string ForUser { get; set; } // "bao" oder "iris"
|
||||||
|
public Guid? TaskId { get; set; } // Verknüpfte Task
|
||||||
|
public bool IsRead { get; set; } = false;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ActivityEvent
|
public sealed class ActivityEvent
|
||||||
{
|
{
|
||||||
public long Id { get; init; }
|
public long Id { get; init; }
|
||||||
public required string Type { get; set; }
|
public required string Type { get; set; }
|
||||||
public required string Message { get; set; }
|
public required string Message { get; set; }
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Nexus.Api.Data;
|
namespace Nexus.Api.Data;
|
||||||
|
|
||||||
@@ -28,6 +29,21 @@ public class NexusUser
|
|||||||
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks one-time seed operations so they are never re-executed — even
|
||||||
|
/// if the underlying data is deleted. This is the single guard that
|
||||||
|
/// prevents owner-password drift after DB resets or volume recreations.
|
||||||
|
/// </summary>
|
||||||
|
[Table("SeedAudit")]
|
||||||
|
public class SeedAudit
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(80)]
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
public class RefreshToken
|
public class RefreshToken
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260618214335_AddNotifications")]
|
||||||
|
partial class AddNotifications
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||||
|
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||||
|
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
IsRead = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskParentChild : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ParentTaskId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ParentTaskId",
|
||||||
|
principalTable: "Tasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskDueDate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "DueDate",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DueDate",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddActivityTaskReference : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "TaskId",
|
||||||
|
table: "Activity",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Activity_TaskId",
|
||||||
|
table: "Activity",
|
||||||
|
column: "TaskId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Activity_TaskId",
|
||||||
|
table: "Activity");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TaskId",
|
||||||
|
table: "Activity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260618233003_AddDelegatedState")]
|
||||||
|
partial class AddDelegatedState
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDelegatedState : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Delegated state is a pure code change to the TaskState enum and
|
||||||
|
// TaskStateHelper. No schema change required since the State column
|
||||||
|
// is already a free-form string column.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// No schema to revert.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260620174200_AddAgentTaskFields")]
|
||||||
|
partial class AddAgentTaskFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAgentTaskFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAgentTask",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExpectedFrom",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_IsAgentTask",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "IsAgentTask");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_ExpectedFrom",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ExpectedFrom");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_IsAgentTask",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_ExpectedFrom",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExpectedFrom",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAgentTask",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260621081500_AddSeedAudit")]
|
||||||
|
partial class AddSeedAudit
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("SeedAudit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSeedAudit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SeedAudit",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SeedAudit", x => x.Key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SeedAudit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Type")
|
b.Property<string>("Type")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
b.ToTable("Activity");
|
b.ToTable("Activity");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
|
|||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -166,6 +214,20 @@ namespace Nexus.Api.Migrations
|
|||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("SeedAudit");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -183,6 +245,19 @@ namespace Nexus.Api.Migrations
|
|||||||
.HasMaxLength(2000)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("character varying(2000)");
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Priority")
|
b.Property<string>("Priority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -211,6 +286,12 @@ namespace Nexus.Api.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AssignedTo");
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
b.HasIndex("Source");
|
b.HasIndex("Source");
|
||||||
|
|
||||||
b.ToTable("Tasks");
|
b.ToTable("Tasks");
|
||||||
@@ -227,10 +308,25 @@ namespace Nexus.Api.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
|||||||
{
|
{
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
||||||
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
||||||
public DbSet<NexusUser> Users => Set<NexusUser>();
|
public DbSet<NexusUser> Users => Set<NexusUser>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -19,10 +21,30 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
|||||||
entity.Property(x => x.Detail).HasMaxLength(2000);
|
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||||
entity.Property(x => x.Source).HasMaxLength(60);
|
entity.Property(x => x.Source).HasMaxLength(60);
|
||||||
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
|
||||||
entity.HasIndex(x => x.Source);
|
entity.HasIndex(x => x.Source);
|
||||||
entity.HasIndex(x => x.AssignedTo);
|
entity.HasIndex(x => x.AssignedTo);
|
||||||
|
entity.HasIndex(x => x.IsAgentTask);
|
||||||
|
entity.HasIndex(x => x.ExpectedFrom);
|
||||||
|
entity.HasOne(x => x.ParentTask)
|
||||||
|
.WithMany(x => x.ChildTasks)
|
||||||
|
.HasForeignKey(x => x.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
modelBuilder.Entity<Notification>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Title).HasMaxLength(240);
|
||||||
|
entity.Property(x => x.Message).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.Type).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.ForUser).HasMaxLength(60);
|
||||||
|
entity.HasIndex(x => new { x.ForUser, x.IsRead, x.CreatedAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ActivityEvent>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Message).HasMaxLength(1000);
|
||||||
|
entity.HasIndex(x => x.TaskId);
|
||||||
});
|
});
|
||||||
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
|
||||||
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ COPY . .
|
|||||||
RUN dotnet publish -c Release -o /app/publish
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
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
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using Nexus.Api.Middleware;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for configuring the Nexus application pipeline and startup.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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)
|
||||||
|
{
|
||||||
|
var configuration = app.Configuration;
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
const string seedKey = "owner_created";
|
||||||
|
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
|
||||||
|
if (alreadySeeded)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
|
||||||
|
var hasUsers = await db.Users.AnyAsync();
|
||||||
|
|
||||||
|
// ── 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 () =>
|
||||||
|
{
|
||||||
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
if (!hasUsers)
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseApiKeyAuthentication();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseSecurityHeaders();
|
||||||
|
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
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;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Routing;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for registering Nexus application services in the DI container.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures JWT authentication, authorization, and antiforgery.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var jwtKey = configuration["Jwt:Key"];
|
||||||
|
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
|
||||||
|
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
||||||
|
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidAudience = jwtAudience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
|
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||||
|
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAntiforgery(options =>
|
||||||
|
{
|
||||||
|
options.HeaderName = "X-CSRF-TOKEN";
|
||||||
|
options.Cookie.Name = "nexus-csrf";
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.Cookie.HttpOnly = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures rate limiting policies (auth and agents).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
options.OnRejected = async (context, ct) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.HttpContext.Response.Headers.ContentType = "application/json";
|
||||||
|
|
||||||
|
var retryAfterSeconds = 60;
|
||||||
|
|
||||||
|
// Try to read retry-after info from the metadata
|
||||||
|
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||||
|
{
|
||||||
|
retryAfterSeconds = (int)retryAfter.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set standard headers
|
||||||
|
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
|
||||||
|
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
|
||||||
|
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||||
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
error = "rate_limit_exceeded",
|
||||||
|
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
|
||||||
|
remaining = 0,
|
||||||
|
retryAfterSeconds
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 5,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 30,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures forwarded headers for reverse proxy scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures Swagger and JSON serialization options.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen();
|
||||||
|
services.ConfigureHttpJsonOptions(options =>
|
||||||
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Entity Framework Core DbContext with Npgsql.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddDbContext<NexusDbContext>(options =>
|
||||||
|
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers typed and named HTTP clients for OpenClaw integration.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient("gateway", client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers application domain services (transient, scoped, singleton).
|
||||||
|
/// </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>();
|
||||||
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
services.AddScoped<IAgentService, AgentService>();
|
||||||
|
services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
services.AddScoped<IProjectService, ProjectService>();
|
||||||
|
services.AddScoped<ITaskService, TaskService>();
|
||||||
|
services.AddScoped<IOperationsService, OperationsService>();
|
||||||
|
services.AddScoped<ITeamService, TeamService>();
|
||||||
|
services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||||
|
services.AddSingleton<IMemoryService, MemoryService>();
|
||||||
|
services.AddSingleton<IIncidentService, IncidentService>();
|
||||||
|
services.AddSingleton<IDocService, DocService>();
|
||||||
|
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>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers data repositories.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
|
services.AddScoped<ITaskRepository, TaskRepository>();
|
||||||
|
services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures health checks (PostgreSQL connectivity and runtime status).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||||
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for password generation and name construction.
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateTemporaryPassword()
|
||||||
|
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human-readable display name from an email address.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildOwnerDisplayName(string email)
|
||||||
|
{
|
||||||
|
var localPart = email.Split('@', 2)[0].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
||||||
|
|
||||||
|
var words = localPart
|
||||||
|
.Replace('.', ' ')
|
||||||
|
.Replace('_', ' ')
|
||||||
|
.Replace('-', ' ')
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
||||||
|
|
||||||
|
var displayName = string.Join(' ', words);
|
||||||
|
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
|
|||||||
return true;
|
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)
|
public static bool IsValidConfigFileName(string fileName)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
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)$");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
|
||||||
|
/// On match, sets a ClaimsPrincipal with role "Service".
|
||||||
|
/// On mismatch or absent header, passes through to next middleware (JWT auth).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiKeyMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
|
||||||
|
var apiKey = configuration["NexusApiKey"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey) &&
|
||||||
|
context.Request.Headers.TryGetValue("X-Nexus-Api-Key", out var providedKey) &&
|
||||||
|
string.Equals(apiKey, providedKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "service"),
|
||||||
|
new Claim(ClaimTypes.Name, "ApiService"),
|
||||||
|
new Claim(ClaimTypes.Role, "Service")
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, "ApiKey");
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApiKeyMiddlewareExtensions
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder)
|
||||||
|
=> builder.UseMiddleware<ApiKeyMiddleware>();
|
||||||
|
}
|
||||||
+132
-4
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
|
|||||||
string? Goal = null,
|
string? Goal = null,
|
||||||
string RoleBadge = "badge-slate",
|
string RoleBadge = "badge-slate",
|
||||||
string StatusLabel = "Bereit",
|
string StatusLabel = "Bereit",
|
||||||
|
string StatusKind = "ready",
|
||||||
|
string? StatusDetail = null,
|
||||||
string? Elapsed = null,
|
string? Elapsed = null,
|
||||||
string? Think = null,
|
string? Think = null,
|
||||||
string? Next = null
|
string? Next = null
|
||||||
@@ -86,8 +88,19 @@ public sealed record DashboardTaskDto(
|
|||||||
string State,
|
string State,
|
||||||
string Priority,
|
string Priority,
|
||||||
string? AssignedTo,
|
string? AssignedTo,
|
||||||
|
Guid? ParentTaskId,
|
||||||
|
DateTimeOffset? DueDate,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
DateTimeOffset UpdatedAt
|
DateTimeOffset UpdatedAt,
|
||||||
|
bool IsAgentTask = false,
|
||||||
|
string? ExpectedFrom = null,
|
||||||
|
string? LastActivityMessage = null,
|
||||||
|
DateTimeOffset? LastActivityAt = null,
|
||||||
|
List<DashboardTaskDto>? ChildTasks = null,
|
||||||
|
int ChildTaskCount = 0,
|
||||||
|
int OpenChildTaskCount = 0,
|
||||||
|
bool HasVisibleDelegation = false,
|
||||||
|
int DoneChildTaskCount = 0
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateDashboardTaskRequest(
|
public sealed record CreateDashboardTaskRequest(
|
||||||
@@ -95,7 +108,20 @@ public sealed record CreateDashboardTaskRequest(
|
|||||||
string? Detail,
|
string? Detail,
|
||||||
string? Source,
|
string? Source,
|
||||||
string? Priority,
|
string? Priority,
|
||||||
string? AssignedTo
|
string? AssignedTo,
|
||||||
|
Guid? ParentTaskId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateAgentTaskRequest(
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
string? ExpectedFrom,
|
||||||
|
Guid? ParentTaskId = null,
|
||||||
|
bool StartsInProgress = true,
|
||||||
|
string? InitialState = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateDashboardTaskRequest(
|
public sealed record UpdateDashboardTaskRequest(
|
||||||
@@ -103,7 +129,8 @@ public sealed record UpdateDashboardTaskRequest(
|
|||||||
string? Detail,
|
string? Detail,
|
||||||
string? Source,
|
string? Source,
|
||||||
string? Priority,
|
string? Priority,
|
||||||
string? AssignedTo
|
string? AssignedTo,
|
||||||
|
DateTimeOffset? DueDate = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateDashboardTaskStatusRequest(
|
public sealed record UpdateDashboardTaskStatusRequest(
|
||||||
@@ -112,5 +139,106 @@ public sealed record UpdateDashboardTaskStatusRequest(
|
|||||||
|
|
||||||
public sealed record AgentActivityEntry(
|
public sealed record AgentActivityEntry(
|
||||||
string Time,
|
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 ──
|
||||||
|
|
||||||
|
public sealed record BoardResponse(
|
||||||
|
List<DashboardTaskDto> Offen,
|
||||||
|
List<DashboardTaskDto> InProgress,
|
||||||
|
List<DashboardTaskDto> Review,
|
||||||
|
List<DashboardTaskDto> Blocked,
|
||||||
|
List<DashboardTaskDto> Done
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record MoveTaskRequest(
|
||||||
|
string State
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ResetStaleRequest(
|
||||||
|
int StaleHours = 2
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ResetStaleResponse(
|
||||||
|
int ResetCount
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record PostActivityRequest(
|
||||||
|
string Message,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record RequestChangesRequest(
|
||||||
|
string Comment,
|
||||||
|
string? TargetState = null
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Agent Workflow DTOs ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overview of the agent workflow state, grouping tasks by expected respondent
|
||||||
|
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AgentWorkflowOverview(
|
||||||
|
List<DashboardTaskDto> WaitingForBao,
|
||||||
|
List<DashboardTaskDto> WaitingForIris,
|
||||||
|
List<DashboardTaskDto> WaitingForOthers,
|
||||||
|
List<DashboardTaskDto> StaleTasks,
|
||||||
|
TimeSpan StaleThreshold
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Notification DTOs ──
|
||||||
|
|
||||||
|
public sealed record NotificationDto(
|
||||||
|
Guid Id, string Type, string Title, string? Message,
|
||||||
|
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UnreadCountDto(int Count);
|
||||||
|
|
||||||
|
public sealed record LiveUpdateEnvelope(
|
||||||
|
string Type,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
object Payload,
|
||||||
|
long Sequence,
|
||||||
|
string Channel
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record LiveCursorDto(
|
||||||
|
long Sequence,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
string Mode
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record NotificationSnapshotDto(
|
||||||
|
List<NotificationDto> Notifications,
|
||||||
|
int UnreadCount,
|
||||||
|
string ForUser
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record DashboardLiveSnapshotDto(
|
||||||
|
BoardResponse Board,
|
||||||
|
NotificationSnapshotDto Notifications,
|
||||||
|
LiveCursorDto Cursor
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record DashboardLiveEventDto(
|
||||||
|
LiveUpdateEnvelope Envelope,
|
||||||
|
LiveCursorDto Cursor
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
|
||||||
|
namespace Nexus.Api;
|
||||||
|
|
||||||
|
public class NexusDbContextFactory : IDesignTimeDbContextFactory<NexusDbContext>
|
||||||
|
{
|
||||||
|
public NexusDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<NexusDbContext>();
|
||||||
|
var connectionString = args.Length > 0
|
||||||
|
? args[0]
|
||||||
|
: Environment.GetEnvironmentVariable("ConnectionStrings__Nexus")
|
||||||
|
?? "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus";
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(connectionString);
|
||||||
|
return new NexusDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-222
@@ -1,234 +1,27 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Nexus.Api.Extensions;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Middleware;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Routing;
|
|
||||||
using Nexus.Api.Services;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// --- JWT Configuration ---
|
// --- Service Registration ---
|
||||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
builder.Services.AddNexusAuth(builder.Configuration);
|
||||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
builder.Services.AddNexusRateLimiting();
|
||||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
|
builder.Services.AddNexusForwardedHeaders();
|
||||||
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
builder.Services.AddNexusSwagger();
|
||||||
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||||
|
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddNexusApplicationServices();
|
||||||
.AddJwtBearer(options =>
|
builder.Services.AddNexusRepositories();
|
||||||
{
|
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||||
options.MapInboundClaims = false;
|
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = jwtIssuer,
|
|
||||||
ValidAudience = jwtAudience,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
|
||||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
|
||||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
|
||||||
ClockSkew = TimeSpan.FromSeconds(30)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
builder.Services.AddAntiforgery(options =>
|
|
||||||
{
|
|
||||||
options.HeaderName = "X-CSRF-TOKEN";
|
|
||||||
options.Cookie.Name = "nexus-csrf";
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
||||||
options.Cookie.HttpOnly = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Rate Limiting ---
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 5,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
|
|
||||||
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 30,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Forwarded Headers ---
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
||||||
{
|
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
||||||
options.KnownIPNetworks.Clear();
|
|
||||||
options.KnownProxies.Clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Swagger & JSON ---
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
||||||
|
|
||||||
// --- Database ---
|
|
||||||
builder.Services.AddDbContext<NexusDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
|
||||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
||||||
|
|
||||||
// --- HTTP Clients ---
|
|
||||||
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient("gateway", client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Application Services ---
|
|
||||||
builder.Services.AddTransient<ModelRoutingService>();
|
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
||||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
|
||||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
|
||||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
|
||||||
builder.Services.AddScoped<ITaskService, TaskService>();
|
|
||||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
|
||||||
builder.Services.AddScoped<ITeamService, TeamService>();
|
|
||||||
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
|
||||||
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
|
||||||
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
|
||||||
builder.Services.AddSingleton<IDocService, DocService>();
|
|
||||||
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
|
||||||
|
|
||||||
// --- Repositories ---
|
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
||||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
|
||||||
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
|
||||||
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
|
|
||||||
|
|
||||||
// --- Health Checks ---
|
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
|
||||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
|
||||||
|
|
||||||
// --- Controllers ---
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// --- Database Migration & Owner Seeding ---
|
// --- Database Migration & Seeding ---
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await app.EnsureDatabaseAsync();
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
|
|
||||||
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
|
||||||
var ownerPassword = builder.Configuration["Owner:Password"];
|
|
||||||
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
|
|
||||||
var hasUsers = await db.Users.AnyAsync();
|
|
||||||
|
|
||||||
if (!hasUsers)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
|
||||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
|
||||||
|
|
||||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
|
||||||
? BuildOwnerDisplayName(ownerEmail)
|
|
||||||
: ownerDisplayName;
|
|
||||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
|
||||||
? 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
|
|
||||||
{
|
|
||||||
Email = ownerEmail,
|
|
||||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
|
||||||
DisplayName = initialDisplayName,
|
|
||||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
|
||||||
Role = "owner"
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Middleware Pipeline ---
|
// --- Middleware Pipeline ---
|
||||||
app.UseForwardedHeaders();
|
app.UseNexusPipeline(app.Environment);
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseSecurityHeaders();
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
static string GenerateTemporaryPassword()
|
|
||||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
static string BuildOwnerDisplayName(string email)
|
|
||||||
{
|
|
||||||
var localPart = email.Split('@', 2)[0].Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
|
||||||
|
|
||||||
var words = localPart
|
|
||||||
.Replace('.', ' ')
|
|
||||||
.Replace('_', ' ')
|
|
||||||
.Replace('-', ' ')
|
|
||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
|
||||||
|
|
||||||
var displayName = string.Join(' ', words);
|
|
||||||
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Nexus.Api.RateLimiting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple in-memory tracking of login attempts per IP,
|
||||||
|
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
|
||||||
|
///
|
||||||
|
/// Provides remaining-attempt count that can be passed back to the frontend.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoginAttemptTracker
|
||||||
|
{
|
||||||
|
private const int MaxAttempts = 5;
|
||||||
|
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
// IP → (count, windowStartTicks)
|
||||||
|
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a failed attempt for the given IP.
|
||||||
|
/// Returns remaining attempts (0 = locked out until reset).
|
||||||
|
/// </summary>
|
||||||
|
public int RecordFailedAttempt(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
var (count, windowStart) = _store.AddOrUpdate(ip,
|
||||||
|
_ => (1, now),
|
||||||
|
(_, entry) =>
|
||||||
|
{
|
||||||
|
if (now - entry.WindowStartTicks >= windowTicks)
|
||||||
|
return (1, now);
|
||||||
|
return (entry.Count + 1, entry.WindowStartTicks);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.Max(0, MaxAttempts - count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the remaining attempts for the given IP without recording.
|
||||||
|
/// </summary>
|
||||||
|
public int GetRemaining(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
if (_store.TryGetValue(ip, out var entry))
|
||||||
|
{
|
||||||
|
if (now - entry.WindowStartTicks >= windowTicks)
|
||||||
|
return MaxAttempts;
|
||||||
|
return Math.Max(0, MaxAttempts - entry.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the number of seconds until the rate-limit window resets,
|
||||||
|
/// or 0 if the window has already expired / no attempts recorded.
|
||||||
|
/// </summary>
|
||||||
|
public int GetRetryAfterSeconds(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
if (!_store.TryGetValue(ip, out var entry))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var elapsed = now - entry.WindowStartTicks;
|
||||||
|
if (elapsed >= windowTicks)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets attempt count for the given IP (e.g. on success).
|
||||||
|
/// </summary>
|
||||||
|
public void Reset(string ip)
|
||||||
|
{
|
||||||
|
_store.TryRemove(ip, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,23 @@ using Nexus.Api.Data;
|
|||||||
|
|
||||||
namespace Nexus.Api.Repositories;
|
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)
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = taskIds.Distinct().ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return Task.FromResult(new List<ActivityEvent>());
|
||||||
|
|
||||||
|
return db.Activity.AsNoTracking()
|
||||||
|
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -27,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
|||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
public async 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")
|
var candidateCount = Math.Max(take * 8, 100);
|
||||||
|
var recent = await db.Activity.AsNoTracking()
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
.Take(take)
|
.Take(candidateCount)
|
||||||
.ToListAsync(ct);
|
.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)
|
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);
|
db.Activity.Add(activity);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
liveUpdates.Publish("activity.created", new
|
||||||
|
{
|
||||||
|
activity.Id,
|
||||||
|
activity.Type,
|
||||||
|
activity.Message,
|
||||||
|
activity.TaskId,
|
||||||
|
activity.CreatedAt,
|
||||||
|
agentIds
|
||||||
|
}, "activity");
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
|||||||
public interface IActivityRepository
|
public interface IActivityRepository
|
||||||
{
|
{
|
||||||
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
||||||
|
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
|
||||||
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public interface ITaskRepository
|
|||||||
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||||
Task<WorkTask> AddAsync(WorkTask task, 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 UpdateAsync(WorkTask task, CancellationToken ct = default);
|
||||||
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
||||||
Task<int> CountAsync(CancellationToken ct = default);
|
Task<int> CountAsync(CancellationToken ct = default);
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ public interface IUserRepository
|
|||||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
||||||
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
||||||
|
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
|
||||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
|
||||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -27,9 +27,45 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
|||||||
return task;
|
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)
|
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
db.Tasks.Update(task);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||||
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||||
|
|
||||||
|
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
|
||||||
|
|
||||||
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
||||||
=> db.Users.AnyAsync(ct);
|
=> db.Users.AnyAsync(ct);
|
||||||
|
|
||||||
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Remove refresh tokens first
|
||||||
|
var tokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.UserId == user.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
db.RefreshTokens.RemoveRange(tokens);
|
||||||
|
db.Users.Remove(user);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
||||||
=> db.RefreshTokens
|
=> db.RefreshTokens
|
||||||
.Include(r => r.User)
|
.Include(r => r.User)
|
||||||
|
|||||||
@@ -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;
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
namespace Nexus.Api.Services;
|
namespace Nexus.Api.Services;
|
||||||
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
|
|||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
return null;
|
return null;
|
||||||
|
if (!AllowedFiles.Contains(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
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);
|
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))
|
var fileKind = DetermineFileKind(fileName);
|
||||||
return null;
|
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}";
|
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))
|
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 tempPath = safePath + ".tmp";
|
||||||
|
var backupPath = safePath + ".bak";
|
||||||
|
var backupCreated = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (File.Exists(safePath))
|
||||||
|
{
|
||||||
|
File.Copy(safePath, backupPath, overwrite: true);
|
||||||
|
backupCreated = true;
|
||||||
|
}
|
||||||
await File.WriteAllTextAsync(tempPath, content, ct);
|
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||||
File.Move(tempPath, safePath!, overwrite: true);
|
File.Move(tempPath, safePath!, overwrite: true);
|
||||||
}
|
}
|
||||||
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var fi = new FileInfo(safePath!);
|
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; }
|
public string? AgentDir { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public string? Model { get; init; }
|
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||||
|
public AgentModelConfig? Model { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("identity")]
|
[JsonPropertyName("identity")]
|
||||||
public AgentIdentityConfig? Identity { get; init; }
|
public AgentIdentityConfig? Identity { get; init; }
|
||||||
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
|
|||||||
public string Theme { get; init; } = string.Empty;
|
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(
|
public sealed record AgentInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -73,6 +128,7 @@ public interface IAgentService
|
|||||||
{
|
{
|
||||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
||||||
@@ -93,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
var agents = new List<AgentInfo>(configs.Count);
|
var agents = new List<AgentInfo>(configs.Count);
|
||||||
foreach (var config in configs)
|
foreach (var config in configs)
|
||||||
{
|
{
|
||||||
var model = config.Model ?? "deepseek/deepseek-v4-flash";
|
var model = ResolveModel(config);
|
||||||
var role = DeriveRole(config.Id);
|
var role = DeriveRole(config.Id);
|
||||||
var description = config.Identity?.Theme ?? string.Empty;
|
var description = config.Identity?.Theme ?? string.Empty;
|
||||||
|
|
||||||
@@ -140,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
Id: config.Id,
|
Id: config.Id,
|
||||||
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
||||||
Role: role,
|
Role: role,
|
||||||
Model: config.Model ?? "deepseek/deepseek-v4-flash",
|
Model: ResolveModel(config),
|
||||||
Status: runtimeStatus.Status,
|
Status: runtimeStatus.Status,
|
||||||
LastSeen: now,
|
LastSeen: now,
|
||||||
Workspace: config.Workspace,
|
Workspace: config.Workspace,
|
||||||
@@ -151,33 +207,48 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var configs = await LoadAgentConfigsAsync(cancellationToken);
|
||||||
|
return configs
|
||||||
|
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
||||||
|
.Select(config => config.Id.Trim().ToLowerInvariant())
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"iris" => "Orchestrator",
|
"iris" => "Orchestrator",
|
||||||
|
"product-owner" => "Product Owner",
|
||||||
"programmer" => "Developer",
|
"programmer" => "Developer",
|
||||||
|
"programmer-fast" => "Developer",
|
||||||
"reviewer" => "Reviewer",
|
"reviewer" => "Reviewer",
|
||||||
"architekt" => "Architect",
|
"architekt" => "Architect",
|
||||||
"main" => "Assistant",
|
"main" => "Assistant",
|
||||||
_ => "Custom"
|
_ => "Custom"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string ResolveModel(AgentConfig config)
|
||||||
|
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
|
||||||
|
|
||||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/etc/nexus/agents-sanitized.json";
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
||||||
var root = document.RootElement;
|
var root = document.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("agents", out var agentsElement))
|
if (!root.TryGetProperty("agents", out var agentsElement))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
if (!agentsElement.TryGetProperty("list", out var listElement))
|
if (!agentsElement.TryGetProperty("list", out var listElement))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
||||||
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
||||||
@@ -193,29 +264,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
// Inherit defaults for missing fields
|
// Inherit defaults for missing fields
|
||||||
if (string.IsNullOrWhiteSpace(config.Name))
|
if (string.IsNullOrWhiteSpace(config.Name))
|
||||||
config = config with { Name = config.Id };
|
config = config with { Name = config.Id };
|
||||||
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
|
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
|
||||||
config = config with { Model = defaults.Model.Primary };
|
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
|
||||||
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
||||||
config = config with { Workspace = defaults.Workspace };
|
config = config with { Workspace = defaults.Workspace };
|
||||||
|
|
||||||
configs.Add(config);
|
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
|
private sealed record AgentDefaults
|
||||||
{
|
{
|
||||||
[JsonPropertyName("workspace")]
|
[JsonPropertyName("workspace")]
|
||||||
public string? Workspace { get; init; }
|
public string? Workspace { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public AgentDefaultModel? Model { get; init; }
|
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||||
}
|
public AgentModelConfig? Model { get; init; }
|
||||||
|
|
||||||
private sealed record AgentDefaultModel
|
|
||||||
{
|
|
||||||
[JsonPropertyName("primary")]
|
|
||||||
public string? Primary { get; init; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
|
|||||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
user.UpdatedAt = 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);
|
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
||||||
return await CreateSessionAsync(user, Guid.NewGuid(), null, 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)
|
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
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 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
|
public interface IAgentConfigService
|
||||||
{
|
{
|
||||||
|
const int MaxConfigFileBytes = 500 * 1024;
|
||||||
|
|
||||||
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||||
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
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<ChatResponse> SendChatAsync(string agentId, string message);
|
||||||
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||||
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||||
|
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
|
||||||
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||||
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface ILiveUpdateService
|
||||||
|
{
|
||||||
|
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
|
||||||
|
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
|
||||||
|
long CurrentSequence { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LiveUpdateSubscription
|
||||||
|
{
|
||||||
|
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
|
||||||
|
public long StartingSequence { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||||
|
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
|
||||||
|
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
|
||||||
|
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
|
|||||||
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||||
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||||
Task<List<QueueItem>> GetQueueAsync();
|
Task<List<QueueItem>> GetQueueAsync();
|
||||||
|
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
|
||||||
Task<bool> DeleteCronJobAsync(string id);
|
Task<bool> DeleteCronJobAsync(string id);
|
||||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IStaleTaskRecoveryService
|
||||||
|
{
|
||||||
|
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
|
||||||
|
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
|
||||||
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Structured backend bridge for agent/task commands.
|
||||||
|
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
|
||||||
|
/// with the task board, activity log, and delegation workflow.
|
||||||
|
///
|
||||||
|
/// This is the internal service layer — never exposed directly to the browser.
|
||||||
|
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITaskBridgeService
|
||||||
|
{
|
||||||
|
// ── Task CRUD (Agent-Commands) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new top-level task (parent or standalone).
|
||||||
|
/// Returns the created task DTO.
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||||
|
string title,
|
||||||
|
string? detail = null,
|
||||||
|
string? source = "iris",
|
||||||
|
string? priority = "Normal",
|
||||||
|
string? assignedTo = null,
|
||||||
|
Guid? projectId = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a child task linked to an existing parent.
|
||||||
|
/// This is the primary delegation command: iris creates a child task,
|
||||||
|
/// assigns it to a sub-agent, and tracks it on the board.
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||||
|
Guid parentTaskId,
|
||||||
|
string title,
|
||||||
|
string? detail = null,
|
||||||
|
string? source = "iris",
|
||||||
|
string? priority = "Normal",
|
||||||
|
string? assignedTo = null,
|
||||||
|
string? expectedFrom = null,
|
||||||
|
bool startsInProgress = false,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the status/state of a task.
|
||||||
|
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string state,
|
||||||
|
string? callerAgent = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends an activity entry to a task (comment, status note, agent note).
|
||||||
|
/// Used by agents to annotate their progress on the board.
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string message,
|
||||||
|
string? type = "comment",
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles a task handoff: sets ExpectedFrom to the target agent,
|
||||||
|
/// appends a handoff activity entry, and optionally updates assigned-to.
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string targetAgent,
|
||||||
|
string? note = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
// ── Query (Read) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the full task board state (grouped by status column).
|
||||||
|
/// </summary>
|
||||||
|
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a single task by ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||||
|
Guid taskId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all child tasks for a given parent task.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||||
|
Guid parentTaskId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns task activity history.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||||
|
Guid taskId,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
|
||||||
|
// ── Agent Workflow ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the agent-workflow overview: who is expected to respond,
|
||||||
|
/// stale tasks, workload distribution.
|
||||||
|
/// </summary>
|
||||||
|
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||||
|
TimeSpan? staleThreshold = null,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result pattern for task-bridge operations.
|
||||||
|
/// WorkTask? is null on NotFound; state is stored in the Outcome.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record TaskBridgeResult<T>(
|
||||||
|
TaskBridgeOutcome Outcome,
|
||||||
|
T? Data = default,
|
||||||
|
string? Error = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public enum TaskBridgeOutcome
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
NotFound,
|
||||||
|
InvalidState,
|
||||||
|
Unauthorized,
|
||||||
|
ValidationError
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
namespace Nexus.Api.Services;
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
@@ -21,9 +22,27 @@ public interface ITaskService
|
|||||||
|
|
||||||
// Dashboard-facing task operations
|
// Dashboard-facing task operations
|
||||||
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||||
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, 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> 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> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Task Board
|
||||||
|
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
|
||||||
|
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||||
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||||
|
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
|
||||||
|
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||||
|
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Agent Workflow Overview
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
|
||||||
|
Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class LiveUpdateService : ILiveUpdateService
|
||||||
|
{
|
||||||
|
private const int ReplayLimit = 256;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
|
||||||
|
private readonly object _historyLock = new();
|
||||||
|
private readonly Queue<LiveUpdateEnvelope> _history = new();
|
||||||
|
private long _sequence;
|
||||||
|
|
||||||
|
public long CurrentSequence => Interlocked.Read(ref _sequence);
|
||||||
|
|
||||||
|
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
AllowSynchronousContinuations = false
|
||||||
|
});
|
||||||
|
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
_subscribers[id] = channel;
|
||||||
|
|
||||||
|
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
|
||||||
|
foreach (var envelope in replay)
|
||||||
|
{
|
||||||
|
channel.Writer.TryWrite(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
ct.Register(() =>
|
||||||
|
{
|
||||||
|
if (_subscribers.TryRemove(id, out var removed))
|
||||||
|
{
|
||||||
|
removed.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Task.FromResult(new LiveUpdateSubscription
|
||||||
|
{
|
||||||
|
Reader = channel.Reader,
|
||||||
|
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||||
|
{
|
||||||
|
var envelope = new LiveUpdateEnvelope(
|
||||||
|
type,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
payload,
|
||||||
|
Interlocked.Increment(ref _sequence),
|
||||||
|
channel);
|
||||||
|
|
||||||
|
lock (_historyLock)
|
||||||
|
{
|
||||||
|
_history.Enqueue(envelope);
|
||||||
|
while (_history.Count > ReplayLimit)
|
||||||
|
{
|
||||||
|
_history.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (id, subscriber) in _subscribers)
|
||||||
|
{
|
||||||
|
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
|
||||||
|
{
|
||||||
|
removed.Writer.TryComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
|
||||||
|
{
|
||||||
|
lock (_historyLock)
|
||||||
|
{
|
||||||
|
return _history.Where(item => item.Sequence > afterSequence).ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService
|
||||||
|
{
|
||||||
|
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = new Notification
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
ForUser = forUser.ToLowerInvariant(),
|
||||||
|
TaskId = taskId
|
||||||
|
};
|
||||||
|
db.Notifications.Add(notification);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = db.Notifications
|
||||||
|
.Where(n => n.ForUser == forUser.ToLowerInvariant());
|
||||||
|
|
||||||
|
if (unreadOnly)
|
||||||
|
query = query.Where(n => !n.IsRead);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = await db.Notifications.FindAsync([id], ct);
|
||||||
|
if (notification is null) return false;
|
||||||
|
|
||||||
|
notification.IsRead = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var normalizedUser = forUser.ToLowerInvariant();
|
||||||
|
var count = await db.Notifications
|
||||||
|
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
|
||||||
|
await PublishSnapshotAsync(normalizedUser, ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await db.Notifications
|
||||||
|
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var normalizedUser = forUser.ToLowerInvariant();
|
||||||
|
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
|
||||||
|
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
|
||||||
|
return new NotificationSnapshotDto(
|
||||||
|
notifications.Select(MapToDto).ToList(),
|
||||||
|
unreadCount,
|
||||||
|
normalizedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
|
||||||
|
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationDto MapToDto(Notification n) => new(
|
||||||
|
n.Id, n.Type, n.Title, n.Message,
|
||||||
|
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
|
|||||||
|
|
||||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
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()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -115,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load agent IDs from openclaw.json config
|
// Load agent IDs from sanitized agents config (no secrets)
|
||||||
var agentIds = LoadAgentIdsFromConfig();
|
var agentIds = LoadAgentIdsFromConfig();
|
||||||
|
|
||||||
var agents = new List<DashboardAgentInfo>();
|
var agents = new List<DashboardAgentInfo>();
|
||||||
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
// 3. Extract activity from session_status
|
// 3. Extract activity from session_status
|
||||||
var isActive = false;
|
var isActive = false;
|
||||||
string? currentTask = null;
|
string? currentTask = null;
|
||||||
|
var statusText = status?["status"]?.GetValue<string>();
|
||||||
if (status is not null)
|
if (status is not null)
|
||||||
{
|
{
|
||||||
// Check explicit isActive field
|
// Check explicit isActive field
|
||||||
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Fall back to status text
|
// Fall back to status text
|
||||||
var statusText = status["status"]?.GetValue<string>();
|
|
||||||
if (!isActive && statusText is not null)
|
if (!isActive && statusText is not null)
|
||||||
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(statusText, "running", 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
|
// 8. Calculate workload from queue items
|
||||||
var workload = CalculateAgentWorkload(id, queueItems);
|
var workload = CalculateAgentWorkload(id, queueItems);
|
||||||
|
|
||||||
|
var statusKind = DeriveStatusKind(status, isActive);
|
||||||
|
var statusDetail = DeriveStatusDetail(status, statusKind);
|
||||||
|
|
||||||
agents.Add(new DashboardAgentInfo(
|
agents.Add(new DashboardAgentInfo(
|
||||||
Id: id,
|
Id: id,
|
||||||
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||||
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
Workload: workload,
|
Workload: workload,
|
||||||
Goal: goal,
|
Goal: goal,
|
||||||
RoleBadge: DeriveRoleBadge(id),
|
RoleBadge: DeriveRoleBadge(id),
|
||||||
StatusLabel: DeriveStatusLabel(isActive, status),
|
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
|
||||||
|
StatusKind: statusKind,
|
||||||
|
StatusDetail: statusDetail,
|
||||||
Elapsed: FormatElapsed(status),
|
Elapsed: FormatElapsed(status),
|
||||||
Think: null,
|
Think: null,
|
||||||
Next: DeriveNext(isActive, currentTask)
|
Next: DeriveNext(isActive, currentTask)
|
||||||
@@ -214,7 +227,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
|
/// Loads agent IDs from the sanitized agents config (no secrets).
|
||||||
/// Falls back to the known list if the config file is unavailable.
|
/// Falls back to the known list if the config file is unavailable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<string> LoadAgentIdsFromConfig()
|
private List<string> LoadAgentIdsFromConfig()
|
||||||
@@ -222,7 +235,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/etc/nexus/agents-sanitized.json";
|
||||||
|
|
||||||
if (!System.IO.File.Exists(configPath))
|
if (!System.IO.File.Exists(configPath))
|
||||||
return GetDefaultAgentIds();
|
return GetDefaultAgentIds();
|
||||||
@@ -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)
|
public async Task<bool> DeleteCronJobAsync(string id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Truncate content to first 200 chars for compact display
|
// Truncate content to first 200 chars for compact display
|
||||||
var text = msg.Content.Length > 200
|
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
|
||||||
? msg.Content[..200] + "…"
|
var text = redacted.Length > 200
|
||||||
: msg.Content;
|
? redacted[..200] + "…"
|
||||||
|
: redacted;
|
||||||
var ts = ParseTimestamp(msg.Timestamp);
|
var ts = ParseTimestamp(msg.Timestamp);
|
||||||
var timeAgo = FormatTimeAgo(ts);
|
var timeAgo = FormatTimeAgo(ts);
|
||||||
|
|
||||||
entries.Add(new AgentActivityEntry(timeAgo, text));
|
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -1005,7 +1085,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/etc/nexus/agents-sanitized.json";
|
||||||
|
|
||||||
if (!System.IO.File.Exists(configPath))
|
if (!System.IO.File.Exists(configPath))
|
||||||
return GetDefaultModels();
|
return GetDefaultModels();
|
||||||
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
_ => "badge-slate"
|
_ => "badge-slate"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
|
||||||
{
|
{
|
||||||
if (!isActive) return "Bereit";
|
return statusKind switch
|
||||||
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
{
|
||||||
return statusText switch
|
"connected" => isActive ? "Arbeitet" : "Verbunden",
|
||||||
|
"thinking" => "Plant",
|
||||||
|
"blocked" => "Blockiert",
|
||||||
|
"stale" => "Stale",
|
||||||
|
"error" => "Fehler",
|
||||||
|
"unsupported" => "Unsupported",
|
||||||
|
"ready" => "Bereit",
|
||||||
|
_ => statusText?.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"thinking" or "think" => "Plant",
|
"thinking" or "think" => "Plant",
|
||||||
"blocked" or "block" => "Blockiert",
|
"blocked" or "block" => "Blockiert",
|
||||||
_ => "Arbeitet"
|
_ => 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)
|
private static string? FormatElapsed(JsonNode? status)
|
||||||
{
|
{
|
||||||
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
var lastActivity = TryGetStatusTimestamp(status);
|
||||||
?? status?["lastMessage"]?.GetValue<string>();
|
|
||||||
if (lastActivity is null) return null;
|
if (lastActivity is null) return null;
|
||||||
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||||
var diff = DateTimeOffset.UtcNow - ts;
|
|
||||||
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||||
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
"main" => "Assistant",
|
"main" => "Assistant",
|
||||||
_ => "Custom"
|
_ => "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 flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
||||||
|
if (flaggedCount > 0)
|
||||||
|
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
||||||
|
}
|
||||||
|
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> RunWatchdogOnceAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
|
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
||||||
|
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class StaleTaskRecoveryOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "TaskRecovery";
|
||||||
|
|
||||||
|
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
|
||||||
|
public int StalledMinutes { get; set; } = 40;
|
||||||
|
|
||||||
|
/// <summary>Prüfintervall des Watchdogs.</summary>
|
||||||
|
public int IntervalMinutes { get; set; } = 10;
|
||||||
|
|
||||||
|
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
|
||||||
|
public int StaleHours { get; set; } = 2;
|
||||||
|
|
||||||
|
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
|
||||||
|
|
||||||
|
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||||
|
|
||||||
|
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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,
|
||||||
|
INotificationService notificationService) : IStaleTaskRecoveryService
|
||||||
|
{
|
||||||
|
private const string StalledActivityType = "stalled";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
|
||||||
|
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
|
||||||
|
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
|
||||||
|
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
|
||||||
|
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var threshold = now - stalledThreshold;
|
||||||
|
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||||
|
|
||||||
|
var inProgress = allTasks
|
||||||
|
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
if (inProgress.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
|
||||||
|
var activityByTask = activities
|
||||||
|
.Where(a => a.TaskId.HasValue)
|
||||||
|
.GroupBy(a => a.TaskId!.Value)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
|
||||||
|
|
||||||
|
var flaggedCount = 0;
|
||||||
|
|
||||||
|
foreach (var task in inProgress)
|
||||||
|
{
|
||||||
|
activityByTask.TryGetValue(task.Id, out var taskActivity);
|
||||||
|
var latest = taskActivity?.FirstOrDefault();
|
||||||
|
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
|
||||||
|
|
||||||
|
if (lastProgressAt >= threshold)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
|
||||||
|
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var silentFor = now - lastProgressAt;
|
||||||
|
await activityRepository.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = StalledActivityType,
|
||||||
|
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_stalled",
|
||||||
|
$"Task hängt: {task.Title}",
|
||||||
|
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
flaggedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flaggedCount > 0)
|
||||||
|
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||||
|
|
||||||
|
return flaggedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
|
||||||
|
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
|
||||||
|
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
|
||||||
|
/// </summary>
|
||||||
|
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 activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
||||||
|
return TaskService.BuildMasterBoard(allTasks, activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
if (duration.TotalHours >= 1)
|
||||||
|
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
||||||
|
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Concrete implementation of ITaskBridgeService.
|
||||||
|
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
|
||||||
|
/// into structured, predictable commands for agent-facing usage.
|
||||||
|
///
|
||||||
|
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
|
||||||
|
/// making agent consumption safe and debuggable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TaskBridgeService(
|
||||||
|
ITaskService taskService,
|
||||||
|
IAgentService agentService,
|
||||||
|
IActivityRepository activityRepo,
|
||||||
|
INotificationService notificationService,
|
||||||
|
ILiveUpdateService liveUpdateService) : ITaskBridgeService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ValidStates =
|
||||||
|
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// ──────────────────────────────── Create Task ────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||||
|
string title,
|
||||||
|
string? detail = null,
|
||||||
|
string? source = "iris",
|
||||||
|
string? priority = "Normal",
|
||||||
|
string? assignedTo = null,
|
||||||
|
Guid? projectId = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||||
|
|
||||||
|
var normalizedSource = NormalizeSource(source);
|
||||||
|
|
||||||
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
|
title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
|
||||||
|
|
||||||
|
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
|
return Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Create Child Task ──────────────────────────
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||||
|
Guid parentTaskId,
|
||||||
|
string title,
|
||||||
|
string? detail = null,
|
||||||
|
string? source = "iris",
|
||||||
|
string? priority = "Normal",
|
||||||
|
string? assignedTo = null,
|
||||||
|
string? expectedFrom = null,
|
||||||
|
bool startsInProgress = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||||
|
|
||||||
|
// Verify parent exists
|
||||||
|
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
|
||||||
|
if (parent is null)
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
|
||||||
|
|
||||||
|
var task = await taskService.CreateAgentTaskAsync(
|
||||||
|
title.Trim(), detail?.Trim(), NormalizeSource(source),
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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 = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
|
return Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Update Status ──────────────────────────────
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string state,
|
||||||
|
string? callerAgent = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!ValidStates.Contains(state))
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
|
||||||
|
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
|
||||||
|
|
||||||
|
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||||
|
if (task is null)
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||||
|
|
||||||
|
// Check authorization
|
||||||
|
if (!TaskStateHelper.CanChangeState(callerAgent, task))
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
|
||||||
|
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
|
||||||
|
|
||||||
|
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
|
||||||
|
if (result.Outcome != TaskOperationOutcome.Success)
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
|
||||||
|
|
||||||
|
var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
|
||||||
|
return Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Append Activity ────────────────────────────
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string message,
|
||||||
|
string? type = "comment",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message))
|
||||||
|
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
|
||||||
|
|
||||||
|
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||||
|
if (task is null)
|
||||||
|
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||||
|
|
||||||
|
var ev = new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = type ?? "comment",
|
||||||
|
Message = message.Trim(),
|
||||||
|
TaskId = taskId
|
||||||
|
};
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(ev, ct);
|
||||||
|
|
||||||
|
// Trigger live update so the board refreshes
|
||||||
|
var board = await taskService.GetBoardAsync(ct);
|
||||||
|
liveUpdateService.Publish("tasks.board.snapshot", board);
|
||||||
|
|
||||||
|
return Success(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Handoff ────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||||
|
Guid taskId,
|
||||||
|
string targetAgent,
|
||||||
|
string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(targetAgent))
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
|
||||||
|
|
||||||
|
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||||
|
if (task is null)
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||||
|
|
||||||
|
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()}";
|
||||||
|
|
||||||
|
// Update expected-from and optionally assigned-to
|
||||||
|
task.ExpectedFrom = normalizedTarget;
|
||||||
|
|
||||||
|
// If this is a child task (has parent), keep assigned-to on the child
|
||||||
|
// If standalone, set assigned-to to the target
|
||||||
|
if (!task.ParentTaskId.HasValue)
|
||||||
|
task.AssignedTo = normalizedTarget;
|
||||||
|
|
||||||
|
await taskService.UpdateDashboardTaskAsync(
|
||||||
|
taskId, title: null, detail: null, source: null,
|
||||||
|
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
|
||||||
|
|
||||||
|
// Append handoff activity
|
||||||
|
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
|
||||||
|
|
||||||
|
// Notify the target
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_assigned",
|
||||||
|
$"Handoff: {task.Title}",
|
||||||
|
handoffNote,
|
||||||
|
normalizedTarget,
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
|
return Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Query ──────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||||
|
=> await taskService.GetBoardAsync(ct);
|
||||||
|
|
||||||
|
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||||
|
Guid taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
|
||||||
|
return dto is null
|
||||||
|
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
|
||||||
|
: Success(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||||
|
Guid parentTaskId, CancellationToken ct = default)
|
||||||
|
=> await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
|
||||||
|
|
||||||
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||||
|
Guid taskId, CancellationToken ct = default)
|
||||||
|
=> await taskService.GetTaskActivityAsync(taskId, ct);
|
||||||
|
|
||||||
|
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||||
|
TimeSpan? staleThreshold = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
|
||||||
|
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private static TaskBridgeResult<T> Success<T>(T data) =>
|
||||||
|
new(TaskBridgeOutcome.Success, data);
|
||||||
|
|
||||||
|
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
|
||||||
|
new(outcome, Data: default, Error: error);
|
||||||
|
|
||||||
|
private static string NormalizeSource(string? source) =>
|
||||||
|
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
||||||
|
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
t.IsAgentTask, t.ExpectedFrom);
|
||||||
|
}
|
||||||
+607
-31
@@ -1,12 +1,18 @@
|
|||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
namespace Nexus.Api.Services;
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
public sealed class TaskService(
|
public sealed class TaskService(
|
||||||
ITaskRepository taskRepo,
|
ITaskRepository taskRepo,
|
||||||
IActivityRepository activityRepo) : ITaskService
|
IActivityRepository activityRepo,
|
||||||
|
INotificationService notificationService,
|
||||||
|
IAgentService agentService,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ILiveUpdateService liveUpdateService,
|
||||||
|
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
=> await taskRepo.GetAllAsync(ct);
|
=> await taskRepo.GetAllAsync(ct);
|
||||||
@@ -14,6 +20,16 @@ public sealed class TaskService(
|
|||||||
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
=> await taskRepo.GetByIdAsync(id, ct);
|
=> await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
|
var task = allTasks.FirstOrDefault(t => t.Id == id);
|
||||||
|
if (task is null) return null;
|
||||||
|
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
|
||||||
|
return MapToDtoWithChildren(task, allTasks, activity);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
=> await taskRepo.GetPendingApprovalAsync(ct);
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||||
|
|
||||||
@@ -26,7 +42,8 @@ public sealed class TaskService(
|
|||||||
ProjectId = request.ProjectId
|
ProjectId = request.ProjectId
|
||||||
};
|
};
|
||||||
await taskRepo.AddAsync(task, ct);
|
await taskRepo.AddAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +57,8 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +72,8 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +85,11 @@ public sealed class TaskService(
|
|||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
task.State = canonical;
|
var caller = ResolveCaller();
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
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)
|
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||||
@@ -77,15 +97,28 @@ public sealed class TaskService(
|
|||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
var changes = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\"");
|
||||||
task.Title = request.Title.Trim();
|
task.Title = request.Title.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}");
|
||||||
task.Priority = request.Priority.Trim();
|
task.Priority = request.Priority.Trim();
|
||||||
|
}
|
||||||
if (request.ProjectId.HasValue)
|
if (request.ProjectId.HasValue)
|
||||||
|
{
|
||||||
|
changes.Add("Projekt-ID geändert");
|
||||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,13 +130,12 @@ public sealed class TaskService(
|
|||||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
await taskRepo.DeleteAsync(task, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success);
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard-facing operations ──
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var all = await taskRepo.GetAllAsync(ct);
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
@@ -112,36 +144,213 @@ public sealed class TaskService(
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WorkTask> CreateDashboardTaskAsync(
|
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
|
||||||
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all
|
||||||
|
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
|
||||||
|
.ThenByDescending(t => t.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
|
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
|
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
|
||||||
|
|
||||||
|
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
|
||||||
|
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
|
||||||
|
|
||||||
|
var waitingForBao = map(agentTasks
|
||||||
|
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
var waitingForIris = map(agentTasks
|
||||||
|
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
var waitingForOthers = map(agentTasks
|
||||||
|
.Where(t =>
|
||||||
|
{
|
||||||
|
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
|
||||||
|
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}));
|
||||||
|
|
||||||
|
var staleTasks = map(agentTasks
|
||||||
|
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
|
||||||
|
|
||||||
|
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||||
|
string title, string? detail, string? source, string? priority,
|
||||||
|
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (parentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
|
||||||
|
if (parent is null)
|
||||||
|
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||||
|
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
|
||||||
|
var isVisibleDelegation = parentTaskId.HasValue;
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = new WorkTask
|
||||||
{
|
{
|
||||||
Title = title.Trim(),
|
Title = title.Trim(),
|
||||||
Detail = detail?.Trim(),
|
Detail = detail?.Trim(),
|
||||||
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
|
Source = normalizedSource,
|
||||||
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||||
AssignedTo = assignedTo?.Trim()
|
AssignedTo = normalizedAssignee,
|
||||||
|
ParentTaskId = parentTaskId,
|
||||||
|
IsAgentTask = isVisibleDelegation
|
||||||
};
|
};
|
||||||
await taskRepo.AddAsync(task, ct);
|
await taskRepo.AddAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
|
|
||||||
|
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
|
||||||
|
if (parentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "delegation",
|
||||||
|
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
|
||||||
|
TaskId = parentTaskId.Value
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
|
||||||
|
|
||||||
|
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_assigned",
|
||||||
|
$"Neue Aufgabe: {task.Title}",
|
||||||
|
detail,
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async 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)
|
||||||
|
{
|
||||||
|
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 = ResolveInitialAgentTaskState(startsInProgress, initialState);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "agent_task",
|
||||||
|
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (parentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "delegation",
|
||||||
|
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
|
||||||
|
TaskId = parentTaskId.Value
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"agent_task_created",
|
||||||
|
$"Neuer Agent-Task: {task.Title}",
|
||||||
|
detail,
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||||
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
Guid id, string? title, string? detail, string? source,
|
||||||
|
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var caller = ResolveCaller();
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
var changes = new List<string>();
|
||||||
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
|
||||||
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
{
|
||||||
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
|
changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
|
||||||
|
task.Title = title.Trim();
|
||||||
|
}
|
||||||
|
if (detail is not null)
|
||||||
|
{
|
||||||
|
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||||
|
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add("Beschreibung aktualisiert");
|
||||||
|
task.Detail = newDetail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(source))
|
||||||
|
task.Source = source.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
|
||||||
|
task.Priority = priority.Trim();
|
||||||
|
}
|
||||||
|
if (assignedTo is not null)
|
||||||
|
{
|
||||||
|
var validated = await NormalizeActorAsync(assignedTo, ct);
|
||||||
|
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
|
||||||
|
task.AssignedTo = validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
|
||||||
|
{
|
||||||
|
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
|
||||||
|
task.DueDate = dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
|
|
||||||
|
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "task",
|
||||||
|
Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
if (changes.Count > 0 && caller == "bao")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_content_changed",
|
||||||
|
$"Bao hat \"{task.Title}\" geändert",
|
||||||
|
changeSummary,
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +362,29 @@ public sealed class TaskService(
|
|||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||||
task.State = canonical;
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
}
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, 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 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)
|
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||||
@@ -167,7 +394,8 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
task.State = "Done";
|
task.State = "Done";
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +413,355 @@ public sealed class TaskService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||||
|
return BuildMasterBoard(all, activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||||
|
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||||
|
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||||
|
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||||
|
/// damit nichts unsichtbar wird.
|
||||||
|
/// </summary>
|
||||||
|
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||||
|
{
|
||||||
|
var ids = all.Select(t => t.Id).ToHashSet();
|
||||||
|
|
||||||
|
var offen = 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 all)
|
||||||
|
{
|
||||||
|
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||||
|
if (!isMaster) continue;
|
||||||
|
|
||||||
|
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||||
|
switch (task.State.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "backlog": offen.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: offen.Add(dto); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offen.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
inProgress.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
review.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
done.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
|
||||||
|
return new BoardResponse(offen, inProgress, review, blocked, done);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var board = await GetBoardAsync(ct);
|
||||||
|
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? TaskStateHelper.BoardGroupToState(newState);
|
||||||
|
if (canonical is null)
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.ExpectedFrom = null;
|
||||||
|
return await UpdateTaskStatusInternalAsync(
|
||||||
|
task,
|
||||||
|
TaskStateHelper.ToStateString(TaskState.Done),
|
||||||
|
caller,
|
||||||
|
"review",
|
||||||
|
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||||
|
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||||
|
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||||
|
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||||
|
|
||||||
|
var trimmed = comment.Trim();
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "review_changes_requested",
|
||||||
|
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
task.ExpectedFrom = "iris";
|
||||||
|
var result = await UpdateTaskStatusInternalAsync(
|
||||||
|
task, target, caller, "review",
|
||||||
|
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||||
|
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_changes_requested",
|
||||||
|
$"Änderung angefordert: {task.Title}",
|
||||||
|
trimmed,
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var normalizedHours = Math.Max(1, staleHours);
|
||||||
|
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
|
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||||
|
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||||
|
return all.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
|
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||||
|
return all.Where(e => e.TaskId == taskId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||||
|
{
|
||||||
|
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||||
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||||
|
var childDtos = includeChildren
|
||||||
|
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||||
|
: null;
|
||||||
|
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||||
|
return dto with
|
||||||
|
{
|
||||||
|
ChildTasks = childDtos,
|
||||||
|
ChildTaskCount = childTasks.Count,
|
||||||
|
OpenChildTaskCount = openChildTaskCount,
|
||||||
|
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||||
|
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
t.IsAgentTask, t.ExpectedFrom);
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
|
||||||
|
{
|
||||||
|
var last = activity
|
||||||
|
.Where(e => e.TaskId == t.Id)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return new DashboardTaskDto(
|
||||||
|
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,
|
||||||
|
last?.Message,
|
||||||
|
last?.CreatedAt,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
t.ParentTaskId.HasValue || t.IsAgentTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext is null) return "nexus-system";
|
||||||
|
|
||||||
|
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
|
return agentHeader.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var user = httpContext.User;
|
||||||
|
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
return nameClaim?.ToLowerInvariant() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
|
||||||
|
WorkTask task,
|
||||||
|
string canonical,
|
||||||
|
string actor,
|
||||||
|
string activityType,
|
||||||
|
string? activityMessage,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
"task_review",
|
||||||
|
$"Task zur Überprüfung: {task.Title}",
|
||||||
|
$"Status auf Review geändert von {caller}",
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_blocked",
|
||||||
|
$"Aufgabe blockiert: {task.Title}",
|
||||||
|
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (caller == "bao")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_status_changed",
|
||||||
|
$"Bao hat Status geändert: {task.Title}",
|
||||||
|
$"Status → {canonical}",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else if (caller == "iris")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_status_changed",
|
||||||
|
$"Iris hat Status geändert: {task.Title}",
|
||||||
|
$"Status → {canonical}",
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"Integrations": {
|
"Integrations": {
|
||||||
"OpenClaw": {
|
"OpenClaw": {
|
||||||
"BaseUrl": "http://127.0.0.1:18789",
|
"BaseUrl": "http://127.0.0.1:18789",
|
||||||
|
"RequiredVersion": "",
|
||||||
"Token": "",
|
"Token": "",
|
||||||
"Password": ""
|
"Password": ""
|
||||||
},
|
},
|
||||||
@@ -21,5 +22,10 @@
|
|||||||
"AccessTokenExpirationMinutes": 15,
|
"AccessTokenExpirationMinutes": 15,
|
||||||
"RefreshTokenExpirationDays": 7
|
"RefreshTokenExpirationDays": 7
|
||||||
},
|
},
|
||||||
|
"TaskRecovery": {
|
||||||
|
"StalledMinutes": 40,
|
||||||
|
"IntervalMinutes": 10,
|
||||||
|
"StaleHours": 2
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-31
@@ -1,10 +1,25 @@
|
|||||||
name: nexus
|
name: nexus
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
|
||||||
|
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
|
||||||
|
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
|
||||||
|
command:
|
||||||
|
- postgres
|
||||||
|
- -c
|
||||||
|
- archive_mode=off
|
||||||
|
- -c
|
||||||
|
- archive_command=
|
||||||
|
restart: always
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384M
|
||||||
|
reservations:
|
||||||
|
memory: 96M
|
||||||
environment:
|
environment:
|
||||||
|
POSTGRES_INITDB_ARGS: --data-checksums
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
@@ -22,17 +37,19 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
args:
|
||||||
|
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
|
||||||
|
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
|
||||||
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
resources:
|
||||||
condition: on-failure
|
limits:
|
||||||
delay: 5s
|
memory: 512M
|
||||||
max_attempts: 3
|
reservations:
|
||||||
window: 120s
|
memory: 128M
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -40,32 +57,34 @@ services:
|
|||||||
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
||||||
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
||||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||||
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
|
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||||
Owner__Password: ${OWNER_PASSWORD:-}
|
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||||
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
Admin__ResetToken: ${Admin__ResetToken:-}
|
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||||
|
NexusApiKey: ${NEXUS_API_KEY:-}
|
||||||
|
AgentConfigPath: /etc/nexus/agents-sanitized.json
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
- /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/etc/nexus/agents-sanitized.json:ro
|
||||||
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||||
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||||
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||||
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
||||||
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
||||||
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
||||||
networks:
|
networks:
|
||||||
- nexus
|
- nexus
|
||||||
- openclaw_default
|
- openclaw_default
|
||||||
@@ -74,39 +93,50 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
args:
|
||||||
|
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
|
||||||
|
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
|
||||||
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
resources:
|
||||||
condition: on-failure
|
limits:
|
||||||
delay: 5s
|
memory: 128M
|
||||||
max_attempts: 3
|
reservations:
|
||||||
window: 120s
|
memory: 32M
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
||||||
|
- "traefik.http.routers.nexus.tls=true"
|
||||||
|
- "traefik.http.routers.nexus.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.nexus.loadbalancer.server.port=80"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
restart: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks: [nexus]
|
networks:
|
||||||
|
- nexus
|
||||||
|
- proxy
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nexus:
|
nexus:
|
||||||
openclaw_default:
|
openclaw_default:
|
||||||
external: true
|
external: true
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
volumes:
|
volumes:
|
||||||
nexus-postgres:
|
nexus-postgres:
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# Nexus Board-First Orchestration & Sichere OpenClaw-Integration
|
||||||
|
|
||||||
|
> Gegenprüfung: Architekt, 2026-06-22
|
||||||
|
> Gegenstand: Sicherster Pfad für Board-first-Agent-Orchestrierung und MCP-artige/strukturierte
|
||||||
|
> OpenClaw-Integration im Nexus-Backend
|
||||||
|
> Kein Frontend-direkter MCP-Pfad. Kein Deploy.
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
### 1.1 Prüfergebnis
|
||||||
|
|
||||||
|
Der eingeschlagene Pfad ist **architektonisch korrekt und sicher**. Das Board-first-Modell mit
|
||||||
|
Nexus-Backend als zentraler Brücke zwischen Benutzer, Board und OpenClaw-Gateway ist der richtige
|
||||||
|
Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren.
|
||||||
|
|
||||||
|
### 1.2 Kernbewertung
|
||||||
|
|
||||||
|
| Aspekt | Status | Bewertung |
|
||||||
|
|--------|--------|-----------|
|
||||||
|
| Board-first Architektur | ✅ Umsetzung läuft | Parent/Child-Modell korrekt implementiert |
|
||||||
|
| Kein Frontend-direkter Gateway-Zugriff | ✅ Eingehalten | Frontend spricht NUR mit Nexus-Backend |
|
||||||
|
| Backend als sichere Brücke | ✅ Eingehalten | API-Container proxyt alle Gateway-Calls |
|
||||||
|
| Auth-/Rechte-Modell | ✅ Solide | JWT + ApiKey + X-Agent-Id Enforcement |
|
||||||
|
| Gateway-Security | ⚠️ Verbesserbar | `loopback`-Bind muss auf `lan` für Docker |
|
||||||
|
| Migration-Reihenfolge | 📋 Vorgeschlagen | (siehe Abschnitt 8) |
|
||||||
|
|
||||||
|
## 2. Ist-Architektur: Wer spricht mit wem?
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser (Bao/Iris) │
|
||||||
|
│ auth.ts → JWT Access Token (15m) + HttpOnly Refresh Cookie │
|
||||||
|
│ api.ts → fetch /api/v1/* → Authorization: Bearer <JWT> │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS :443 (Traefik/npm)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nexus web (nginx container) │
|
||||||
|
│ location /api/ → proxy_pass http://api:8080 │
|
||||||
|
│ location / → SPA (index.html) │
|
||||||
|
│ CSP: connect-src 'self' — kein externer Gateway-Call möglich │
|
||||||
|
└───────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTP :8080 (internal network)
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Nexus API (.NET 10 Container) ← SICHERE BRÜCKE │
|
||||||
|
│ │
|
||||||
|
│ Auth-Middleware (JWT → Controller) │
|
||||||
|
│ ApiKey-Middleware (X-Nexus-Api-Key → Role=Service) │
|
||||||
|
│ SecurityHeaders-Middleware (HSTS, CSP, XFO) │
|
||||||
|
│ Rate Limiter (auth: 5/min/caller, agents: 30/min/caller) │
|
||||||
|
│ │
|
||||||
|
│ IOpenClawGatewayClient │
|
||||||
|
│ ├─ InvokeToolAsync("session_status", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_list", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_history", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("memory_search", ...) │
|
||||||
|
│ ├─ InvokeToolAsync("sessions_send", ...) │
|
||||||
|
│ └─ InvokeToolAsync("cron", ...) │
|
||||||
|
│ │
|
||||||
|
│ GatewayBridgeController (/api/bridge/*) <- NEU (2026-06-22) │
|
||||||
|
│ └─ ITaskBridgeService │
|
||||||
|
│ create_task / create_child_task / update_status / │
|
||||||
|
│ append_activity / handoff / get_board / get_agent_overview │
|
||||||
|
│ │
|
||||||
|
│ IAgentRuntime (OpenClawRuntime) │
|
||||||
|
│ └─ POST /v1/chat/completions (OpenAI-compat) │
|
||||||
|
│ │
|
||||||
|
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||||
|
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||||
|
│ openclaw-gateway-bao:18789 │
|
||||||
|
│ (internes Docker-DNS) │
|
||||||
|
▼ │
|
||||||
|
┌──────────────────────────────┐ │
|
||||||
|
│ OpenClaw Gateway Container │ │
|
||||||
|
│ Port 18789 │ │
|
||||||
|
│ Auth: password │ │
|
||||||
|
│ Bind: loopback (127.0.0.1) │ │
|
||||||
|
│ │ │
|
||||||
|
│ HTTP Deny-List (default): │ │
|
||||||
|
│ exec, spawn, shell, │ │
|
||||||
|
│ fs_write, fs_delete, │ │
|
||||||
|
│ fs_move, apply_patch, │ │
|
||||||
|
│ sessions_spawn, sessions_send│ │
|
||||||
|
│ cron, gateway, nodes, │ │
|
||||||
|
│ whatsapp_login │ │
|
||||||
|
└───────────────────────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 Wichtig: Das Frontend sieht den Gateway NICHT
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ─── [Nexus API] ─── Gateway
|
||||||
|
│
|
||||||
|
+─ Gateway-Passwort lebt NUR im Backend
|
||||||
|
+─ CSP: connect-src 'self' blockiert jeden Direktzugriff
|
||||||
|
+─ Gateway HTTP Deny-List blockiert alle RCE-Tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Das ist die korrekte Architektur.** Kein Frontend-komponenten-direkter MCP-Pfad existiert
|
||||||
|
und keiner sollte eingeführt werden.
|
||||||
|
|
||||||
|
## 3. Board-First Orchestrierung: Ist-Stand & Bewertung
|
||||||
|
|
||||||
|
### 3.1 Das Parent/Child-Task-Modell (Phase 3)
|
||||||
|
|
||||||
|
```
|
||||||
|
Parent-Task (Owner: Iris)
|
||||||
|
├── Child-Task A (AssignedTo: programmer)
|
||||||
|
├── Child-Task B (AssignedTo: reviewer)
|
||||||
|
└── Child-Task C (AssignedTo: architekt)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status:** ✅ Implementiert (2026-06-21)
|
||||||
|
|
||||||
|
**Datenmodell (WorkTask):**
|
||||||
|
- `ParentTaskId` (Guid?) — verknüpft Child mit Parent
|
||||||
|
- `IsAgentTask` (bool) — markiert programmatisch erstellte Agent-Tasks
|
||||||
|
- `ExpectedFrom` (string?) — wer als nächstes antworten soll
|
||||||
|
- `AssignedTo` (string?) — operativer Owner der Task
|
||||||
|
|
||||||
|
**State Machine:**
|
||||||
|
```
|
||||||
|
Parent: Backlog → InProgress → Review → Done
|
||||||
|
↘ Blocked → Backlog
|
||||||
|
|
||||||
|
Child: Backlog → InProgress → Done
|
||||||
|
↘ Blocked → Backlog
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules (aus TaskStateHelper.CanChangeState):**
|
||||||
|
- **Nur Iris & Bao** dürfen State-Änderungen vornehmen
|
||||||
|
- Sub-Agenten (programmer, reviewer, architekt, researcher, executor) **niemals**
|
||||||
|
- `nexus-system` als technischer Fallback für Cron/Reset-Stale
|
||||||
|
|
||||||
|
### 3.2 Bewertung: Architektonisch korrekt
|
||||||
|
|
||||||
|
| Kriterium | Bewertung | Begründung |
|
||||||
|
|-----------|-----------|------------|
|
||||||
|
| Board als Single Source of Truth | ✅ | Task Board = sichtbare Aufgabenwahrheit |
|
||||||
|
| Delegation sichtbar | ✅ | Child-Tasks statt unsichtbarem Delegations-Status |
|
||||||
|
| Feingliedrige Berechtigung | ✅ | State-Change nur durch Iris/Bao |
|
||||||
|
| Agenten arbeiten gegen Child-Tasks | ✅ | Klare Ownership durch `AssignedTo` |
|
||||||
|
| Parent bleibt bei Iris | ✅ | Parent in `InProgress` während Koordination |
|
||||||
|
| Review-Gate für Bao | ✅ | Parent erst in `Review`, dann Bao-Entscheidung |
|
||||||
|
|
||||||
|
### 3.3 Offene Punkte im Board-Modell
|
||||||
|
|
||||||
|
1. **Keine automatische Child-Task-Erstellung** — Das Board-Modell setzt voraus, dass Iris manuell
|
||||||
|
Child-Tasks anlegt. Ein strukturierter Workflow für automatische Child-Task-Erstellung bei
|
||||||
|
`spawn`/Subagent-Aufrufen fehlt.
|
||||||
|
|
||||||
|
2. **Keine Task→Session-Verknüpfung** — Es gibt keine direkte Verknüpfung zwischen einer
|
||||||
|
Child-Task und der OpenClaw-Subagent-Session, die sie bearbeitet. Der `AgentService` kennt
|
||||||
|
Sessions, `TaskService` kennt Tasks — aber sie sind nicht verknüpft.
|
||||||
|
|
||||||
|
3. **Reset-Stale ist ungeschützt** — `POST /api/v1/tasks/reset-stale` hat `[AllowAnonymous]`
|
||||||
|
und kann von jedem aufgerufen werden (siehe Risiko #1).
|
||||||
|
|
||||||
|
## 4. Auth- und Rechte-Modell
|
||||||
|
|
||||||
|
### 4.1 Authentifizierungsebenen
|
||||||
|
|
||||||
|
```
|
||||||
|
Ebene 1: Browser-JWT (Access Token 15m, Refresh Token HttpOnly Cookie)
|
||||||
|
Ebene 2: X-Nexus-Api-Key (Service-zu-Service, Role=Service)
|
||||||
|
Ebene 3: Gateway-Password (Backend → Gateway, Bearer Authorization)
|
||||||
|
Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Berechtigungsmatrix
|
||||||
|
|
||||||
|
| Aktion | Bao | Iris | Sub-Agent | nexus-system | Service (ApiKey) |
|
||||||
|
|--------|-----|------|-----------|--------------|-------------------|
|
||||||
|
| Task State ändern | ✅ | ✅ | ❌ | ✅ (intern) | ❌ |
|
||||||
|
| Task Inhalt editieren | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Agent-Config lesen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||||
|
| Gateway-Tool aufrufen | ❌ | ❌ | ❌ | ❌ | ✅ (intern) |
|
||||||
|
| Dashboard-Metriken sehen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
### 4.3 Bewertung
|
||||||
|
|
||||||
|
**Positiv:**
|
||||||
|
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
|
||||||
|
- Refresh-Token-Reuse-Detection verhindert Token-Theft
|
||||||
|
- Rate-Limiting auf Login und Refresh
|
||||||
|
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
|
||||||
|
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
|
||||||
|
|
||||||
|
**Kritisch:**
|
||||||
|
- `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` — siehe Risiko-Analyse
|
||||||
|
- Kein Scoped-ApiKey — der `X-Nexus-Api-Key` gibt volle Service-Rechte
|
||||||
|
- Keine Audit-Protokollierung für ApiKey-Nutzung
|
||||||
|
|
||||||
|
## 5. Gateway-Integration: Sicherheitsanalyse
|
||||||
|
|
||||||
|
### 5.1 Tool-Invoke-Pfad (Ist-Stand)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/operations/snapshot
|
||||||
|
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||||
|
→ POST http://openclaw-gateway-bao:18789/tools/invoke
|
||||||
|
Authorization: Bearer <Gateway-Password>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aufgerufene Tools (durch Nexus-Backend):**
|
||||||
|
- `session_status` — Agent-Status abfragen (read-only)
|
||||||
|
- `sessions_list` — Session-Liste (read-only)
|
||||||
|
- `sessions_history` — Chat-Verlauf (read-only)
|
||||||
|
- `memory_search` — Memory-Suche (read-only)
|
||||||
|
- `sessions_send` — Chat-Nachricht senden (write, aber kontrolliert)
|
||||||
|
- `cron` — Cron-Jobs verwalten (write)
|
||||||
|
|
||||||
|
**Gateway HTTP Deny-List blockiert:**
|
||||||
|
- Alle Exec-Tools (exec, spawn, shell)
|
||||||
|
- Alle Filesystem-Tools (fs_write, fs_delete, fs_move, apply_patch)
|
||||||
|
- Gateway-Control-Plane (gateway)
|
||||||
|
- Node-Relay (nodes)
|
||||||
|
- Session-Orchestrierung (sessions_spawn, sessions_send)
|
||||||
|
|
||||||
|
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||||
|
|
||||||
|
**Aktueller Stand (2026-07-09):**
|
||||||
|
```
|
||||||
|
compose.yaml:
|
||||||
|
api:
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
|
networks:
|
||||||
|
- nexus
|
||||||
|
- openclaw_default
|
||||||
|
|
||||||
|
Gateway-Konfiguration:
|
||||||
|
gateway.bind: "lan"
|
||||||
|
|
||||||
|
Nexus-Konfiguration:
|
||||||
|
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ergebnis:**
|
||||||
|
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
|
||||||
|
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
|
||||||
|
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
|
||||||
|
|
||||||
|
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
|
||||||
|
|
||||||
|
Produktive Einstellung:
|
||||||
|
```yaml
|
||||||
|
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
|
||||||
|
```
|
||||||
|
Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
|
||||||
|
|
||||||
|
### 5.3 MCP-artige Integration: Bewertung
|
||||||
|
|
||||||
|
**Das Gateway `/tools/invoke` ist bereits MCP-artig:**
|
||||||
|
- JSON-RPC-ähnliche Aufrufe mit `tool` + `args` + `sessionKey`
|
||||||
|
- Strukturierte Responses mit `{ ok, result, error }`
|
||||||
|
- Tool-Discovery via Policy (Deny/Allow-List)
|
||||||
|
- Request/Response mit eindeutiger Fehlersemantik
|
||||||
|
|
||||||
|
**Was fehlt für ein vollständiges MCP-Interface:**
|
||||||
|
- Keine Tool-Listing/Discovery über API (kein `tools/list`)
|
||||||
|
- Keine Schema-Validierung für Tool-Arguments
|
||||||
|
- Keine Structured Outputs (function-calling-ähnliches Format)
|
||||||
|
|
||||||
|
**Empfehlung: NICHT ein MCP-Protokoll zwischen Nexus und Gateway einführen.**
|
||||||
|
Stattdessen den bestehenden `/tools/invoke`-Pfad weiter nutzen und strukturieren.
|
||||||
|
|
||||||
|
### 5.4 Neue Backend-Bridge (Implementiert 2026-06-22)
|
||||||
|
|
||||||
|
Der Nexus-eigene strukturierte Kommando-Adapter wurde eingeführt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nexus Backend
|
||||||
|
├─ GatewayToolClient (bestehender OpenClawGatewayClient)
|
||||||
|
│ └─ POST /tools/invoke (Gateway)
|
||||||
|
│
|
||||||
|
├─ GatewayBridgeController (NEU — /api/bridge/)
|
||||||
|
│ ├─ POST /api/bridge/tasks (create_task)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/children (create_child_task)
|
||||||
|
│ ├─ PATCH /api/bridge/tasks/{id}/status (update_status)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/activity (append_activity)
|
||||||
|
│ ├─ POST /api/bridge/tasks/{id}/handoff (handoff)
|
||||||
|
│ ├─ GET /api/bridge/board (get_board)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id} (get_task)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id}/children (get_children)
|
||||||
|
│ ├─ GET /api/bridge/tasks/{id}/activity (get_activity)
|
||||||
|
│ └─ GET /api/bridge/agent-overview (get_agent_overview)
|
||||||
|
│
|
||||||
|
├─ ITaskBridgeService / TaskBridgeService (NEU)
|
||||||
|
│ └─ Typisierte TaskBridgeResult<T> mit Outcome: Success/NotFound/InvalidState/Unauthorized/ValidationError
|
||||||
|
│
|
||||||
|
├─ BoardOrchestrationService (offen)
|
||||||
|
│ ├─ Tasks erstellen/aktualisieren
|
||||||
|
│ ├─ Session-Status abfragen
|
||||||
|
│ └─ Agent-Progress berechnen
|
||||||
|
│
|
||||||
|
└─ AgentDelegationService (offen)
|
||||||
|
├─ Subagent-Task anlegen
|
||||||
|
├─ Session verfolgen
|
||||||
|
└─ Ergebnis integrieren
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth-Modell für /api/bridge:**
|
||||||
|
- Primär: `X-Agent-Id` Header (Agent-Identität vom Gateway)
|
||||||
|
- Fallback: JWT (Browser-authenticated user → bao)
|
||||||
|
- Fallback: `X-Nexus-Api-Key` (Backend-zu-Backend Service-Identität)
|
||||||
|
- Rate-Limiting: 30 Requests/Minute (agents-Policy)
|
||||||
|
|
||||||
|
**Das Frontend sieht /api/bridge NICHT.** Der Pfad ist ausschließlich für Agent-zu-Backend-Kommunikation.
|
||||||
|
|
||||||
|
## 6. Risikoanalyse
|
||||||
|
|
||||||
|
### 6.1 KRITISCH — `[AllowAnonymous]` auf Board-Endpunkten ✅ BEHOBEN (2026-06-22)
|
||||||
|
|
||||||
|
**Betroffen (vorher):**
|
||||||
|
```csharp
|
||||||
|
// TasksController.cs
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("board")] // Gab ALLE Tasks zurück — inkl. Detail-Texte
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("reset-stale")] // Konnte Tasks zurücksetzen — datenändernd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix angewandt:**
|
||||||
|
- `[AllowAnonymous]` entfernt
|
||||||
|
- Inline-Auth-Check: `X-Agent-Id` Header, ApiKey-Rolle, oder JWT erforderlich
|
||||||
|
- `reset-stale` erfordert zusätzlich `X-Agent-Id: iris` Identität (nur Iris darf)
|
||||||
|
- Neuer `/api/bridge/` Pfad als sauberer Agent-zu-Backend-Adapter
|
||||||
|
- Rate-Limiting (agents-Policy: 30/min) auf Bridge-Endpunkte
|
||||||
|
|
||||||
|
### 6.2 MITTEL — Keine Task→Session-Verknüpfung
|
||||||
|
|
||||||
|
Wenn ein Subagent eine Child-Task bearbeitet, gibt es keine technische Verknüpfung zwischen
|
||||||
|
der Child-Task und der OpenClaw-Session. Das bedeutet:
|
||||||
|
- Keine automatische Status-Aktualisierung bei Session-Abschluss
|
||||||
|
- Keine Sitzungs-Historie direkt von der Task aus erreichbar
|
||||||
|
- Iris muss manuell prüfen, ob ein Agent fertig ist
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- `WorkTask` um `SessionKey` (string?) erweitern
|
||||||
|
- Bei Child-Task-Erstellung Session-Key speichern
|
||||||
|
- Status-Polling: Wenn Session inaktiv, Child-Task auf Done/Blocked prüfen
|
||||||
|
|
||||||
|
### 6.3 MITTEL — Gateway-Passwort in Config-Dateien
|
||||||
|
|
||||||
|
Das Gateway-Passwort `ieDmOjBiVfbbDM0ibrEebPAg` ist:
|
||||||
|
- In `.env` auf dem Host (OK)
|
||||||
|
- In `gateway-api-research.md` (maskiert: `ieDm...PAg`)
|
||||||
|
- Im `openclaw.json` auf dem Host (OK)
|
||||||
|
- In der API-Container-Umgebungsvariable (notwendig)
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- Gateway-Rate-Limiting aktiv halten (10 attempts/60s → 5min lockout)
|
||||||
|
- Gateway-Bind auf `lan` ändern (nicht öffentlich exponiert)
|
||||||
|
- `gateway-api-research.md` aus dem öffentlichen Repo entfernen oder Passwort entfernen
|
||||||
|
|
||||||
|
### 6.4 NIEDRIG — Keine Scoped API-Keys
|
||||||
|
|
||||||
|
Der `X-Nexus-Api-Key` gibt volle Service-Rechte. Es gibt keine Möglichkeit, verschiedene
|
||||||
|
API-Keys mit unterschiedlichen Rechten zu vergeben.
|
||||||
|
|
||||||
|
**Empfehlung (spätere Phase):**
|
||||||
|
- API-Key-Scopes einführen (read, write, admin)
|
||||||
|
- Rate-Limiting pro API-Key
|
||||||
|
- Audit-Log für ApiKey-Nutzung
|
||||||
|
|
||||||
|
### 6.5 NIEDRIG — Kein Structured Output vom Gateway
|
||||||
|
|
||||||
|
Die `/tools/invoke`-Responses sind JSON, aber ohne Schema-Garantie. Die Backend-Logik
|
||||||
|
extrahiert Felder mit vielen Fallbacks (`??`-Kettenantworten).
|
||||||
|
|
||||||
|
**Empfehlung:**
|
||||||
|
- Response-Typen für jedes Tool definieren (DTOs)
|
||||||
|
- Deserialisierung mit Schema-Validierung
|
||||||
|
- Fallback-Logik zentralisieren
|
||||||
|
|
||||||
|
## 7. Migrationsreihenfolge (Vorschlag)
|
||||||
|
|
||||||
|
### Phase 3b — Sichere Board-Grundlage (JETZT)
|
||||||
|
```
|
||||||
|
1. [AllowAnonymous] auf /tasks/board und /tasks/reset-stale fixen
|
||||||
|
→ ApiKey-Auth plus X-Agent-Id-Validierung
|
||||||
|
→ README/Iris-Doku aktualisieren
|
||||||
|
|
||||||
|
2. Gateway-Bind von loopback auf lan ändern
|
||||||
|
→ API-Container über openclaw_default Netzwerk ansprechen
|
||||||
|
→ host.docker.internal-Fallback entfernen
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4 — Strukturierte Orchestrierung
|
||||||
|
```
|
||||||
|
3. Task→Session-Verknüpfung einführen
|
||||||
|
→ WorkTask.SessionKey (string?)
|
||||||
|
→ Bei Child-Task-Erstellung Session speichern
|
||||||
|
|
||||||
|
4. AgentDelegationService
|
||||||
|
→ Zentralisierte Subagent-Task-Erstellung
|
||||||
|
→ Session-Status-Monitoring
|
||||||
|
→ Automatische Status-Propagation (Session done → Task done)
|
||||||
|
|
||||||
|
5. BoardOrchestrationService
|
||||||
|
→ Refresh-Intervall für Agent-Progress
|
||||||
|
→ Stale-Erkennung mit Session-Status
|
||||||
|
→ Priorisierung nach Workload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 5 — Erweiterte Integration
|
||||||
|
```
|
||||||
|
6. Structured Tool Responses
|
||||||
|
→ DTOs für jedes Gateway-Tool
|
||||||
|
→ Schema-Validierung
|
||||||
|
→ Caching für häufige Abfragen
|
||||||
|
|
||||||
|
7. API-Key-Scopes
|
||||||
|
→ read/write/admin Scopes
|
||||||
|
→ Audit-Log für ApiKey-Nutzung
|
||||||
|
|
||||||
|
8. Automatische Child-Task-Erstellung
|
||||||
|
→ Bei spawn/subagent-Aufrufen automatisch Child-Task anlegen
|
||||||
|
→ Session-Key verknüpfen
|
||||||
|
→ Activity-Feed erweitern
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Sichere Brücke: Architekturprinzipien
|
||||||
|
|
||||||
|
### 8.1 Das Backend ist die einzige Brücke
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ←→ Nexus API ←→ OpenClaw Gateway
|
||||||
|
↑ ↑
|
||||||
|
JWT Auth Gateway Password
|
||||||
|
(pro User) (nur im Backend)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Niemals:**
|
||||||
|
- Gateway-Passwort im Frontend
|
||||||
|
- Direkter Browser→Gateway API-Call
|
||||||
|
- MCP-Protokoll zwischen Frontend und Gateway
|
||||||
|
- Agent-Sessions direkt aus dem Frontend steuern
|
||||||
|
|
||||||
|
### 8.2 Prinzipien für jede neue Integration
|
||||||
|
|
||||||
|
1. **Neue Endpunkte immer im Nexus-Backend**
|
||||||
|
2. **Auth über bestehendes JWT/ApiKey-System**
|
||||||
|
3. **Gateway-Calls immer serverseitig mit Gateway-Passwort**
|
||||||
|
4. **Kein Gateway-Tool direkt aus dem Frontend aufrufen**
|
||||||
|
5. **State-Änderungen nur durch Iris/Bao (via Backend-Enforcement)**
|
||||||
|
6. **Activity/Audit für jede State-Änderung**
|
||||||
|
|
||||||
|
### 8.3 Strukturierte OpenClaw-Integration (MCP-artig)
|
||||||
|
|
||||||
|
Der Gateway `/tools/invoke`-Endpunkt ist bereits strukturell MCP-artig. Eine formale
|
||||||
|
MCP-Implementierung zwischen Nexus und Gateway ist **nicht notwendig**. Stattdessen:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nexus.Backend.Services
|
||||||
|
├── IOpenClawGatewayClient (Gateway-Tool-Abstraktion)
|
||||||
|
│ └── InvokeToolAsync(tool, args) → JsonNode?
|
||||||
|
│
|
||||||
|
├── IBoardOrchestrationService (NEU)
|
||||||
|
│ ├── CreateDelegateTask(agentId, title, detail) → WorkTask
|
||||||
|
│ ├── WatchAgentSession(agentId) → SessionWatcher
|
||||||
|
│ └── SyncAgentProgress() → Progress[]
|
||||||
|
│
|
||||||
|
└── IAgentDelegationService (NEU)
|
||||||
|
├── DelegateToAgent(parentTaskId, agentId, instruction)
|
||||||
|
├── CollectResult(subTaskId) → AgentResult
|
||||||
|
└── HandleBlocker(subTaskId, reason) → void
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Zusammenfassung
|
||||||
|
|
||||||
|
### Was gut ist (nicht ändern):
|
||||||
|
- Board-first-Ansatz mit Parent/Child-Tasks
|
||||||
|
- Backend als einzige Gateway-Brücke
|
||||||
|
- JWT + Gateway-Password-Trennung
|
||||||
|
- State-Change-Restriktion auf Iris/Bao
|
||||||
|
- HTTP Deny-List des Gateways
|
||||||
|
- CSP im Frontend (kein externer Connect)
|
||||||
|
|
||||||
|
### Was verbessert werden muss:
|
||||||
|
- `[AllowAnonymous]` auf Board-Endpunkten → ApiKey-Auth
|
||||||
|
- Gateway-Bind loopback → lan (oder Netzwerk-Routing korrigieren)
|
||||||
|
- Task→Session-Verknüpfung fehlt
|
||||||
|
|
||||||
|
### Was später kommen kann:
|
||||||
|
- Automatische Child-Task-Erstellung bei Subagent-Aufrufen
|
||||||
|
- Structured Gateway Responses mit Schema-Validierung
|
||||||
|
- API-Key-Scopes und Audit
|
||||||
|
- Session-gesteuertes Progress-Tracking
|
||||||
|
|
||||||
|
### Was niemals kommen darf:
|
||||||
|
- Gateway-Passwort im Frontend
|
||||||
|
- Direkter MCP-Pfad Browser→Gateway
|
||||||
|
- Agent-Session-Steuerung aus dem Frontend
|
||||||
|
- Task-State-Änderung durch Sub-Agenten
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# Gateway API Research
|
# Gateway API Research
|
||||||
|
|
||||||
> Generated: 2026-06-10
|
> Generated: 2026-06-10 | Updated: 2026-06-22
|
||||||
> Auth mode: password (not token)
|
> Auth mode: password (not token)
|
||||||
|
>
|
||||||
|
> ⚠️ **Security note:** Diese Datei enthält Infrastruktur-Details zur Gateway-Integration.
|
||||||
|
> Sie gehört nicht ins öffentliche Repository. Bis zur Bereinigung: Gateway-Passwort
|
||||||
|
> maskiert als `ieDm...PAg`. Vollständige Architektur-Analyse in
|
||||||
|
> [`architecture-board-first-orchestration.md`](architecture-board-first-orchestration.md).
|
||||||
|
|
||||||
## 1. Authentication
|
## 1. Authentication
|
||||||
|
|
||||||
@@ -285,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
api:
|
api:
|
||||||
extra_hosts:
|
|
||||||
- host.docker.internal:host-gateway
|
|
||||||
environment:
|
environment:
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
|
networks:
|
||||||
|
- nexus
|
||||||
|
- openclaw_default
|
||||||
```
|
```
|
||||||
|
|
||||||
The API container:
|
The API container:
|
||||||
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
|
||||||
- Has `extra_hosts` configured for `host.docker.internal`
|
- Does not depend on a published host port for the Gateway
|
||||||
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
||||||
|
|
||||||
### Known Issue: Gateway Bind = loopback
|
### Resolved Routing Issue (2026-07-09)
|
||||||
|
|
||||||
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
|
The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
|
||||||
|
|
||||||
| Scenario | Works? | Why |
|
| Scenario | Works? | Why |
|
||||||
|----------|--------|-----|
|
|----------|--------|-----|
|
||||||
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
|
||||||
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
|
||||||
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
|
||||||
|
|
||||||
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
|
The Gateway must listen on its container interface (`gateway.bind: "lan"`):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -320,8 +325,8 @@ The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it onl
|
|||||||
|
|
||||||
**Test command (from Nexus API container):**
|
**Test command (from Nexus API container):**
|
||||||
```bash
|
```bash
|
||||||
curl -s http://host.docker.internal:18789/health
|
curl -s http://openclaw-gateway-bao:18789/
|
||||||
# Expected: 200 if gateway bind is lan/container IP is reachable
|
# Expected: HTTP 200 from inside nexus-api-1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Required .env Vars for Nexus
|
### Required .env Vars for Nexus
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
# OpenClaw ↔ Nexus Task Board Flow
|
||||||
|
|
||||||
|
> Letzte Aktualisierung: 2026-06-21
|
||||||
|
> Status: kanonische Arbeitsbeschreibung für Iris, Sub-Agenten und das Nexus Task Board
|
||||||
|
|
||||||
|
Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
|
||||||
|
|
||||||
|
- **Bao** als Auftraggeber
|
||||||
|
- **Iris** als Chief of Staff / Koordinatorin
|
||||||
|
- **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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Kurzfassung
|
||||||
|
|
||||||
|
**Eine Hauptaufgabe gehört Iris.**
|
||||||
|
Wenn Iris Arbeit delegiert, wird diese Delegation **nicht unsichtbar im Chat** geführt, sondern als **sichtbare Child-Task** im Nexus Task Board angelegt.
|
||||||
|
|
||||||
|
Das bedeutet:
|
||||||
|
|
||||||
|
- **Parent-Task** = Verantwortung von Iris
|
||||||
|
- **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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Die Hauptidee
|
||||||
|
|
||||||
|
Früher war Delegation leicht unsichtbar oder lief über einen separaten `Delegated`-Status.
|
||||||
|
|
||||||
|
Der neue Flow ersetzt das durch:
|
||||||
|
|
||||||
|
1. **Iris übernimmt eine Parent-Task**
|
||||||
|
2. **Iris zerlegt die Arbeit bei Bedarf in Subtasks**
|
||||||
|
3. **Jede echte Delegation wird als Child-Task auf dem Board angelegt**
|
||||||
|
4. **Der zuständige Agent arbeitet gegen diese Child-Task**
|
||||||
|
5. **Iris integriert die Ergebnisse zurück in die Parent-Task**
|
||||||
|
6. **Erst wenn alles fertig ist, geht die Parent-Task in Review**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Systembild
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Bao[Bao\nAuftraggeber]
|
||||||
|
Iris[Iris\nChief of Staff]
|
||||||
|
Board[Nexus Task Board\nParent + Child Tasks]
|
||||||
|
OC[OpenClaw Runtime]
|
||||||
|
Agents[Sub-Agenten\nDeveloper / Reviewer / Architekt / ...]
|
||||||
|
|
||||||
|
Bao -->|Auftrag / Priorisierung| Iris
|
||||||
|
Iris -->|legt Parent-Task an / übernimmt Task| Board
|
||||||
|
Iris -->|delegiert konkrete Arbeit| OC
|
||||||
|
OC -->|führt Agenten-Task aus| Agents
|
||||||
|
Iris -->|legt Child-Tasks an| Board
|
||||||
|
Agents -->|MCP Tools /mcp| Board
|
||||||
|
Agents -->|liefern Ergebnis / melden Blocker| Iris
|
||||||
|
Iris -->|integriert Ergebnis| Board
|
||||||
|
Board -->|Review für Bao| Bao
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rollen und Verantwortlichkeiten
|
||||||
|
|
||||||
|
### Bao
|
||||||
|
- gibt Aufgaben inhaltlich vor
|
||||||
|
- priorisiert und nimmt fertige Arbeit ab
|
||||||
|
- verschiebt fertige Hauptaufgaben aus **Review** nach **Done** oder zurück
|
||||||
|
|
||||||
|
### Iris
|
||||||
|
- übernimmt die Parent-Task
|
||||||
|
- analysiert, zerlegt, delegiert und reviewed
|
||||||
|
- hält die Hauptaufgabe auf dem Board aktuell
|
||||||
|
- erstellt sichtbare Child-Tasks für delegierte Arbeit
|
||||||
|
- entscheidet, ob etwas **In Progress**, **Blocked** oder **Review** ist
|
||||||
|
|
||||||
|
### Sub-Agenten
|
||||||
|
- arbeiten **nicht** direkt gegen eine diffuse Hauptaufgabe
|
||||||
|
- arbeiten gegen eine **konkret zugewiesene Child-Task**
|
||||||
|
- melden Fortschritt, Ergebnisse und Blocker an Iris
|
||||||
|
|
||||||
|
### OpenClaw
|
||||||
|
- führt die Agentenarbeit technisch aus
|
||||||
|
- 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
|
||||||
|
- dokumentiert den tatsächlichen Arbeitsfluss
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Parent-Task vs. Child-Task
|
||||||
|
|
||||||
|
| Ebene | Zweck | Owner | Sichtbarkeit |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Parent-Task | Hauptauftrag / Koordination | Iris | Board |
|
||||||
|
| Child-Task | Delegierter Arbeitsblock | zuständiger Agent | Board |
|
||||||
|
|
||||||
|
### Parent-Task-Regeln
|
||||||
|
- bleibt bei Iris
|
||||||
|
- bleibt in der Regel **In Progress**, solange Koordination läuft
|
||||||
|
- geht erst auf **Review**, wenn alle nötigen Child-Tasks erledigt und integriert sind
|
||||||
|
- geht nur auf **Blocked**, wenn Iris insgesamt nicht weiterkommt
|
||||||
|
|
||||||
|
### Child-Task-Regeln
|
||||||
|
- repräsentiert eine echte delegierte Teilaufgabe
|
||||||
|
- hat klare Ownership (`AssignedTo`)
|
||||||
|
- zeigt sichtbar, welcher Agent woran arbeitet
|
||||||
|
- wird nicht für triviale Mini-Schritte missbraucht
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Zustandsmodell
|
||||||
|
|
||||||
|
### Parent-Task-Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Backlog
|
||||||
|
Backlog --> InProgress: Iris übernimmt
|
||||||
|
InProgress --> InProgress: Child-Tasks anlegen / koordinieren
|
||||||
|
InProgress --> Blocked: Gesamtblocker
|
||||||
|
InProgress --> Review: alles integriert
|
||||||
|
Review --> Done: Bao nimmt ab
|
||||||
|
Review --> Backlog: Bao gibt zurück
|
||||||
|
Blocked --> Backlog: Blocker gelöst
|
||||||
|
```
|
||||||
|
|
||||||
|
### Child-Task-Lifecycle
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Backlog
|
||||||
|
Backlog --> InProgress: Agent startet
|
||||||
|
InProgress --> Done: Ergebnis geliefert
|
||||||
|
InProgress --> Blocked: Agent kommt nicht weiter
|
||||||
|
Blocked --> Backlog: neu geplant / entsperrt
|
||||||
|
Blocked --> InProgress: Iris stößt Weiterarbeit an
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Der konkrete Arbeitsablauf
|
||||||
|
|
||||||
|
### Fall A: Bao gibt Iris einen neuen Auftrag
|
||||||
|
|
||||||
|
1. Bao formuliert einen Auftrag
|
||||||
|
2. Iris prüft Ziel, Scope, Risiko und Umgebung
|
||||||
|
3. Iris übernimmt oder erstellt die **Parent-Task**
|
||||||
|
4. Parent-Task geht auf **In Progress**
|
||||||
|
5. Wenn nötig zerlegt Iris die Arbeit in **Child-Tasks**
|
||||||
|
6. Child-Tasks werden passenden Agenten zugewiesen
|
||||||
|
7. Agenten arbeiten die Child-Tasks ab
|
||||||
|
8. Iris sammelt Ergebnisse ein und integriert sie
|
||||||
|
9. Parent-Task geht auf **Review**
|
||||||
|
10. Bao entscheidet: **Done** oder zurück nach **Backlog**
|
||||||
|
|
||||||
|
### Fall B: Agent meldet einen Blocker
|
||||||
|
|
||||||
|
1. Agent meldet Blocker an Iris
|
||||||
|
2. Iris prüft, ob der Blocker lokal lösbar ist
|
||||||
|
3. Wenn nein: die betroffene **Child-Task** geht auf **Blocked**
|
||||||
|
4. Falls nötig entsteht eine neue Ursachen-Task / neue Child-Task
|
||||||
|
5. Parent-Task bleibt **In Progress**, solange der Gesamtauftrag noch koordiniert wird
|
||||||
|
6. Nur wenn die Hauptaufgabe insgesamt feststeckt, geht die **Parent-Task** auf **Blocked**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. OpenClaw- und Board-Interaktion
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant Bao
|
||||||
|
participant Iris
|
||||||
|
participant Board as Nexus Task Board
|
||||||
|
participant OpenClaw
|
||||||
|
participant Agent as Sub-Agent
|
||||||
|
|
||||||
|
Bao->>Iris: Auftrag
|
||||||
|
Iris->>Board: Parent-Task übernehmen / anlegen
|
||||||
|
Iris->>Board: Child-Task anlegen
|
||||||
|
Iris->>OpenClaw: Agentenauftrag starten
|
||||||
|
OpenClaw->>Agent: Task ausführen
|
||||||
|
Agent-->>Iris: Ergebnis / Rückfrage / Blocker
|
||||||
|
Iris->>Board: Child-Task aktualisieren
|
||||||
|
Iris->>Board: Parent-Task integrieren
|
||||||
|
Iris->>Board: Parent auf Review setzen
|
||||||
|
Board-->>Bao: Review sichtbar
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Regeln für gutes Schneiden von Child-Tasks
|
||||||
|
|
||||||
|
Eine Child-Task ist sinnvoll, wenn sie:
|
||||||
|
|
||||||
|
- einen **klaren Arbeitsblock** darstellt
|
||||||
|
- einen **eigenen Verantwortlichen** hat
|
||||||
|
- ein **eigenes Ergebnis** liefern soll
|
||||||
|
- unabhängig als **Done** oder **Blocked** sichtbar sein kann
|
||||||
|
|
||||||
|
Keine gute Child-Task ist:
|
||||||
|
|
||||||
|
- „Datei öffnen"
|
||||||
|
- „kurz nachschauen"
|
||||||
|
- „eine Kleinigkeit prüfen"
|
||||||
|
|
||||||
|
Faustregel:
|
||||||
|
|
||||||
|
> **Eine Child-Task soll ein echter delegierbarer Arbeitsauftrag sein, kein Mikro-Schritt.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Board-Sicht: was sichtbar sein soll
|
||||||
|
|
||||||
|
Im Board soll erkennbar sein:
|
||||||
|
|
||||||
|
- welche Parent-Task Iris gerade steuert
|
||||||
|
- welche Child-Tasks darunter existieren
|
||||||
|
- welcher Agent welche Child-Task besitzt
|
||||||
|
- welche Child-Task blockiert ist
|
||||||
|
- welche Parent-Task in Review auf Bao wartet
|
||||||
|
|
||||||
|
Im Task-Detail sollen sichtbar sein:
|
||||||
|
|
||||||
|
- Parent/Child-Beziehung
|
||||||
|
- `AssignedTo`
|
||||||
|
- Status
|
||||||
|
- erwarteter nächster Beitrag / letzter Aktivitätshinweis
|
||||||
|
- Child-Task-Liste direkt unter der Parent-Task
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Kanonische Regeln
|
||||||
|
|
||||||
|
### Regel 1 — Das Board ist die sichtbare Aufgabenwahrheit
|
||||||
|
Chat und Agentenläufe ergänzen das Board, ersetzen es aber nicht.
|
||||||
|
|
||||||
|
### Regel 2 — Iris bleibt Ownerin der Hauptaufgabe
|
||||||
|
Delegation verschiebt Verantwortung nicht automatisch auf den Agenten.
|
||||||
|
|
||||||
|
### Regel 3 — Delegation ist sichtbar
|
||||||
|
Jede echte delegierte Arbeit wird als Child-Task abgebildet.
|
||||||
|
|
||||||
|
### Regel 4 — Kein künstlicher Wartezustand auf Parent-Ebene
|
||||||
|
Die Parent-Task bleibt **In Progress**, solange Iris aktiv koordiniert.
|
||||||
|
|
||||||
|
### Regel 5 — Blocker präzise markieren
|
||||||
|
Wenn nur ein Arbeitspaket hängt, blockiert zuerst die **Child-Task**, nicht automatisch die ganze Parent-Task.
|
||||||
|
|
||||||
|
### Regel 6 — Review ist Bao-Gate
|
||||||
|
Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Done** oder zurück.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Beispiel
|
||||||
|
|
||||||
|
### Parent-Task
|
||||||
|
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
|
||||||
|
|
||||||
|
### Mögliche Child-Tasks
|
||||||
|
- **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`
|
||||||
|
|
||||||
|
So sieht Bao später nicht nur „Iris arbeitet daran“, sondern konkret:
|
||||||
|
|
||||||
|
- welcher Teil erledigt ist
|
||||||
|
- welcher Teil noch läuft
|
||||||
|
- welcher Teil blockiert ist
|
||||||
|
- worauf Iris gerade wartet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Anti-Patterns
|
||||||
|
|
||||||
|
Diese Muster sollen vermieden werden:
|
||||||
|
|
||||||
|
- Parent-Task auf einen bloßen **Delegated**-Wartestatus schieben
|
||||||
|
- Delegation nur im Chat sichtbar machen
|
||||||
|
- Child-Tasks ohne klare Ownership anlegen
|
||||||
|
- Blocker nur mündlich erwähnen, aber nicht im Board markieren
|
||||||
|
- zehn Mikro-Subtasks für einen Mini-Arbeitsschritt erzeugen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Entscheidungsregel für Iris
|
||||||
|
|
||||||
|
Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
|
||||||
|
|
||||||
|
**Child-Task anlegen**, wenn mindestens einer der Punkte zutrifft:
|
||||||
|
|
||||||
|
- anderer Agent übernimmt echte Arbeit
|
||||||
|
- eigener Status muss sichtbar verfolgt werden
|
||||||
|
- eigener Blocker ist möglich
|
||||||
|
- Bao soll Transparenz über diesen Teil sehen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Technische Leitplanken
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Merksatz
|
||||||
|
|
||||||
|
> **Iris koordiniert die Hauptaufgabe. Agenten erledigen sichtbare Child-Tasks. Das Board zeigt die Wahrheit. OpenClaw führt aus.**
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"locator":{"name":"pnpm","reference":"10.12.1"},"bin":{"pnpm":"./bin/pnpm.cjs","pnpx":"./bin/pnpx.cjs"},"hash":"sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
|
||||||
|
Copyright (c) 2016-2025 Zoltan Kochan and other contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
[简体中文](https://pnpm.io/zh/) |
|
||||||
|
[日本語](https://pnpm.io/ja/) |
|
||||||
|
[한국어](https://pnpm.io/ko/) |
|
||||||
|
[Italiano](https://pnpm.io/it/) |
|
||||||
|
[Português Brasileiro](https://pnpm.io/pt/)
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://i.imgur.com/qlW1eEG.png">
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/qlW1eEG.png">
|
||||||
|
<img src="https://i.imgur.com/qlW1eEG.png" alt="pnpm">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
Fast, disk space efficient package manager:
|
||||||
|
|
||||||
|
* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
|
||||||
|
* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
|
||||||
|
* **[Great for monorepos](https://pnpm.io/workspaces).**
|
||||||
|
* **Strict.** A package can access only dependencies that are specified in its `package.json`.
|
||||||
|
* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
|
||||||
|
* **Works as a Node.js version manager.** See [pnpm env use](https://pnpm.io/cli/env).
|
||||||
|
* **Works everywhere.** Supports Windows, Linux, and macOS.
|
||||||
|
* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/users) since 2016.
|
||||||
|
* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
|
||||||
|
|
||||||
|
To quote the [Rush](https://rushjs.io/) team:
|
||||||
|
|
||||||
|
> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and we’ve found it to be very fast and reliable.
|
||||||
|
|
||||||
|
[](https://github.com/pnpm/pnpm/releases/latest)
|
||||||
|
[](https://r.pnpm.io/chat)
|
||||||
|
[](https://opencollective.com/pnpm)
|
||||||
|
[](https://opencollective.com/pnpm)
|
||||||
|
[](https://x.com/intent/follow?screen_name=pnpmjs®ion=follow_link)
|
||||||
|
[](https://stand-with-ukraine.pp.ua)
|
||||||
|
|
||||||
|
## Platinum Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://bit.dev/?utm_source=pnpm&utm_medium=readme" target="_blank"><img src="https://pnpm.io/img/users/bit.svg" width="80" alt="Bit"></a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://sanity.io/?utm_source=pnpm&utm_medium=readme" target="_blank"><img src="https://pnpm.io/img/users/sanity.svg" width="180" alt="Bit"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Gold Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://discord.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/discord.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/discord_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/discord.svg" width="220" alt="Discord" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://coderabbit.ai/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/coderabbit.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/coderabbit_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/coderabbit.svg" width="220" alt="CodeRabbit" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://workleap.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/workleap.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/workleap_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/workleap.svg" width="190" alt="Workleap" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://stackblitz.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/stackblitz.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/stackblitz_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/stackblitz.svg" width="190" alt="Stackblitz" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://vite.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/vitejs.svg" width="42" alt="Vite">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Silver Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://uscreen.de/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/uscreen.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/uscreen_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/uscreen.svg" width="180" alt="u|screen" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://leniolabs.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/leniolabs.jpg" width="40" alt="Leniolabs_">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://depot.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/depot.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/depot_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/depot.svg" width="100" alt="Depot" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://devowl.io/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/devowlio.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/devowlio.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/devowlio.svg" width="100" alt="devowl.io" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://cerbos.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/cerbos.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/cerbos_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/cerbos.svg" width="90" alt="Cerbos" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://opensource.mercedes-benz.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/mercedes.svg" width="32" alt="Vite">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Support this project by [becoming a sponsor](https://opencollective.com/pnpm#sponsor).
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
pnpm uses a content-addressable filesystem to store all files from all module directories on a disk.
|
||||||
|
When using npm, if you have 100 projects using lodash, you will have 100 copies of lodash on disk.
|
||||||
|
With pnpm, lodash will be stored in a content-addressable storage, so:
|
||||||
|
|
||||||
|
1. If you depend on different versions of lodash, only the files that differ are added to the store.
|
||||||
|
If lodash has 100 files, and a new version has a change only in one of those files,
|
||||||
|
`pnpm update` will only add 1 new file to the storage.
|
||||||
|
1. All the files are saved in a single place on the disk. When packages are installed, their files are linked
|
||||||
|
from that single place consuming no additional disk space. Linking is performed using either hard-links or reflinks (copy-on-write).
|
||||||
|
|
||||||
|
As a result, you save gigabytes of space on your disk and you have a lot faster installations!
|
||||||
|
If you'd like more details about the unique `node_modules` structure that pnpm creates and
|
||||||
|
why it works fine with the Node.js ecosystem, read this small article: [Flat node_modules is not the only way](https://pnpm.io/blog/2020/05/27/flat-node-modules-is-not-the-only-way).
|
||||||
|
|
||||||
|
💖 Like this project? Let people know with a [tweet](https://r.pnpm.io/tweet)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
For installation options [visit our website](https://pnpm.io/installation).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Just use pnpm in place of npm/Yarn. E.g., install dependencies via:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
For more advanced usage, read [pnpm CLI](https://pnpm.io/pnpm-cli) on our website, or run `pnpm help`.
|
||||||
|
|
||||||
|
## Benchmark
|
||||||
|
|
||||||
|
pnpm is up to 2x faster than npm and Yarn classic. See all benchmarks [here](https://r.pnpm.io/benchmarks).
|
||||||
|
|
||||||
|
Benchmarks on an app with lots of dependencies:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- [Frequently Asked Questions](https://pnpm.io/faq)
|
||||||
|
- [Chat](https://r.pnpm.io/chat)
|
||||||
|
- [X](https://x.com/pnpmjs)
|
||||||
|
- [Bluesky](https://bsky.app/profile/pnpm.io)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](https://github.com/pnpm/pnpm/blob/main/LICENSE)
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "10.12.1",
|
||||||
|
"description": "Fast, disk space efficient package manager",
|
||||||
|
"keywords": [
|
||||||
|
"pnpm",
|
||||||
|
"pnpm10",
|
||||||
|
"dependencies",
|
||||||
|
"dependency manager",
|
||||||
|
"efficient",
|
||||||
|
"fast",
|
||||||
|
"hardlinks",
|
||||||
|
"install",
|
||||||
|
"installer",
|
||||||
|
"link",
|
||||||
|
"lockfile",
|
||||||
|
"modules",
|
||||||
|
"monorepo",
|
||||||
|
"multi-package",
|
||||||
|
"npm",
|
||||||
|
"package manager",
|
||||||
|
"package.json",
|
||||||
|
"packages",
|
||||||
|
"prune",
|
||||||
|
"rapid",
|
||||||
|
"remove",
|
||||||
|
"shrinkwrap",
|
||||||
|
"symlinks",
|
||||||
|
"uninstall",
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": "https://opencollective.com/pnpm",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/pnpm/pnpm.git",
|
||||||
|
"directory": "pnpm"
|
||||||
|
},
|
||||||
|
"homepage": "https://pnpm.io",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pnpm/pnpm/issues"
|
||||||
|
},
|
||||||
|
"main": "bin/pnpm.cjs",
|
||||||
|
"exports": {
|
||||||
|
".": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"pnpm": "bin/pnpm.cjs",
|
||||||
|
"pnpx": "bin/pnpx.cjs"
|
||||||
|
},
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"unpkg": "dist/pnpm.cjs",
|
||||||
|
"__dependencies": {
|
||||||
|
"v8-compile-cache": "2.4.0"
|
||||||
|
},
|
||||||
|
"__optionalDependencies": {
|
||||||
|
"node-gyp": "^11.1.0"
|
||||||
|
},
|
||||||
|
"__devDependencies": {
|
||||||
|
"@pnpm/assert-project": "workspace:*",
|
||||||
|
"@pnpm/byline": "catalog:",
|
||||||
|
"@pnpm/cache.commands": "workspace:*",
|
||||||
|
"@pnpm/cli-meta": "workspace:*",
|
||||||
|
"@pnpm/cli-utils": "workspace:*",
|
||||||
|
"@pnpm/client": "workspace:*",
|
||||||
|
"@pnpm/command": "workspace:*",
|
||||||
|
"@pnpm/common-cli-options-help": "workspace:*",
|
||||||
|
"@pnpm/config": "workspace:*",
|
||||||
|
"@pnpm/constants": "workspace:*",
|
||||||
|
"@pnpm/core-loggers": "workspace:*",
|
||||||
|
"@pnpm/crypto.hash": "workspace:*",
|
||||||
|
"@pnpm/default-reporter": "workspace:*",
|
||||||
|
"@pnpm/dependency-path": "workspace:*",
|
||||||
|
"@pnpm/env.path": "workspace:*",
|
||||||
|
"@pnpm/error": "workspace:*",
|
||||||
|
"@pnpm/exec.build-commands": "workspace:*",
|
||||||
|
"@pnpm/filter-workspace-packages": "workspace:*",
|
||||||
|
"@pnpm/find-workspace-dir": "workspace:*",
|
||||||
|
"@pnpm/lockfile.types": "workspace:*",
|
||||||
|
"@pnpm/logger": "workspace:*",
|
||||||
|
"@pnpm/modules-yaml": "workspace:*",
|
||||||
|
"@pnpm/nopt": "catalog:",
|
||||||
|
"@pnpm/parse-cli-args": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-audit": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-completion": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-config": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-deploy": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-doctor": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-env": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-init": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-installation": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-licenses": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-listing": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-outdated": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-patching": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-publishing": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-rebuild": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-script-runners": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-server": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-setup": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-store": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-store-inspecting": "workspace:*",
|
||||||
|
"@pnpm/prepare": "workspace:*",
|
||||||
|
"@pnpm/read-package-json": "workspace:*",
|
||||||
|
"@pnpm/read-project-manifest": "workspace:*",
|
||||||
|
"@pnpm/registry-mock": "catalog:",
|
||||||
|
"@pnpm/run-npm": "workspace:*",
|
||||||
|
"@pnpm/store.cafs": "workspace:*",
|
||||||
|
"@pnpm/tabtab": "catalog:",
|
||||||
|
"@pnpm/test-fixtures": "workspace:*",
|
||||||
|
"@pnpm/test-ipc-server": "workspace:*",
|
||||||
|
"@pnpm/tools.path": "workspace:*",
|
||||||
|
"@pnpm/tools.plugin-commands-self-updater": "workspace:*",
|
||||||
|
"@pnpm/types": "workspace:*",
|
||||||
|
"@pnpm/worker": "workspace:*",
|
||||||
|
"@pnpm/workspace.find-packages": "workspace:*",
|
||||||
|
"@pnpm/workspace.pkgs-graph": "workspace:*",
|
||||||
|
"@pnpm/workspace.read-manifest": "workspace:*",
|
||||||
|
"@pnpm/workspace.state": "workspace:*",
|
||||||
|
"@pnpm/write-project-manifest": "workspace:*",
|
||||||
|
"@types/cross-spawn": "catalog:",
|
||||||
|
"@types/is-windows": "catalog:",
|
||||||
|
"@types/pnpm__byline": "catalog:",
|
||||||
|
"@types/ramda": "catalog:",
|
||||||
|
"@types/semver": "catalog:",
|
||||||
|
"@zkochan/retry": "catalog:",
|
||||||
|
"@zkochan/rimraf": "catalog:",
|
||||||
|
"chalk": "catalog:",
|
||||||
|
"ci-info": "catalog:",
|
||||||
|
"cross-spawn": "catalog:",
|
||||||
|
"deep-require-cwd": "catalog:",
|
||||||
|
"delay": "catalog:",
|
||||||
|
"dir-is-case-sensitive": "catalog:",
|
||||||
|
"esbuild": "catalog:",
|
||||||
|
"execa": "catalog:",
|
||||||
|
"exists-link": "catalog:",
|
||||||
|
"is-windows": "catalog:",
|
||||||
|
"load-json-file": "catalog:",
|
||||||
|
"loud-rejection": "catalog:",
|
||||||
|
"normalize-newline": "catalog:",
|
||||||
|
"p-any": "catalog:",
|
||||||
|
"p-defer": "catalog:",
|
||||||
|
"path-name": "catalog:",
|
||||||
|
"pidtree": "catalog:",
|
||||||
|
"ps-list": "catalog:",
|
||||||
|
"ramda": "catalog:",
|
||||||
|
"read-yaml-file": "catalog:",
|
||||||
|
"render-help": "catalog:",
|
||||||
|
"semver": "catalog:",
|
||||||
|
"split-cmd": "catalog:",
|
||||||
|
"symlink-dir": "catalog:",
|
||||||
|
"tempy": "catalog:",
|
||||||
|
"tree-kill": "catalog:",
|
||||||
|
"write-json-file": "catalog:",
|
||||||
|
"write-pkg": "catalog:",
|
||||||
|
"write-yaml-file": "catalog:"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "@pnpm/jest-config/with-registry"
|
||||||
|
},
|
||||||
|
"preferGlobal": true,
|
||||||
|
"publishConfig": {
|
||||||
|
"tag": "next-10",
|
||||||
|
"executableFiles": [
|
||||||
|
"./dist/node-gyp-bin/node-gyp",
|
||||||
|
"./dist/node-gyp-bin/node-gyp.cmd",
|
||||||
|
"./dist/node_modules/node-gyp/bin/node-gyp.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"bundle": "ts-node bundle.ts",
|
||||||
|
"start": "tsc --watch",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"pretest:e2e": "rimraf node_modules/.bin/pnpm",
|
||||||
|
"_test": "jest",
|
||||||
|
"test": "pnpm run compile && pnpm run _test",
|
||||||
|
"_compile": "tsc --build",
|
||||||
|
"compile": "tsc --build && pnpm run lint --fix && rimraf dist bin/nodes && pnpm run bundle && shx cp -r node-gyp-bin dist/node-gyp-bin && shx cp -r node_modules/@pnpm/tabtab/lib/templates dist/templates && shx cp -r node_modules/ps-list/vendor dist/vendor && shx cp pnpmrc dist/pnpmrc"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user