Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42e4ea4497 | |||
| 1732fa97ad | |||
| c4270a4975 | |||
| 6ba788bb6e | |||
| a55951f315 | |||
| 77b9587fa6 | |||
| 17dc84082c | |||
| 8ad8c956eb | |||
| 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 | |||
| f271602f31 | |||
| 63319e1046 | |||
| b730fa1518 | |||
| fadb5d75c4 | |||
| 45a39d319f | |||
| 5ea7aa9611 | |||
| a6fabb90b0 | |||
| db62354c97 | |||
| 20dedcd6fa | |||
| 4ad0f9e493 | |||
| ac4e1cd3cf | |||
| 01c9bda339 | |||
| 1b11793dad | |||
| 98f98b55d5 | |||
| f28c398d16 | |||
| 358ec3e65d | |||
| 5f3d04f44c | |||
| d169cbe9d5 | |||
| 6cedd8410f | |||
| 9033ff2973 | |||
| 676dbd7589 | |||
| 9330de7af0 | |||
| 6023b5ea24 | |||
| 166c9f9051 | |||
| 2d6e3537e8 | |||
| 3672e56994 | |||
| f378d7aed4 | |||
| 1a7bf8ca11 | |||
| 3907548a1d | |||
| b1888bd8ef | |||
| c29740a466 | |||
| 45c6b24928 | |||
| 5fb62bef8a | |||
| 068b0d31b8 | |||
| 97b8588dc3 | |||
| 6150ea96af | |||
| 81af81fb6f | |||
| 2877035c5c | |||
| 6a1366b472 | |||
| adecfea432 | |||
| b7b44494f0 | |||
| a538025049 | |||
| 6d7454a7c1 | |||
| 3c72e807da | |||
| 702692cf0c | |||
| 51d1917a7b | |||
| 85f3400076 | |||
| a5cbe98f25 | |||
| 5b0e3a19f6 | |||
| e1d6b1eeb3 | |||
| afcbf941a9 | |||
| 49b9778872 | |||
| 6d0dab4889 | |||
| dd509a75be | |||
| e0fc305832 | |||
| c120155170 | |||
| 0241130c2f | |||
| 889af65ae7 | |||
| bdd75c9224 | |||
| f707dceb98 | |||
| 96a44233c0 | |||
| 191cb5cbd2 | |||
| 12e629432c | |||
| 47f0f1d786 | |||
| bf60b8b064 | |||
| b8498f47bb | |||
| f037aa2eeb | |||
| e6520fc26d | |||
| c9d8852609 | |||
| 11e9a257a1 | |||
| ead202ad8b | |||
| effc86e15b | |||
| 0f9809e423 | |||
| c2736d20c1 | |||
| 084cff4fe6 | |||
| ef3fc6039e | |||
| 3599513128 | |||
| 7dd8f53f2f | |||
| 90bb7251e3 | |||
| e57bef95e5 |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npx vite *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=replace-with-a-strong-database-password
|
||||
JWT_KEY=replace-with-at-least-32-random-bytes
|
||||
OWNER_EMAIL=owner@example.com
|
||||
OWNER_PASSWORD=replace-with-at-least-14-characters
|
||||
OWNER_DISPLAY_NAME=Owner
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||
NVIDIA_API_KEY=
|
||||
+2
-4
@@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
|
||||
# ── Owner Account ───────────────────────────────────────
|
||||
OWNER_EMAIL=***
|
||||
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
|
||||
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
|
||||
# ── Bootstrap Owner (first seed only) ───────────────────
|
||||
BOOTSTRAP_OWNER_EMAIL=***
|
||||
|
||||
# ── OpenClaw Integration ────────────────────────────────
|
||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||
|
||||
Executable
+222
@@ -0,0 +1,222 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
|
||||
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
|
||||
ENV_TMPFILE=""
|
||||
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
|
||||
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
require_env() {
|
||||
name="$1"
|
||||
eval "value=\${$name:-}"
|
||||
if [ -z "$value" ]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env ENV_POSTGRES_PASSWORD
|
||||
require_env ENV_JWT_KEY
|
||||
|
||||
secure_tmpfile() {
|
||||
template="$1"
|
||||
dir="$(dirname "$template")"
|
||||
base="$(basename "$template")"
|
||||
mkdir -p "$dir"
|
||||
mktemp "$dir/$base.XXXXXX"
|
||||
}
|
||||
|
||||
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
|
||||
chmod 600 "$ENV_TMPFILE"
|
||||
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "VERSION file not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Invalid VERSION value: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
echo "Deploying Nexus v$VERSION from $GIT_REF"
|
||||
|
||||
umask 077
|
||||
cat > "$ENV_TMPFILE" <<EOF_ENV
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||
JWT_KEY=${ENV_JWT_KEY}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
NEXUS_VERSION=${VERSION}
|
||||
NEXUS_GIT_SHA=${GIT_SHA}
|
||||
EOF_ENV
|
||||
|
||||
echo "Syncing source to deploy path: $DEPLOY_PATH"
|
||||
git archive --format=tar HEAD | docker run --rm -i \
|
||||
-v "$DEPLOY_PATH:/dest" \
|
||||
alpine:3.20 \
|
||||
sh -c '
|
||||
set -eu
|
||||
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||
mkdir -p /src-snapshot
|
||||
tar -xf - -C /src-snapshot
|
||||
|
||||
is_protected_path() {
|
||||
case "$1" in
|
||||
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cd /dest
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then
|
||||
rm -rf "$path"
|
||||
fi
|
||||
done
|
||||
|
||||
cd /src-snapshot
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then
|
||||
cp -a "$path" /dest/
|
||||
fi
|
||||
done
|
||||
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
|
||||
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.json)"
|
||||
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
||||
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
||||
OPENCLAW_CONFIG_DIR="/home/projekte_bao/openclaw/data/openclaw"
|
||||
|
||||
# Extract only "agents" key from openclaw.json using jq in an alpine container.
|
||||
# This ensures NO secrets (gateway, channels, auth, etc.) leak into the sanitized file.
|
||||
if docker run --rm \
|
||||
-v "$OPENCLAW_CONFIG:/input/openclaw.json:ro" \
|
||||
-v "$OPENCLAW_CONFIG_DIR:/output" \
|
||||
alpine:3.20 \
|
||||
sh -c '
|
||||
if ! apk add --no-cache jq >/dev/null 2>&1; then
|
||||
echo "WARNING: jq not available, agents-sanitized.json NOT regenerated" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f /input/openclaw.json ]; then
|
||||
echo "WARNING: openclaw.json not found — agents-sanitized.json NOT regenerated" >&2
|
||||
exit 1
|
||||
fi
|
||||
jq "{agents: .agents}" /input/openclaw.json > /output/agents-sanitized.json
|
||||
count=$(jq ".agents.list | length" /output/agents-sanitized.json 2>/dev/null || echo 0)
|
||||
echo "Sanitized agents config written ($count agents)"
|
||||
' 2>&1; then
|
||||
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||
else
|
||||
echo "WARNING: Failed to generate agents-sanitized.json — Nexus will use fallback agent IDs" >&2
|
||||
fi
|
||||
|
||||
echo "Building and starting Docker compose stack"
|
||||
docker run --rm \
|
||||
-v "$DEPLOY_PATH:/workspace/nexus" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh -c 'set -eu
|
||||
umask 077
|
||||
cat > /tmp/nexus-deploy-env
|
||||
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
|
||||
docker compose --env-file /tmp/nexus-deploy-env build
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps
|
||||
' < "$ENV_TMPFILE"
|
||||
|
||||
echo "Verifying image provenance"
|
||||
for container in nexus-api-1 nexus-web-1; do
|
||||
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
|
||||
version="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$container")"
|
||||
if [ "$revision" != "$GIT_SHA" ]; then
|
||||
echo "Image revision mismatch for $container: expected $GIT_SHA, got $revision" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$version" != "$VERSION" ]; then
|
||||
echo "Image version mismatch for $container: expected $VERSION, got $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$container provenance verified: v$version $revision"
|
||||
done
|
||||
|
||||
echo "Checking live health"
|
||||
retry=0
|
||||
while [ "$retry" -lt 6 ]; do
|
||||
retry=$((retry + 1))
|
||||
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
fi
|
||||
if [ "$retry" -eq 6 ]; then
|
||||
echo "Health check failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$retry"
|
||||
done
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
check() {
|
||||
path="$1"
|
||||
expected="$2"
|
||||
label="$3"
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$BASE_URL$path")"
|
||||
printf '%-28s HTTP %s\n' "$label" "$code"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_post() {
|
||||
path="$1"
|
||||
expected="$2"
|
||||
label="$3"
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{}' "$BASE_URL$path")"
|
||||
printf '%-28s HTTP %s\n' "$label" "$code"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check "/dashboard" "200" "Dashboard"
|
||||
check "/health" "200" "Health"
|
||||
check "/api/v1/operations/snapshot" "401" "Operations auth"
|
||||
check_post "/api/v1/chat" "401" "Chat auth"
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "Smoke test failed: $fail failed, $pass passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Nexus v$VERSION deployed and verified"
|
||||
@@ -0,0 +1,156 @@
|
||||
name: Database Backup
|
||||
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Owner: DevOps (Architekt)
|
||||
# Trigger: Manual (workflow_dispatch) + optional schedule.
|
||||
#
|
||||
# Strategy:
|
||||
# 1. Connects to the live PostgreSQL container via docker exec.
|
||||
# 2. Runs pg_dumpall (full cluster dump, single file).
|
||||
# 3. Compresses with gzip.
|
||||
# 4. Uploads as a Gitea Action artifact (or writes to host path).
|
||||
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
|
||||
#
|
||||
# Rotation: Gitea artifact expiration handles old backups automatically.
|
||||
# For longer retention, configure an external cron job or use the
|
||||
# host_path output to copy the backup elsewhere.
|
||||
#
|
||||
# Restoration: See phases/deployment.md for step-by-step instructions.
|
||||
# ───────────────────────────────────────────────────────
|
||||
concurrency:
|
||||
group: db-backup
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_on_host:
|
||||
description: 'Also copy backup to host path?'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
host_backup_path:
|
||||
description: 'Host path for backup (only if keep_on_host is true)'
|
||||
required: false
|
||||
default: '/home/projekte_bao/backups/nexus'
|
||||
type: string
|
||||
|
||||
# Optional: uncomment to enable nightly automatic backups
|
||||
# schedule:
|
||||
# - cron: '0 3 * * *' # Every night at 03:00 UTC
|
||||
|
||||
jobs:
|
||||
backup:
|
||||
name: Backup PostgreSQL
|
||||
runs-on: linux
|
||||
env:
|
||||
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||
|
||||
steps:
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 1: Generate backup filename
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Generate backup identifier
|
||||
id: meta
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
|
||||
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
|
||||
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
|
||||
echo "📅 Backup ID: ${TIMESTAMP}"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 2: Dump PostgreSQL via docker exec
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Dump database
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "🗄️ Dumping PostgreSQL cluster..."
|
||||
|
||||
docker exec "${BACKUP_CONTAINER_NAME}" \
|
||||
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus" \
|
||||
| gzip > "${{ steps.meta.outputs.filename }}"
|
||||
|
||||
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
||||
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 3: Upload backup as Gitea artifact
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Upload backup artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
|
||||
path: ${{ steps.meta.outputs.filename }}
|
||||
retention-days: 90
|
||||
compression-level: 0 # already gzipped
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 4: Optional — copy to host filesystem
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Copy backup to host (optional)
|
||||
if: inputs.keep_on_host == true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
HOST_PATH="${{ inputs.host_backup_path }}"
|
||||
|
||||
# Create host dir if it doesn't exist
|
||||
docker run --rm \
|
||||
-v "${HOST_PATH}:/backup-target" \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
alpine:latest \
|
||||
sh -c "
|
||||
mkdir -p /backup-target && \
|
||||
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
|
||||
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
|
||||
"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 5: Verify backup integrity
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Verify backup integrity
|
||||
run: |
|
||||
echo "🔍 Verifying backup integrity..."
|
||||
if gzip -t "${{ steps.meta.outputs.filename }}"; then
|
||||
echo "✅ Backup gzip integrity check passed"
|
||||
else
|
||||
echo "❌ Backup file is corrupted!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Quick content check: should start with PostgreSQL dump header
|
||||
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
|
||||
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
|
||||
echo "✅ Backup content header check passed"
|
||||
else
|
||||
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 6: Backup Summary
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Backup Summary
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " 💾 Database Backup Summary"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " File: ${{ steps.meta.outputs.filename }}"
|
||||
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
|
||||
echo " Triggered: @${{ gitea.actor }}"
|
||||
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
|
||||
echo " Status: ${STATUS}"
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
if [ "${STATUS}" = "success" ]; then
|
||||
echo ""
|
||||
echo "💡 Restore command (manual, on host):"
|
||||
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
|
||||
fi
|
||||
+49
-11
@@ -8,9 +8,12 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- 'codex/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ─── Backend ───────────────────────────────────
|
||||
@@ -27,14 +30,13 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore backend/Nexus.Api.csproj
|
||||
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release
|
||||
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||
continue-on-error: true
|
||||
|
||||
# ─── Frontend ──────────────────────────────────
|
||||
frontend:
|
||||
@@ -52,18 +54,20 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@latest --activate
|
||||
corepack prepare pnpm@10.12.1 --activate
|
||||
|
||||
# --prefer-offline: use cached packages if available in the runner image
|
||||
# Lockfile IS committed — regenerated on changes via pnpm install.
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile --prefer-offline
|
||||
run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
|
||||
- name: Type check
|
||||
run: pnpm exec vue-tsc --noEmit
|
||||
working-directory: frontend
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
working-directory: frontend
|
||||
@@ -72,15 +76,49 @@ jobs:
|
||||
security:
|
||||
name: Security Check
|
||||
runs-on: linux
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for .env leaks
|
||||
run: |
|
||||
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then
|
||||
echo "⚠️ Warning: Potential secrets in source code (review manually)"
|
||||
echo "🔍 Scanning for potential secrets in source code..."
|
||||
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||
if [ -n "$HITS" ]; then
|
||||
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
|
||||
echo "$HITS"
|
||||
echo ""
|
||||
echo "Remove these values and use environment variables or a secrets manager instead."
|
||||
exit 1
|
||||
fi
|
||||
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
|
||||
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||
if [ -n "$LOOSE" ]; then
|
||||
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
|
||||
echo "$LOOSE"
|
||||
else
|
||||
echo "✅ No obvious secrets found"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Deploy Nexus
|
||||
runs-on: linux
|
||||
needs: [backend, frontend, security]
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
if: |
|
||||
gitea.event_name == 'push' &&
|
||||
gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy after green CI
|
||||
run: sh .gitea/scripts/deploy-nexus.sh
|
||||
|
||||
+12
-227
@@ -1,243 +1,28 @@
|
||||
name: Deploy to Production
|
||||
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
||||
name: Deploy Nexus Manual
|
||||
run-name: Deploy Nexus manually by @${{ gitea.actor }}
|
||||
|
||||
# ── Concurrency: one deploy at a time, cancel queued ones ──
|
||||
# Why: prevents race conditions when CI triggers deploy while
|
||||
# a manual deploy is still running. The latest deploy wins.
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
# ───────────────────────────────────────────────────
|
||||
# Trigger: automatic after CI success, or manual dispatch.
|
||||
# Runner: uses ubuntu-latest label (consistently present on
|
||||
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
|
||||
# Standard labels avoid custom-label matching edge cases.
|
||||
# ───────────────────────────────────────────────────
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI - Build & Test"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_version:
|
||||
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
|
||||
required: false
|
||||
default: 'patch'
|
||||
type: string
|
||||
options:
|
||||
- 'patch'
|
||||
- 'minor'
|
||||
- 'major'
|
||||
service:
|
||||
description: 'Service to deploy (empty = all)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
no_cache:
|
||||
description: 'Disable build cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Nexus
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: linux
|
||||
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:
|
||||
# ── Step 1: Checkout ─────────────────────
|
||||
- name: Checkout latest code
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
# ── Step 2: Version bump (race-free) ─────
|
||||
# Derives current version from git tags (not VERSION file) to
|
||||
# avoid race conditions where tag exists but VERSION is stale.
|
||||
# Uses --force on tag+push to handle retries after failed runs.
|
||||
- name: Version Bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Source of truth: latest git tag
|
||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
CURRENT_VERSION="${TAG#v}"
|
||||
echo "📦 Current version (from git tags): $CURRENT_VERSION"
|
||||
|
||||
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
|
||||
|
||||
case "${{ inputs.bump_version }}" in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||
patch|*)
|
||||
PATCH=$((PATCH + 1)) ;;
|
||||
esac
|
||||
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
echo "🏷️ New version: $NEW_VERSION"
|
||||
echo "$NEW_VERSION" > VERSION
|
||||
|
||||
git config user.email "devops@noveria.net"
|
||||
git config user.name "DevOps"
|
||||
git add VERSION
|
||||
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
|
||||
|
||||
# --force avoids "tag already exists" when re-running after a failed attempt
|
||||
git tag -f "v${NEW_VERSION}"
|
||||
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
|
||||
echo "✅ Version bumped to v${NEW_VERSION}"
|
||||
|
||||
# ── Step 3: Sync code + .env to host ──────
|
||||
# Creates .env from Gitea secrets in the workspace, then syncs
|
||||
# everything (except .git) to the host deploy path via DIND.
|
||||
- name: Sync code + .env to host
|
||||
run: |
|
||||
# Create .env from Gitea secrets in the workspace
|
||||
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
|
||||
# Nexus Production Environment — auto-generated by CD pipeline
|
||||
# Managed via Gitea secrets → do not edit manually on the host
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
|
||||
OWNER_DISPLAY_NAME=
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
ENVEOF
|
||||
|
||||
# Sync everything (except .git) from workspace to host
|
||||
docker run --rm \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
|
||||
alpine:latest \
|
||||
sh -c "
|
||||
cd /src && \
|
||||
find . -mindepth 1 -maxdepth 1 \
|
||||
! -name .git \
|
||||
-exec cp -a {} /dest/ \;
|
||||
"
|
||||
echo "✅ Code + .env synced to host deploy path"
|
||||
|
||||
# ── Step 4: Docker Buildx ─────────────────
|
||||
- name: Set up Docker Buildx
|
||||
run: docker buildx create --use 2>/dev/null || true
|
||||
|
||||
# ── Step 5: Build & Deploy ────────────────
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
BUILD_ARGS=""
|
||||
if [ "${{ inputs.no_cache }}" = "true" ]; then
|
||||
BUILD_ARGS="--no-cache"
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
docker:cli \
|
||||
sh -c "
|
||||
set -e
|
||||
if [ -n '${{ inputs.service }}' ]; then
|
||||
echo '🚀 Deploying service: ${{ inputs.service }}'
|
||||
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
|
||||
docker compose up -d --force-recreate ${{ inputs.service }}
|
||||
else
|
||||
echo '🚀 Deploying all services'
|
||||
docker compose build ${BUILD_ARGS}
|
||||
docker compose up -d --force-recreate
|
||||
fi
|
||||
"
|
||||
|
||||
# ── Step 6: Health Check (backoff) ────────
|
||||
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
|
||||
# Why: cold-start containers need variable warmup time;
|
||||
# fixed 5s intervals either wait too long or give up too early.
|
||||
- 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 7: Smoke test (multi-endpoint) ───
|
||||
# Tests multiple endpoints to catch partial failures.
|
||||
# Why: a single /dashboard check can miss backend-only outages;
|
||||
# /health tests the API + database + runtime status.
|
||||
- name: Verify (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=$(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
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "❌ Smoke test failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Deployment verified"
|
||||
|
||||
# ── Step 8: Rollback hint ────────────────
|
||||
# On any failure, prints the previous deploy tag for quick manual rollback.
|
||||
# Why: reduces MTTR (mean time to recovery) by providing the exact
|
||||
# git tag to roll back to without needing to look it up manually.
|
||||
- name: Rollback hint
|
||||
if: failure()
|
||||
run: |
|
||||
echo ""
|
||||
echo "🔙 ─── Rollback Instructions ─── 🔙"
|
||||
echo ""
|
||||
echo " # 1. Checkout previous version:"
|
||||
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
|
||||
echo ""
|
||||
echo " # 2. Redeploy:"
|
||||
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
|
||||
echo " docker compose up -d --force-recreate"
|
||||
echo ""
|
||||
echo " # 3. Or trigger rollback via Gitea:"
|
||||
echo " Trigger 'Deploy to Production' workflow with the previous tag"
|
||||
echo ""
|
||||
- name: Deploy main
|
||||
run: sh .gitea/scripts/deploy-nexus.sh
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
name: Rollback to Previous Version
|
||||
run-name: 🔙 Rollback by @${{ gitea.actor }}
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Owner: DevOps (Architekt)
|
||||
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
|
||||
#
|
||||
# This workflow reverts the deploy path to the code at a
|
||||
# given git tag/ref, then rebuilds and redeploys the stack.
|
||||
#
|
||||
# Strategy: git checkout <tag> → docker compose up -d --build
|
||||
# This is a "full restart rollback" — safest for containerized
|
||||
# apps where DB schema changes may need the matching API binary.
|
||||
#
|
||||
# DB migrations: the API runs MigrateAsync on startup. If the
|
||||
# rollback-tag's migration history is a prefix of the current DB,
|
||||
# EF Core handles this gracefully (no-op for already-applied
|
||||
# migrations). If the tag predates a destructive migration, manual
|
||||
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Rollback wins over queued/in-progress deploys.
|
||||
# It shares deploy-production with deploy.yaml so rollback and deploy never run together,
|
||||
# but cancel-in-progress=true prevents a queued auto-deploy from running after rollback.
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
description: 'Git tag to roll back to (e.g. v0.2.49)'
|
||||
required: true
|
||||
type: string
|
||||
confirm:
|
||||
description: 'Type "ROLLBACK" to confirm'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
name: Rollback Nexus
|
||||
runs-on: linux
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
ENV_TMPFILE: /tmp/nexus-rollback-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
|
||||
steps:
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 0: Safety gate — require explicit confirmation
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Safety Gate
|
||||
run: |
|
||||
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
|
||||
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
|
||||
echo " You entered: '${{ inputs.confirm }}'"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 1: Checkout target tag
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Checkout target tag
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.target_tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 2: Verify tag exists
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Verify tag
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
||||
if [ -z "$ACTUAL_TAG" ]; then
|
||||
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
|
||||
echo " Available tags:"
|
||||
git tag -l 'v*' | sort -V | tail -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Checked out: $ACTUAL_TAG"
|
||||
echo " Commit: $(git rev-parse --short HEAD)"
|
||||
echo " Message: $(git log -1 --oneline)"
|
||||
|
||||
# Read version from VERSION file at this tag
|
||||
if [ -f VERSION ]; then
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
echo " VERSION: $VERSION"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 3: Prepare .env from secrets (safe temp file)
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Prepare .env (secrets → temp file)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cat > "${ENV_TMPFILE}" <<EOF
|
||||
# Nexus Production Environment — auto-generated by CD pipeline
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||
JWT_KEY=${ENV_JWT_KEY}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_TMPFILE}"
|
||||
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 4: Sync rollback code to host
|
||||
# ═══════════════════════════════════════════════════
|
||||
- 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 "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 5: Rebuild & Redeploy
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Rebuild & Redeploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v "/tmp:/tmp-host:ro" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
docker:cli \
|
||||
sh -c "
|
||||
set -e
|
||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||
"
|
||||
|
||||
echo "✅ Rollback redeploy completed"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 6: 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 7: Health Check
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Health Check
|
||||
run: |
|
||||
echo "🏥 Health check after rollback..."
|
||||
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
|
||||
NEXT=$((WAIT + RETRY))
|
||||
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||
done
|
||||
echo "❌ Health check failed after $MAX attempts"
|
||||
exit 1
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 8: Smoke Test
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Smoke Test
|
||||
run: |
|
||||
echo "🔍 Smoke test after rollback..."
|
||||
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 "✅ Rollback to ${{ inputs.target_tag }} successful"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 9: Rollback Summary
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Rollback Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " 🔙 Rollback Summary"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " Rolled to: ${{ inputs.target_tag }}"
|
||||
echo " Triggered: @${{ gitea.actor }}"
|
||||
echo " Status: ${{ job.status }}"
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 10: Failure → Reviewer Handoff
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: 🔴 Rollback Failed — Reviewer Handoff
|
||||
if: failure()
|
||||
run: |
|
||||
echo ""
|
||||
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
|
||||
echo "├─────────────────────────────────────────────────────────────┤"
|
||||
echo "│ │"
|
||||
echo "│ Target: ${{ inputs.target_tag }}"
|
||||
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 "│ Letzter bekannter funktionierender Stand: │"
|
||||
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||
echo "│ → Manuellen Rollback erwägen: │"
|
||||
echo "│ cd /home/projekte_bao/nexus │"
|
||||
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||
echo "│ │"
|
||||
echo "└─────────────────────────────────────────────────────────────┘"
|
||||
+10
-2
@@ -6,7 +6,6 @@
|
||||
|
||||
# Environment
|
||||
.env
|
||||
!.env.example
|
||||
!.env.template
|
||||
|
||||
# IDE
|
||||
@@ -30,4 +29,13 @@ docker-compose.override.yml
|
||||
*.tmp
|
||||
*.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/
|
||||
|
||||
+6
-5
@@ -31,7 +31,7 @@
|
||||
│ 127.0.0.1:18880 │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼───────────────────┐ │
|
||||
│ │ Host nginx reverse proxy │ │ │
|
||||
│ │ Traefik v3 reverse proxy │ │ │
|
||||
│ │ nexus.noveria.net :443 ───┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
|
||||
ss -tlnp | grep 18880
|
||||
```
|
||||
|
||||
### Host nginx Reverse Proxy
|
||||
### Traefik Reverse Proxy
|
||||
Falls `nexus.noveria.net` nicht erreichbar:
|
||||
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880`
|
||||
- TLS-Zertifikat gültig?
|
||||
- Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
|
||||
- `web` hängt am externen `proxy`-Netzwerk
|
||||
- TLS-Zertifikat/Let's-Encrypt-Resolver in Traefik gültig?
|
||||
|
||||
---
|
||||
|
||||
@@ -151,5 +152,5 @@ Falls `nexus.noveria.net` nicht erreichbar:
|
||||
| backend/Dockerfile | ✅ Multi-Stage .NET 10 |
|
||||
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
|
||||
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
|
||||
| Host nginx Reverse Proxy | ⚠️ Muss auf Port 18880 zeigen |
|
||||
| Traefik Reverse Proxy | ✅ Per Compose-Labels auf `web:80` |
|
||||
| Docker installiert auf VPS | ⚠️ Vorausgesetzt |
|
||||
|
||||
@@ -3,7 +3,15 @@
|
||||
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.
|
||||
|
||||
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy.
|
||||
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
|
||||
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
||||
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||
|
||||
> CI runs automatically on every push. CD runs **inside the green CI run**
|
||||
> on main or can be triggered **manually** (workflow_dispatch). Deploy reads
|
||||
> `VERSION` but does not mutate Git or create tags. Rollback and database backup
|
||||
> are separate manual workflows.
|
||||
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||
|
||||
## Current foundation
|
||||
|
||||
@@ -11,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)
|
||||
- Entity Framework Core and PostgreSQL
|
||||
- JWT owner authentication with rotating refresh sessions
|
||||
- `IAgentRuntime` abstraction with an OpenClaw adapter
|
||||
- `IModelProvider` abstractions for Ollama and NVIDIA
|
||||
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
|
||||
- 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
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
|
||||
# OWNER_EMAIL and OWNER_PASSWORD.
|
||||
cp .env.template .env
|
||||
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
|
||||
docker compose up --build -d
|
||||
curl http://127.0.0.1:18880/health
|
||||
```
|
||||
|
||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14
|
||||
characters. Existing databases are never overwritten by the bootstrap process.
|
||||
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
|
||||
derives the initial display name from that email, and logs a generated temporary password once.
|
||||
After first seed the password lives only in PostgreSQL. Existing databases are
|
||||
never overwritten by the bootstrap process.
|
||||
|
||||
The web service is loopback-only. Public reverse-proxy activation for
|
||||
`nexus.noveria.net` remains a separate infrastructure change and must terminate
|
||||
TLS before forwarding to port `18880`.
|
||||
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||
Health checks, rate limiting, and security headers are active.
|
||||
|
||||
## Workspace mounts
|
||||
|
||||
@@ -41,12 +47,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
|
||||
|
||||
| Host path | Container mount |
|
||||
|---|---|
|
||||
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
@@ -161,13 +167,102 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
| Route | Name | Description |
|
||||
|---|---|---|
|
||||
| `/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 |
|
||||
| `/activity` | Activity | Audit timeline |
|
||||
| `/chat` | Mobile Chat | Owner-chat preview |
|
||||
|
||||
## 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)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -187,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) |
|
||||
|
||||
### 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
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -203,11 +307,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/tasks` | List all tasks |
|
||||
| `POST` | `/api/v1/tasks` | Create task |
|
||||
| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour |
|
||||
| `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals |
|
||||
| `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) |
|
||||
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
|
||||
| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress → done) |
|
||||
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress → backlog) |
|
||||
| `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) |
|
||||
| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
|
||||
|
||||
### Agents
|
||||
@@ -217,10 +321,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
| `GET` | `/api/v1/agents` | List all agents |
|
||||
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
|
||||
| `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) |
|
||||
| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary |
|
||||
| `POST` | `/api/v1/agents/{id}/command` | Send command to agent |
|
||||
| `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
|
||||
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
|
||||
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) |
|
||||
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result |
|
||||
|
||||
### Memory & Docs
|
||||
|
||||
@@ -279,12 +384,71 @@ Backlog → Blocked → In progress / Done
|
||||
provider key. Conversation IDs are stable per browser and Iris is the default
|
||||
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
|
||||
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
|
||||
3. `gpt-5.5` through OpenClaw for strategic and critical review
|
||||
| Agent | Model |
|
||||
|-------|-------|
|
||||
| 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
|
||||
credentials.
|
||||
# Trigger CI
|
||||
|
||||
## CI/CD
|
||||
|
||||
### CI — Automatic
|
||||
|
||||
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
||||
- **Backend**: .NET restore → build → test
|
||||
- **Frontend**: pnpm install → type-check → test → build
|
||||
- **Security**: Scan for hardcoded secrets in source code
|
||||
|
||||
CI must never break. If it does, Reviewer fixes.
|
||||
|
||||
### CD — Auto + Manual (CD v4)
|
||||
|
||||
Deployment can happen automatically or manually:
|
||||
|
||||
#### Auto-Deploy (after successful CI jobs on main)
|
||||
|
||||
- Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
|
||||
- Starts only after backend, frontend, and security jobs succeed on `main`
|
||||
- Deploys the current `main` version after CI succeeds.
|
||||
- This replaces `workflow_run`, which did not create deploy runs in this Gitea 1.26.3 installation.
|
||||
- The deploy script reads `VERSION`; it does not mutate Git, bump versions, or create tags
|
||||
|
||||
#### Manual Deploy (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
|
||||
2. Workflow validates `VERSION`, builds and deploys `main`
|
||||
3. Health check + smoke test verify the deployment
|
||||
|
||||
#### Rollback (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
|
||||
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
|
||||
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
|
||||
4. Health check + smoke test verify the rollback
|
||||
|
||||
#### Database Backup (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||
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
|
||||
4. Artifacts are retained for 90 days (configurable)
|
||||
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
||||
|
||||
#### Failure Handling
|
||||
|
||||
When deploy or rollback fails:
|
||||
- **DevOps (Architekt)** analyses the error
|
||||
- **Reviewer (Code-Fixer)** fixes the problem
|
||||
- **DevOps** re-deploys to verify the fix
|
||||
|
||||
The workflow outputs a formatted handoff message with the job URL.
|
||||
|
||||
Full CD documentation: [phases/deployment.md](phases/deployment.md)
|
||||
|
||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
@@ -44,18 +36,162 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||
Assert.Null(agent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
|
||||
{
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
|
||||
|
||||
Assert.Contains("product-owner", ids);
|
||||
Assert.Contains("programmer-fast", ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
|
||||
{
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Developer", agent.Role);
|
||||
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
|
||||
{
|
||||
var configPath = CreateAgentConfigFile(
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/workspace/default",
|
||||
"model": "deepseek/deepseek-v4-flash"
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "iris",
|
||||
"name": "iris",
|
||||
"model": "openai/gpt-5.5"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
var config = CreateConfiguration(configPath);
|
||||
var service = new AgentService(config, new FakeRuntime());
|
||||
|
||||
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("openai/gpt-5.5", agent!.Model);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
|
||||
{
|
||||
var configPath = CreateAgentConfigFile(
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/workspace/default",
|
||||
"model": "openai/gpt-5.5-mini"
|
||||
},
|
||||
"list": [
|
||||
{
|
||||
"id": "reviewer",
|
||||
"name": "reviewer"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
var config = CreateConfiguration(configPath);
|
||||
var service = new AgentService(config, new FakeRuntime());
|
||||
|
||||
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
|
||||
}
|
||||
|
||||
private static IConfiguration CreateConfiguration(string configPath)
|
||||
=> new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = configPath
|
||||
})
|
||||
.Build();
|
||||
|
||||
private static string CreateAgentConfigFile(string? json = null)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path, json ??
|
||||
"""
|
||||
{
|
||||
"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" }
|
||||
},
|
||||
{
|
||||
"id": "architekt",
|
||||
"name": "architekt",
|
||||
"model": { "primary": "openai/gpt-5.5" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FakeRuntime : IAgentRuntime
|
||||
|
||||
@@ -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,311 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public class GatewayConnectorTests
|
||||
{
|
||||
// ── Options / Configuration tests ──
|
||||
|
||||
[Fact]
|
||||
public void Options_DefaultWebSocketPath_IsWs()
|
||||
{
|
||||
var options = new GatewayConnectorOptions();
|
||||
Assert.Equal("/ws", options.WebSocketPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_DefaultReconnectInitialDelay_Is1000ms()
|
||||
{
|
||||
var options = new GatewayConnectorOptions();
|
||||
Assert.Equal(1000, options.ReconnectInitialDelayMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_DefaultReconnectMaxDelay_Is5Minutes()
|
||||
{
|
||||
var options = new GatewayConnectorOptions();
|
||||
Assert.Equal(300_000, options.ReconnectMaxDelayMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_FailFastOnVersionMismatch_IsTrueByDefault()
|
||||
{
|
||||
var options = new GatewayConnectorOptions();
|
||||
Assert.True(options.FailFastOnVersionMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Options_FailFastOnMissingVersion_IsFalseByDefault()
|
||||
{
|
||||
var options = new GatewayConnectorOptions();
|
||||
Assert.False(options.FailFastOnMissingVersion);
|
||||
}
|
||||
|
||||
// ── Configuration binding tests ──
|
||||
|
||||
[Fact]
|
||||
public void Options_BindFromConfiguration()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["GatewayConnector:WebSocketPath"] = "/events",
|
||||
["GatewayConnector:ReconnectInitialDelayMs"] = "2000",
|
||||
["GatewayConnector:ReconnectMaxDelayMs"] = "60000",
|
||||
["GatewayConnector:FailFastOnVersionMismatch"] = "false",
|
||||
["GatewayConnector:FailFastOnMissingVersion"] = "true"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddOptions<GatewayConnectorOptions>()
|
||||
.BindConfiguration(GatewayConnectorOptions.SectionName);
|
||||
var sp = services.BuildServiceProvider();
|
||||
var options = sp.GetRequiredService<IOptions<GatewayConnectorOptions>>().Value;
|
||||
|
||||
Assert.Equal("/events", options.WebSocketPath);
|
||||
Assert.Equal(2000, options.ReconnectInitialDelayMs);
|
||||
Assert.Equal(60000, options.ReconnectMaxDelayMs);
|
||||
Assert.False(options.FailFastOnVersionMismatch);
|
||||
Assert.True(options.FailFastOnMissingVersion);
|
||||
}
|
||||
|
||||
// ── Health endpoint tests ──
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_ReturnsCorrectStructure()
|
||||
{
|
||||
var controller = new GatewayHealthController(new FakeGatewayConnector(
|
||||
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
|
||||
DateTimeOffset.UtcNow, 0, "Gateway verbunden"));
|
||||
|
||||
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("connected", root.GetProperty("status").GetString());
|
||||
Assert.True(root.GetProperty("connected").GetBoolean());
|
||||
Assert.Equal("2.103.0", root.GetProperty("gatewayVersion").GetString());
|
||||
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
|
||||
Assert.True(root.GetProperty("versionPinned").GetBoolean());
|
||||
Assert.Equal(0, root.GetProperty("reconnectAttempts").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_Disconnected_ShowsDisconnected()
|
||||
{
|
||||
var controller = new GatewayHealthController(new FakeGatewayConnector(
|
||||
GatewayConnectionState.Disconnected, null, "2.103.0",
|
||||
null, 5, "Connection refused"));
|
||||
|
||||
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("disconnected", root.GetProperty("status").GetString());
|
||||
Assert.False(root.GetProperty("connected").GetBoolean());
|
||||
Assert.Equal("unknown", root.GetProperty("gatewayVersion").GetString());
|
||||
Assert.Equal(5, root.GetProperty("reconnectAttempts").GetInt32());
|
||||
Assert.Equal("Connection refused", root.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_Reconnecting_ShowsReconnecting()
|
||||
{
|
||||
var controller = new GatewayHealthController(new FakeGatewayConnector(
|
||||
GatewayConnectionState.Reconnecting, null, null,
|
||||
null, 3, "Reconnecting..."));
|
||||
|
||||
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("reconnecting", root.GetProperty("status").GetString());
|
||||
Assert.False(root.GetProperty("connected").GetBoolean());
|
||||
Assert.False(root.GetProperty("versionPinned").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_Failed_ShowsFailed()
|
||||
{
|
||||
var controller = new GatewayHealthController(new FakeGatewayConnector(
|
||||
GatewayConnectionState.Failed, "2.100.0", "2.103.0",
|
||||
null, 10, "Version mismatch — fail-fast"));
|
||||
|
||||
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("failed", root.GetProperty("status").GetString());
|
||||
Assert.False(root.GetProperty("connected").GetBoolean());
|
||||
Assert.Equal("2.100.0", root.GetProperty("gatewayVersion").GetString());
|
||||
Assert.Equal("2.103.0", root.GetProperty("requiredVersion").GetString());
|
||||
Assert.Equal(10, root.GetProperty("reconnectAttempts").GetInt32());
|
||||
Assert.True(root.TryGetProperty("timestamp", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpoint_Unpinned_ShowsVersionPinnedFalse()
|
||||
{
|
||||
var controller = new GatewayHealthController(new FakeGatewayConnector(
|
||||
GatewayConnectionState.Connected, "2.103.0", null,
|
||||
DateTimeOffset.UtcNow, 0, null));
|
||||
|
||||
var body = await ExecuteAndReadJsonAsync(controller.GetGatewayHealth());
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.False(root.GetProperty("versionPinned").GetBoolean());
|
||||
Assert.Equal(JsonValueKind.Null, root.GetProperty("requiredVersion").ValueKind);
|
||||
}
|
||||
|
||||
// ── Connection state transition tests ──
|
||||
|
||||
[Fact]
|
||||
public void Connector_InitialState_IsInitializing()
|
||||
{
|
||||
var connector = new FakeGatewayConnector(
|
||||
GatewayConnectionState.Initializing, null, null, null, 0, null);
|
||||
|
||||
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
|
||||
Assert.Null(connector.GatewayVersion);
|
||||
Assert.Null(connector.LastConnectedAt);
|
||||
Assert.Equal(0, connector.ReconnectAttempts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connector_AfterConnection_ReportsConnected()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var connector = new FakeGatewayConnector(
|
||||
GatewayConnectionState.Connected, "2.103.0", "2.103.0",
|
||||
now, 0, null);
|
||||
|
||||
Assert.Equal(GatewayConnectionState.Connected, connector.ConnectionState);
|
||||
Assert.Equal("2.103.0", connector.GatewayVersion);
|
||||
Assert.Equal("2.103.0", connector.RequiredVersion);
|
||||
Assert.NotNull(connector.LastConnectedAt);
|
||||
Assert.Equal(0, connector.ReconnectAttempts);
|
||||
}
|
||||
|
||||
// ── DI registration tests ──
|
||||
|
||||
[Fact]
|
||||
public void Connector_CanBeRegisteredInDi()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Integrations:OpenClaw:BaseUrl"] = "http://localhost:18789",
|
||||
["Integrations:OpenClaw:RequiredVersion"] = "2.103.0"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(config);
|
||||
services.AddLogging();
|
||||
services.AddHttpClient("gateway");
|
||||
services.AddOptions<GatewayConnectorOptions>()
|
||||
.BindConfiguration(GatewayConnectorOptions.SectionName);
|
||||
services.AddSingleton<IGatewayConnector, GatewayConnector>();
|
||||
|
||||
var sp = services.BuildServiceProvider();
|
||||
var connector = sp.GetRequiredService<IGatewayConnector>();
|
||||
|
||||
Assert.NotNull(connector);
|
||||
Assert.Equal(GatewayConnectionState.Initializing, connector.ConnectionState);
|
||||
}
|
||||
|
||||
// ── HTTP endpoint routing test ──
|
||||
|
||||
[Fact]
|
||||
public void HealthEndpoint_HasAllowAnonymous()
|
||||
{
|
||||
var type = typeof(GatewayHealthController);
|
||||
var attr = type.GetCustomAttributes(true)
|
||||
.OfType<Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(attr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthEndpoint_HasCorrectRoute()
|
||||
{
|
||||
var method = typeof(GatewayHealthController).GetMethod("GetGatewayHealth");
|
||||
Assert.NotNull(method);
|
||||
var routeAttr = method!.GetCustomAttributes(true)
|
||||
.OfType<Microsoft.AspNetCore.Mvc.HttpGetAttribute>()
|
||||
.FirstOrDefault();
|
||||
Assert.NotNull(routeAttr);
|
||||
Assert.Contains("/api/health/gateway", routeAttr!.Template!);
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private static async Task<string> ExecuteAndReadJsonAsync(IResult result)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase);
|
||||
var sp = services.BuildServiceProvider();
|
||||
|
||||
var httpContext = new DefaultHttpContext { RequestServices = sp };
|
||||
// HttpResults.Ok<T> needs a response body to write to
|
||||
httpContext.Response.Body = new MemoryStream();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
httpContext.Response.Body.Position = 0;
|
||||
using var reader = new StreamReader(httpContext.Response.Body);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test double implementing all members of IGatewayConnector.
|
||||
/// </summary>
|
||||
public sealed class FakeGatewayConnector : IGatewayConnector
|
||||
{
|
||||
private readonly GatewayConnectionState _state;
|
||||
private readonly string? _gatewayVersion;
|
||||
private readonly string? _requiredVersion;
|
||||
private readonly DateTimeOffset? _lastConnectedAt;
|
||||
private readonly int _reconnectAttempts;
|
||||
private readonly string? _statusMessage;
|
||||
|
||||
public FakeGatewayConnector(
|
||||
GatewayConnectionState state,
|
||||
string? gatewayVersion,
|
||||
string? requiredVersion,
|
||||
DateTimeOffset? lastConnectedAt,
|
||||
int reconnectAttempts,
|
||||
string? statusMessage)
|
||||
{
|
||||
_state = state;
|
||||
_gatewayVersion = gatewayVersion;
|
||||
_requiredVersion = requiredVersion;
|
||||
_lastConnectedAt = lastConnectedAt;
|
||||
_reconnectAttempts = reconnectAttempts;
|
||||
_statusMessage = statusMessage;
|
||||
}
|
||||
|
||||
public GatewayConnectionState ConnectionState => _state;
|
||||
public string? GatewayVersion => _gatewayVersion;
|
||||
public string? RequiredVersion => _requiredVersion;
|
||||
public DateTimeOffset? LastConnectedAt => _lastConnectedAt;
|
||||
public int ReconnectAttempts => _reconnectAttempts;
|
||||
public string? StatusMessage => _statusMessage;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the MCP server configuration is correct:
|
||||
/// all tools are registered, the streamable-http transport is configured
|
||||
/// via the service extensions, and MapMcp is called in Program.cs.
|
||||
/// </summary>
|
||||
public sealed class McpServerConfigurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void McpServer_CanBeRegistered_WithoutError()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOptions();
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
// Simulate what AddNexusApplicationServices does
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
.WithTools<NexusMcpTools>();
|
||||
|
||||
// Build the container — this should not throw
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Verify the ToolType is properly decorated
|
||||
var attr = typeof(NexusMcpTools).GetCustomAttributes(
|
||||
typeof(McpServerToolTypeAttribute), inherit: false);
|
||||
Assert.Single(attr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpServer_ToolTypeAttribute_IsPresent()
|
||||
{
|
||||
var attr = typeof(NexusMcpTools).GetCustomAttributes(
|
||||
typeof(McpServerToolTypeAttribute), inherit: false);
|
||||
Assert.Single(attr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpEndpoint_MapMcp_IsCalledInProgram()
|
||||
{
|
||||
// Source path relative to the test output directory
|
||||
var programPath = ResolveSourcePath("backend", "Program.cs");
|
||||
Assert.True(File.Exists(programPath), $"Program.cs not found at {programPath}");
|
||||
var source = File.ReadAllText(programPath);
|
||||
Assert.Contains("MapMcp", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpServerRegistration_IsCalledInServiceExtensions()
|
||||
{
|
||||
var extPath = ResolveSourcePath("backend", "Extensions", "ServiceCollectionExtensions.cs");
|
||||
Assert.True(File.Exists(extPath), $"ServiceCollectionExtensions.cs not found at {extPath}");
|
||||
var source = File.ReadAllText(extPath);
|
||||
Assert.Contains("AddMcpServer", source, StringComparison.Ordinal);
|
||||
Assert.Contains("WithHttpTransport", source, StringComparison.Ordinal);
|
||||
Assert.Contains("Stateless", source, StringComparison.Ordinal);
|
||||
Assert.Contains("WithTools<NexusMcpTools>", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NuGetPackage_ModelContextProtocol_AspNetCore_IsReferenced()
|
||||
{
|
||||
var csprojPath = ResolveSourcePath("backend", "Nexus.Api.csproj");
|
||||
Assert.True(File.Exists(csprojPath), $"Nexus.Api.csproj not found at {csprojPath}");
|
||||
var source = File.ReadAllText(csprojPath);
|
||||
Assert.Contains("ModelContextProtocol.AspNetCore", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ResolveSourcePath(params string[] segments)
|
||||
{
|
||||
// Navigate from test output directory to the repo root
|
||||
// Test DLL is at: backend-tests/bin/Debug/net10.0/Nexus.Api.Tests.dll
|
||||
// We go up 4 levels (net10.0 → Debug → bin → backend-tests) to reach repo root
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
var repoRoot = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", ".."));
|
||||
return Path.Combine(new[] { repoRoot }.Concat(segments).ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class McpToolsTests
|
||||
{
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Enum Validation
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTaskState_HasExactlyFiveValidStates()
|
||||
{
|
||||
var values = Enum.GetValues<NexusMcpTaskState>();
|
||||
Assert.Equal(5, values.Length);
|
||||
|
||||
var names = Enum.GetNames<NexusMcpTaskState>();
|
||||
Assert.Contains("Backlog", names);
|
||||
Assert.Contains("InProgress", names);
|
||||
Assert.Contains("Blocked", names);
|
||||
Assert.Contains("Done", names);
|
||||
Assert.Contains("Review", names);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NexusMcpTaskState.Backlog, "Backlog")]
|
||||
[InlineData(NexusMcpTaskState.InProgress, "In progress")]
|
||||
[InlineData(NexusMcpTaskState.Blocked, "Blocked")]
|
||||
[InlineData(NexusMcpTaskState.Done, "Done")]
|
||||
[InlineData(NexusMcpTaskState.Review, "Review")]
|
||||
public void NexusMcpTaskState_MapsToCorrectStateString(NexusMcpTaskState mcpState, string expectedBridgeState)
|
||||
{
|
||||
// Verify the TaskStateHelper roundtrip works
|
||||
string stateString = mcpState 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 ArgumentOutOfRangeException(nameof(mcpState))
|
||||
};
|
||||
|
||||
Assert.Equal(expectedBridgeState, stateString);
|
||||
Assert.True(TaskStateHelper.IsValidState(stateString));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTaskState_EnumValuesMatchTaskStateEnum()
|
||||
{
|
||||
// The MCP state enum must cover exactly the canonical task states
|
||||
foreach (var mcpState in Enum.GetValues<NexusMcpTaskState>())
|
||||
{
|
||||
var taskState = mcpState switch
|
||||
{
|
||||
NexusMcpTaskState.Backlog => TaskState.Backlog,
|
||||
NexusMcpTaskState.InProgress => TaskState.InProgress,
|
||||
NexusMcpTaskState.Blocked => TaskState.Blocked,
|
||||
NexusMcpTaskState.Done => TaskState.Done,
|
||||
NexusMcpTaskState.Review => TaskState.Review,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mcpState))
|
||||
};
|
||||
Assert.True(Enum.IsDefined(taskState));
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Tool Registration
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllRequiredTools_AreRegistered()
|
||||
{
|
||||
var toolMethods = typeof(NexusMcpTools)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null)
|
||||
.Select(m => m.GetCustomAttribute<McpServerToolAttribute>()!.Name!)
|
||||
.OrderBy(n => n)
|
||||
.ToList();
|
||||
|
||||
var expected = new[]
|
||||
{
|
||||
"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"
|
||||
}.OrderBy(n => n).ToList();
|
||||
|
||||
Assert.Equal(expected, toolMethods);
|
||||
Assert.Equal(10, toolMethods.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTools_HasMcpServerToolTypeAttribute()
|
||||
{
|
||||
var attr = typeof(NexusMcpTools).GetCustomAttribute<McpServerToolTypeAttribute>();
|
||||
Assert.NotNull(attr);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllTools_HaveDescriptionAttribute()
|
||||
{
|
||||
var methods = typeof(NexusMcpTools)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null);
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
var desc = method.GetCustomAttribute<DescriptionAttribute>();
|
||||
Assert.NotNull(desc);
|
||||
Assert.False(string.IsNullOrWhiteSpace(desc!.Description),
|
||||
$"Tool {method.Name} is missing a description.");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTools_AllMethodsAreAsync()
|
||||
{
|
||||
var methods = typeof(NexusMcpTools)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.Where(m => m.GetCustomAttribute<McpServerToolAttribute>() is not null);
|
||||
|
||||
foreach (var method in methods)
|
||||
{
|
||||
Assert.True(
|
||||
method.ReturnType.Name.StartsWith("Task") ||
|
||||
method.ReturnType.Name.StartsWith("ValueTask"),
|
||||
$"Tool {method.Name} does not return Task.");
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Tool Behavior via Fixture (integration-style)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateTask_ReturnsSuccess_ForValidInput()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var result = await fixture.Tools.CreateTask("MCP Test Task", "MCP detail", "High", "iris");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("nexus_create_task", result.Command);
|
||||
Assert.NotNull(result.Data);
|
||||
Assert.Null(result.Error);
|
||||
Assert.Equal("MCP Test Task", result.Data!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTask_ReturnsSuccess_ForValidParent()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var parent = await fixture.Tools.CreateTask("Parent Task", "Parent detail");
|
||||
Assert.True(parent.Ok && parent.Data is not null);
|
||||
|
||||
var child = await fixture.Tools.CreateChildTask(
|
||||
parent.Data!.Id, "Child Task", "Child detail", "Normal", "programmer");
|
||||
|
||||
Assert.True(child.Ok);
|
||||
Assert.Equal("nexus_create_child_task", child.Command);
|
||||
Assert.NotNull(child.Data);
|
||||
Assert.Equal("Child Task", child.Data!.Title);
|
||||
Assert.Equal(parent.Data.Id, child.Data.ParentTaskId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTask_ReturnsError_ForMissingParent()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var result = await fixture.Tools.CreateChildTask(
|
||||
Guid.NewGuid(), "Orphan Child");
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetBoard_ReturnsGroupedTasks()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
// Create test tasks
|
||||
await fixture.Tools.CreateTask("Board Task 1", assignedTo: "iris");
|
||||
await fixture.Tools.CreateTask("Board Task 2", assignedTo: "programmer");
|
||||
|
||||
var board = await fixture.Tools.GetBoard();
|
||||
|
||||
Assert.NotNull(board);
|
||||
Assert.NotNull(board.Offen);
|
||||
Assert.NotNull(board.InProgress);
|
||||
Assert.NotNull(board.Review);
|
||||
Assert.NotNull(board.Blocked);
|
||||
Assert.NotNull(board.Done);
|
||||
Assert.True(board.Offen.Count >= 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ReturnsTask_WhenFound()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var created = await fixture.Tools.CreateTask("GetTask Test");
|
||||
Assert.True(created.Ok && created.Data is not null);
|
||||
|
||||
var fetched = await fixture.Tools.GetTask(created.Data.Id);
|
||||
|
||||
Assert.True(fetched.Ok);
|
||||
Assert.Equal("GetTask Test", fetched.Data!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTask_ReturnsError_WhenNotFound()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var result = await fixture.Tools.GetTask(Guid.NewGuid());
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("not found", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetChildren_ReturnsChildTasks()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var parent = await fixture.Tools.CreateTask("Parent for Children");
|
||||
Assert.True(parent.Ok && parent.Data is not null);
|
||||
|
||||
await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 1");
|
||||
await fixture.Tools.CreateChildTask(parent.Data.Id, "Child 2");
|
||||
|
||||
var children = await fixture.Tools.GetChildren(parent.Data.Id);
|
||||
|
||||
Assert.NotNull(children);
|
||||
Assert.Equal(2, children.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_AdvancesState()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var task = await fixture.Tools.CreateTask("Status Test");
|
||||
Assert.True(task.Ok && task.Data is not null);
|
||||
Assert.Equal("Backlog", task.Data.State);
|
||||
|
||||
var inProgress = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress);
|
||||
Assert.True(inProgress.Ok);
|
||||
Assert.Equal("In progress", inProgress.Data!.State);
|
||||
|
||||
var done = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.Done);
|
||||
Assert.True(done.Ok);
|
||||
Assert.Equal("Done", done.Data!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateStatus_Unauthorized_ForSubAgent()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("programmer");
|
||||
|
||||
var task = await fixture.Tools.CreateTask("SubAgent Status Test");
|
||||
Assert.True(task.Ok && task.Data is not null);
|
||||
|
||||
// The programmer creates the task fine, but cannot change status
|
||||
var result = await fixture.Tools.UpdateStatus(task.Data.Id, NexusMcpTaskState.InProgress);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NexusMcpTaskState.Backlog)]
|
||||
[InlineData(NexusMcpTaskState.InProgress)]
|
||||
[InlineData(NexusMcpTaskState.Review)]
|
||||
[InlineData(NexusMcpTaskState.Blocked)]
|
||||
[InlineData(NexusMcpTaskState.Done)]
|
||||
public async Task UpdateStatus_AcceptsAllValidStates(NexusMcpTaskState state)
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var task = await fixture.Tools.CreateTask($"StateTest-{state}");
|
||||
Assert.True(task.Ok && task.Data is not null);
|
||||
|
||||
var result = await fixture.Tools.UpdateStatus(task.Data.Id, state);
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendActivity_WritesActivityEntry()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var task = await fixture.Tools.CreateTask("Activity Test");
|
||||
Assert.True(task.Ok && task.Data is not null);
|
||||
|
||||
var result = await fixture.Tools.AppendActivity(task.Data.Id, "Test checkpoint", "checkpoint");
|
||||
Assert.True(result.Ok);
|
||||
Assert.NotNull(result.Data);
|
||||
Assert.Equal("Test checkpoint", result.Data!.Message);
|
||||
|
||||
var activities = await fixture.Tools.GetActivity(task.Data.Id);
|
||||
Assert.NotEmpty(activities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handoff_UpdatesExpectedFrom()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var task = await fixture.Tools.CreateTask("Handoff Test");
|
||||
Assert.True(task.Ok && task.Data is not null);
|
||||
|
||||
var result = await fixture.Tools.Handoff(task.Data.Id, "programmer", "Please implement");
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("programmer", result.Data!.ExpectedFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentOverview_ReturnsGroupedWorkflow()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
|
||||
var overview = await fixture.Tools.GetAgentOverview(staleHours: 2);
|
||||
|
||||
Assert.NotNull(overview);
|
||||
Assert.NotNull(overview.WaitingForBao);
|
||||
Assert.NotNull(overview.WaitingForIris);
|
||||
Assert.NotNull(overview.WaitingForOthers);
|
||||
Assert.NotNull(overview.StaleTasks);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Auth Resolution
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_AcceptsValidXAgentId()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("programmer");
|
||||
|
||||
// Simply verify a tool call succeeds with a valid agent header
|
||||
var result = await fixture.Tools.CreateTask("Auth Test via header");
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_AcceptsJwtClaim()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerUser("iris", "member");
|
||||
|
||||
var result = await fixture.Tools.CreateTask("Auth Test via JWT");
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_AcceptsOwnerRole()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerUser("owner", "owner");
|
||||
|
||||
var result = await fixture.Tools.CreateTask("Auth Test via owner JWT");
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_AcceptsServiceKey()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerServiceKey("test-service-key");
|
||||
|
||||
var result = await fixture.Tools.CreateTask("Auth Test via service key");
|
||||
Assert.True(result.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_RejectsUnknownAgentId()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("hacker");
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => fixture.Tools.CreateTask("Should fail"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveCaller_RejectsMissingAuth()
|
||||
{
|
||||
await using var fixture = await McpToolsFixture.CreateAsync();
|
||||
// No auth set = should reject
|
||||
|
||||
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||
() => fixture.Tools.CreateTask("Should fail"));
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Test Fixture
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
internal sealed class McpToolsFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
|
||||
private McpToolsFixture(
|
||||
NexusDbContext db,
|
||||
NexusMcpTools tools,
|
||||
HttpContextAccessor httpContextAccessor,
|
||||
ITaskBridgeService taskBridgeService,
|
||||
IAgentService agentService)
|
||||
{
|
||||
_db = db;
|
||||
Tools = tools;
|
||||
HttpContextAccessor = httpContextAccessor;
|
||||
TaskBridgeService = taskBridgeService;
|
||||
AgentService = agentService;
|
||||
}
|
||||
|
||||
public NexusMcpTools Tools { get; }
|
||||
public HttpContextAccessor HttpContextAccessor { get; }
|
||||
public ITaskBridgeService TaskBridgeService { get; }
|
||||
public IAgentService AgentService { get; }
|
||||
|
||||
public static async Task<McpToolsFixture> 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();
|
||||
|
||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository, activityRepository, liveUpdateService);
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService);
|
||||
|
||||
var taskBridgeService = new TaskBridgeService(
|
||||
taskService,
|
||||
agentService,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService);
|
||||
|
||||
var logger = NullLogger<NexusMcpTools>.Instance;
|
||||
|
||||
var tools = new NexusMcpTools(
|
||||
taskBridgeService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
configuration,
|
||||
logger);
|
||||
|
||||
return new McpToolsFixture(db, tools, httpContextAccessor, taskBridgeService, agentService);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
}
|
||||
|
||||
public void SetCallerAgent(string agentId)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Agent-Id"] = agentId;
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
HttpContextAccessor.HttpContext = httpContext;
|
||||
}
|
||||
|
||||
public void SetCallerUser(string userId, string role)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
};
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
HttpContextAccessor.HttpContext = httpContext;
|
||||
}
|
||||
|
||||
public void SetCallerServiceKey(string key)
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.Role, "Service") };
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Headers["X-Nexus-Api-Key"] = key;
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "ApiKey"));
|
||||
HttpContextAccessor.HttpContext = httpContext;
|
||||
}
|
||||
|
||||
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" },
|
||||
{ "id": "programmer", "name": "programmer" },
|
||||
{ "id": "programmer-fast", "name": "programmer-fast" },
|
||||
{ "id": "reviewer", "name": "reviewer" },
|
||||
{ "id": "architekt", "name": "architekt" },
|
||||
{ "id": "executor", "name": "executor" },
|
||||
{ "id": "researcher", "name": "researcher" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<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);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public class OperationsSnapshotTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetSnapshot_RequiresAuthorization()
|
||||
{
|
||||
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.NotNull(method);
|
||||
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
|
||||
{
|
||||
var guard = new RepositoryConcurrencyGuard();
|
||||
var runtime = new SnapshotRuntimeStub();
|
||||
var agentService = new SnapshotAgentServiceStub();
|
||||
var projectRepo = new GuardedProjectRepository(guard);
|
||||
var taskRepo = new GuardedTaskRepository(guard);
|
||||
var activityRepo = new GuardedActivityRepository(guard);
|
||||
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
|
||||
|
||||
await service.GetSnapshotAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, guard.MaxConcurrentCalls);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RepositoryConcurrencyGuard
|
||||
{
|
||||
private readonly Lock sync = new();
|
||||
private int currentCalls;
|
||||
|
||||
public int MaxConcurrentCalls { get; private set; }
|
||||
|
||||
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
currentCalls++;
|
||||
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(25, ct);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
currentCalls--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
|
||||
{
|
||||
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||
=> guard.RunAsync(new List<Project>
|
||||
{
|
||||
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
|
||||
}, ct);
|
||||
|
||||
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
|
||||
{
|
||||
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> guard.RunAsync(new List<WorkTask>
|
||||
{
|
||||
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
|
||||
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
|
||||
}, ct);
|
||||
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
|
||||
{
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> guard.RunAsync(new List<ActivityEvent>
|
||||
{
|
||||
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||
}, 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)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class SnapshotRuntimeStub : IAgentRuntime
|
||||
{
|
||||
public string Name => "stub";
|
||||
|
||||
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
|
||||
|
||||
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class SnapshotAgentServiceStub : IAgentService
|
||||
{
|
||||
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
|
||||
[
|
||||
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
|
||||
]);
|
||||
|
||||
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class StaleTaskRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
|
||||
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Stale in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
await fixture.ActivityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "comment",
|
||||
Message = "Previous agent note",
|
||||
TaskId = staleInProgress.Id,
|
||||
CreatedAt = staleTimestamp.AddMinutes(15)
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Blocked task",
|
||||
State = "Blocked",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Review task",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Done task",
|
||||
State = "Done",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Backlog task",
|
||||
State = "Backlog",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Fresh in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, resetCount);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
|
||||
|
||||
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
|
||||
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
|
||||
|
||||
Assert.NotNull(resetActivity);
|
||||
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
|
||||
{
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
var taskId = Guid.NewGuid();
|
||||
var staleCandidate = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var currentTask = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
|
||||
var activityRepository = new FakeActivityRepository();
|
||||
var liveUpdateService = new FakeLiveUpdateService();
|
||||
var recoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService);
|
||||
|
||||
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, resetCount);
|
||||
Assert.Equal("Review", currentTask.State);
|
||||
Assert.Equal(0, taskRepository.ResetCount);
|
||||
Assert.Empty(activityRepository.Added);
|
||||
Assert.Equal(0, liveUpdateService.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StaleHours = 4,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, fakeRecoveryService.CallCount);
|
||||
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
|
||||
Assert.Equal(7, resetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StaleHours = 2,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await backgroundService.StartAsync(cts.Token);
|
||||
await firstCall.Task.WaitAsync(cts.Token);
|
||||
await backgroundService.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(fakeRecoveryService.CallCount >= 1);
|
||||
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
|
||||
{
|
||||
const string key = "TaskRecovery__StaleHours";
|
||||
var originalValue = Environment.GetEnvironmentVariable(key);
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, "5");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
|
||||
})
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
|
||||
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal(5, options!.StaleHours);
|
||||
Assert.Equal(30, options.IntervalMinutes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, originalValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
|
||||
{
|
||||
public int ResetCount { get; private set; }
|
||||
|
||||
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask> { staleCandidate });
|
||||
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
|
||||
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (id != currentTask.Id
|
||||
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||
|| currentTask.UpdatedAt >= staleBefore)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
ResetCount++;
|
||||
currentTask.State = "Backlog";
|
||||
currentTask.UpdatedAt = updatedAt;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask>());
|
||||
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.FromResult(task);
|
||||
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<WorkTask?>(null);
|
||||
}
|
||||
|
||||
file sealed class FakeActivityRepository : IActivityRepository
|
||||
{
|
||||
public List<ActivityEvent> Added { get; } = [];
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type,
|
||||
string? sort,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult((new List<ActivityEvent>(), 0));
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
Added.Add(activity);
|
||||
return Task.FromResult(activity);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeLiveUpdateService : ILiveUpdateService
|
||||
{
|
||||
public int PublishCount { get; private set; }
|
||||
public long CurrentSequence => PublishCount;
|
||||
|
||||
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||
{
|
||||
PublishCount++;
|
||||
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
public TimeSpan LastThreshold { get; private set; }
|
||||
public Action? OnCall { get; set; }
|
||||
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
LastThreshold = staleThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
||||
{
|
||||
public T CurrentValue { get; private set; } = currentValue;
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
@@ -0,0 +1,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,558 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class TaskWorkflowTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
||||
"PO spec",
|
||||
"Prepare specification",
|
||||
"iris",
|
||||
"Medium",
|
||||
"product-owner",
|
||||
"programmer-fast",
|
||||
parent.Id,
|
||||
startsInProgress: false,
|
||||
initialState: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.Equal("Backlog", child.State);
|
||||
Assert.Equal("product-owner", child.AssignedTo);
|
||||
Assert.Equal("programmer-fast", child.ExpectedFrom);
|
||||
Assert.True(child.IsAgentTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var child = await fixture.TaskService.CreateAgentTaskAsync(
|
||||
"Implement",
|
||||
"Code changes",
|
||||
"iris",
|
||||
"High",
|
||||
"programmer-fast",
|
||||
"programmer-fast",
|
||||
parent.Id,
|
||||
startsInProgress: false,
|
||||
initialState: null,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(dto);
|
||||
Assert.True(dto!.HasVisibleDelegation);
|
||||
Assert.NotNull(dto.LastActivityMessage);
|
||||
Assert.Equal("programmer-fast", dto.AssignedTo);
|
||||
Assert.Equal("programmer-fast", dto.ExpectedFrom);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
await fixture.TaskBridgeService.CreateChildTaskAsync(
|
||||
parent.Id,
|
||||
"Review",
|
||||
"Review implementation",
|
||||
"iris",
|
||||
"Medium",
|
||||
"reviewer",
|
||||
"reviewer",
|
||||
startsInProgress: false,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
|
||||
var child = Assert.Single(children);
|
||||
|
||||
Assert.True(child.HasVisibleDelegation);
|
||||
Assert.NotNull(child.LastActivityMessage);
|
||||
Assert.Equal("reviewer", child.AssignedTo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "programmer-fast"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new DashboardController(
|
||||
new FakeDashboardService(),
|
||||
fixture.TaskService,
|
||||
fixture.ActivityRepository,
|
||||
new HttpContextAccessor(),
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
fixture.NotificationService,
|
||||
fixture.LiveUpdateService)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "programmer-fast"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext()
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "unknown-agent"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<UnauthorizedObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new GatewayBridgeController(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
NullLogger<GatewayBridgeController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
Assert.IsType<OkObjectResult>(result.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("programmer-fast");
|
||||
|
||||
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
|
||||
|
||||
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
|
||||
parent.Id,
|
||||
"Implement",
|
||||
"Ship the change",
|
||||
"programmer-fast",
|
||||
"Medium",
|
||||
"programmer-fast",
|
||||
"programmer-fast",
|
||||
startsInProgress: false,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
|
||||
Assert.NotNull(updatedParent);
|
||||
Assert.Equal("In progress", updatedParent!.State);
|
||||
}
|
||||
|
||||
private static void AssertStatusCode(IResult result, int expectedStatusCode)
|
||||
{
|
||||
if (expectedStatusCode == StatusCodes.Status403Forbidden)
|
||||
{
|
||||
Assert.Equal("Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult", result.GetType().FullName);
|
||||
return;
|
||||
}
|
||||
|
||||
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(expectedStatusCode, statusResult.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
|
||||
private TaskWorkflowFixture(
|
||||
NexusDbContext db,
|
||||
IConfiguration configuration,
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
IStaleTaskRecoveryService staleTaskRecoveryService,
|
||||
ITaskService taskService,
|
||||
ITaskBridgeService taskBridgeService,
|
||||
IAgentService agentService,
|
||||
HttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_db = db;
|
||||
Configuration = configuration;
|
||||
TaskRepository = taskRepository;
|
||||
ActivityRepository = activityRepository;
|
||||
NotificationService = notificationService;
|
||||
LiveUpdateService = liveUpdateService;
|
||||
StaleTaskRecoveryService = staleTaskRecoveryService;
|
||||
TaskService = taskService;
|
||||
TaskBridgeService = taskBridgeService;
|
||||
AgentService = agentService;
|
||||
HttpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
public ITaskRepository TaskRepository { get; }
|
||||
public IActivityRepository ActivityRepository { get; }
|
||||
public INotificationService NotificationService { get; }
|
||||
public ILiveUpdateService LiveUpdateService { get; }
|
||||
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
|
||||
public ITaskService TaskService { get; }
|
||||
public ITaskBridgeService TaskBridgeService { get; }
|
||||
public IAgentService AgentService { get; }
|
||||
public HttpContextAccessor HttpContextAccessor { get; }
|
||||
|
||||
public static async Task<TaskWorkflowFixture> CreateAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = configPath,
|
||||
["NexusApiKey"] = "test-service-key"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||
var liveUpdateService = new LiveUpdateService();
|
||||
var activityRepository = new ActivityRepository(db, liveUpdateService);
|
||||
var taskRepository = new TaskRepository(db);
|
||||
var notificationService = new NotificationService(db, liveUpdateService);
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService);
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService);
|
||||
|
||||
var taskBridgeService = new TaskBridgeService(
|
||||
taskService,
|
||||
agentService,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService);
|
||||
|
||||
return new TaskWorkflowFixture(
|
||||
db,
|
||||
configuration,
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService,
|
||||
taskService,
|
||||
taskBridgeService,
|
||||
agentService,
|
||||
httpContextAccessor);
|
||||
}
|
||||
|
||||
public static DefaultHttpContext CreateHttpContext(
|
||||
string? agentId = null,
|
||||
Dictionary<string, string>? headers = null,
|
||||
ClaimsPrincipal? user = null)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
if (!string.IsNullOrWhiteSpace(agentId))
|
||||
httpContext.Request.Headers["X-Agent-Id"] = agentId;
|
||||
|
||||
if (headers is not null)
|
||||
{
|
||||
foreach (var (key, value) in headers)
|
||||
httpContext.Request.Headers[key] = value;
|
||||
}
|
||||
|
||||
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
return httpContext;
|
||||
}
|
||||
|
||||
public static ClaimsPrincipal CreateUser(string userId, string role)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId),
|
||||
new Claim(ClaimTypes.Role, role)
|
||||
};
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
}
|
||||
|
||||
public void SetCallerAgent(string agentId)
|
||||
{
|
||||
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _db.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string CreateAgentConfigFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||
File.WriteAllText(path,
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "/workspace/default",
|
||||
"model": {
|
||||
"primary": "deepseek/deepseek-v4-flash"
|
||||
}
|
||||
},
|
||||
"list": [
|
||||
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
|
||||
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
|
||||
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
|
||||
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeDashboardService : IDashboardService
|
||||
{
|
||||
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
|
||||
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
|
||||
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
|
||||
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
|
||||
public List<ModelOption> GetAvailableModels() => [];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
.vscode/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
*.log
|
||||
@@ -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,8 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.Data;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
@@ -15,6 +15,8 @@ public class AgentsController(
|
||||
IAgentService agentService,
|
||||
IAgentRuntime runtime,
|
||||
IActivityRepository activityRepo,
|
||||
IAgentConfigService agentConfigService,
|
||||
IDashboardService dashboardService,
|
||||
ILogger<AgentsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -22,8 +24,7 @@ public class AgentsController(
|
||||
{
|
||||
var agents = await agentService.GetAgentsAsync(ct);
|
||||
return Results.Ok(agents.Select(a => new AgentListResponse(
|
||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
|
||||
)));
|
||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -34,15 +35,32 @@ public class AgentsController(
|
||||
return Results.Ok(new AgentDetailResponse(
|
||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
||||
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
||||
agent.SubAgents, agent.IdentityName
|
||||
));
|
||||
agent.SubAgents, agent.IdentityName));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/activity")]
|
||||
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
|
||||
{
|
||||
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
|
||||
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }));
|
||||
var activity = items
|
||||
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
|
||||
.ToList();
|
||||
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
|
||||
foreach (var entry in gatewayEntries)
|
||||
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
|
||||
|
||||
return Results.Ok(activity
|
||||
.OrderByDescending(x => x.At)
|
||||
.Take(50));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/summary")]
|
||||
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
|
||||
{
|
||||
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
|
||||
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/command")]
|
||||
@@ -58,9 +76,7 @@ public class AgentsController(
|
||||
try
|
||||
{
|
||||
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -73,79 +89,168 @@ public class AgentsController(
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Agent Config Editor ==========
|
||||
// ── Config Editor ──
|
||||
|
||||
[HttpGet("{id}/config")]
|
||||
public IResult GetConfig(string id)
|
||||
{
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||
};
|
||||
|
||||
var files = Directory.GetFiles(workspacePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(f => allowedFiles.Contains(f.Name))
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(f => new
|
||||
{
|
||||
fileName = f.Name,
|
||||
size = f.Length,
|
||||
modifiedAt = f.LastWriteTimeUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(files);
|
||||
}
|
||||
=> Results.Ok(agentConfigService.GetConfigFiles(id));
|
||||
|
||||
[HttpGet("{id}/config/{fileName}")]
|
||||
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
|
||||
var fi = new FileInfo(safePath!);
|
||||
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
|
||||
return file is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
|
||||
}
|
||||
|
||||
[HttpPut("{id}/config/{fileName}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
||||
|
||||
if (request.Content is null)
|
||||
return Results.BadRequest(new { error = "Content is required." });
|
||||
|
||||
if (request.Content.Length > 500 * 1024)
|
||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{id}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return Results.NotFound();
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
try
|
||||
{
|
||||
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
|
||||
System.IO.File.Move(tempPath, safePath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
|
||||
throw;
|
||||
}
|
||||
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
var caller = DescribeCaller(HttpContext.User);
|
||||
|
||||
var fi = new FileInfo(safePath);
|
||||
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
if (attempt.Failure is not null)
|
||||
{
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
|
||||
}, ct);
|
||||
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["content"] = attempt.Failure.Validation.Errors.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var result = attempt.SaveResult!;
|
||||
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.FileName,
|
||||
result.Size,
|
||||
result.ModifiedAt,
|
||||
result.Validation,
|
||||
result.Backup,
|
||||
ReloadCheck = result.ReloadCheck
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
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 Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -14,7 +15,8 @@ public class AuthController(
|
||||
IAuthService authService,
|
||||
IAntiforgery antiforgery,
|
||||
IConfiguration config,
|
||||
IHostEnvironment env) : ControllerBase
|
||||
IHostEnvironment env,
|
||||
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||
{
|
||||
[HttpGet("csrf")]
|
||||
public IActionResult GetCsrfToken()
|
||||
@@ -30,11 +32,38 @@ public class AuthController(
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
||||
|
||||
var session = await authService.LoginAsync(request, ct);
|
||||
if (session is null) return Results.Unauthorized();
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
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);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
@@ -54,6 +83,8 @@ public class AuthController(
|
||||
|
||||
SetRefreshCookie(Response, session.RefreshToken);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
@@ -91,6 +122,23 @@ public class AuthController(
|
||||
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
|
||||
}
|
||||
|
||||
[HttpPost("admin-reset-password")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.NewPassword) || string.IsNullOrWhiteSpace(request.AdminToken))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["request"] = ["Email, new password, and admin token are required."] });
|
||||
|
||||
if (request.NewPassword.Length < 10)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["newPassword"] = ["New password must be at least 10 characters."] });
|
||||
|
||||
var success = await authService.AdminResetPasswordAsync(request.Email, request.NewPassword, request.AdminToken, ct);
|
||||
if (!success)
|
||||
return Results.Problem("Password reset failed. Check the admin token, email, and that the user exists.", statusCode: 400);
|
||||
|
||||
return Results.Ok(new { message = "Password reset successfully." });
|
||||
}
|
||||
|
||||
[HttpPost("change-password")]
|
||||
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -1,80 +1,17 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/calendar")]
|
||||
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
|
||||
public class CalendarController(ICalendarService calendarService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
{
|
||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
||||
|
||||
var response = await httpClient.GetAsync("/api/cron", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||
return Results.Ok(data ?? new List<CronJobEntry>());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
|
||||
}
|
||||
|
||||
var fallbackJobs = new List<object>
|
||||
{
|
||||
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
|
||||
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
|
||||
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
|
||||
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
|
||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
|
||||
};
|
||||
return Results.Ok(fallbackJobs);
|
||||
}
|
||||
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
|
||||
|
||||
[HttpGet("upcoming")]
|
||||
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
||||
{
|
||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
||||
httpClient.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
||||
|
||||
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||
return Results.Ok(data ?? new List<UpcomingCronEntry>());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var fallback = new List<object>
|
||||
{
|
||||
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
|
||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
|
||||
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
|
||||
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
|
||||
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
|
||||
};
|
||||
return Results.Ok(fallback);
|
||||
}
|
||||
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.DTOs;
|
||||
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/chat")]
|
||||
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
public class DashboardController(
|
||||
IDashboardService dashboardService,
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<DashboardStatus> GetStatus()
|
||||
=> await dashboardService.GetStatusAsync();
|
||||
|
||||
[HttpGet("agents")]
|
||||
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||
=> await dashboardService.GetAgentsAsync();
|
||||
|
||||
[HttpGet("operations")]
|
||||
public async Task<List<FeedEntry>> GetOperations(
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string? agent = null)
|
||||
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||
|
||||
[HttpPost("chat/send")]
|
||||
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
return new ChatResponse(false, null, "Message is required");
|
||||
|
||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||
}
|
||||
|
||||
[HttpGet("chat/messages")]
|
||||
public async Task<List<MessageEntry>> GetMessages(
|
||||
[FromQuery] string? sessionKey,
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] int offset = 0)
|
||||
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||
|
||||
[HttpGet("queue")]
|
||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||
=> await dashboardService.GetQueueAsync(ct);
|
||||
|
||||
[HttpGet("gateway")]
|
||||
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
|
||||
=> await dashboardService.GetGatewayInfoAsync(ct);
|
||||
|
||||
[HttpDelete("queue/{id}")]
|
||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
QueueDeleteOutcome.Deleted => NoContent(),
|
||||
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
|
||||
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
|
||||
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => StatusCode(500, new { error = "Internal error" })
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPut("queue/{id}/priority")]
|
||||
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||
{
|
||||
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
|
||||
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||
_ => Ok(new { status = "ok", priority = result.NewPriority })
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("agents/{id}/model")]
|
||||
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||
{
|
||||
var info = await dashboardService.GetAgentModelAsync(id);
|
||||
return info is null
|
||||
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||
: Ok(info);
|
||||
}
|
||||
|
||||
[HttpPut("agents/{id}/model")]
|
||||
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "Model is required" });
|
||||
|
||||
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
|
||||
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||
}
|
||||
|
||||
[HttpGet("agents/{id}/activity")]
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||
|
||||
[HttpGet("models")]
|
||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||
=> Ok(dashboardService.GetAvailableModels());
|
||||
|
||||
// ── Task Endpoints ──
|
||||
|
||||
[HttpGet("tasks")]
|
||||
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||
{
|
||||
var tasks = await taskService.GetOpenAsync(ct);
|
||||
return tasks.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
[HttpPost("tasks")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return BadRequest(new { error = "Title is required." });
|
||||
|
||||
try
|
||||
{
|
||||
var task = await taskService.CreateDashboardTaskAsync(
|
||||
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
|
||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.UpdateDashboardTaskAsync(
|
||||
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpDelete("tasks/{id:guid}")]
|
||||
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
|
||||
_ => NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{id:guid}/status")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||
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);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
// ── Task Board Endpoints ──
|
||||
|
||||
[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));
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
|
||||
if (completed == readTask)
|
||||
{
|
||||
var envelope = await readTask;
|
||||
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 if (await heartbeatTask)
|
||||
{
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
var children = board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done)
|
||||
.Where(task => task.ParentTaskId == id)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(children);
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/docs")]
|
||||
public class DocsController : ControllerBase
|
||||
public class DocsController(IDocService docService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IResult GetAll()
|
||||
{
|
||||
var workspaceRoot = "/mnt/workspace-iris";
|
||||
var results = new List<object>();
|
||||
|
||||
void ScanDir(string dir, string category)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
|
||||
continue;
|
||||
var fi = new FileInfo(file);
|
||||
results.Add(new
|
||||
{
|
||||
name = fi.Name,
|
||||
path = file.Replace(workspaceRoot, "").TrimStart('/'),
|
||||
category,
|
||||
type = ext.Replace(".", ""),
|
||||
size = fi.Length,
|
||||
modifiedAt = fi.LastWriteTimeUtc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
|
||||
ScanDir("/mnt/workspace-iris/skills", "skills");
|
||||
ScanDir("/mnt/workspace-iris", "workspace");
|
||||
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
|
||||
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
|
||||
|
||||
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
|
||||
}
|
||||
=> Results.Ok(docService.GetAll());
|
||||
|
||||
[HttpGet("{**path}")]
|
||||
public async Task<IResult> GetFile(string path)
|
||||
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return Results.BadRequest("Path required.");
|
||||
|
||||
string? resolvedPath = null;
|
||||
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
|
||||
{
|
||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
|
||||
{
|
||||
resolvedPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
|
||||
var fi = new FileInfo(resolvedPath);
|
||||
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
var file = await docService.GetFileAsync(path);
|
||||
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Health-check endpoint for the Gateway WebSocket connection.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
public class GatewayHealthController(IGatewayConnector connector) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the current Gateway WebSocket connection health.
|
||||
/// </summary>
|
||||
[HttpGet("/api/health/gateway")]
|
||||
public IResult GetGatewayHealth()
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = connector.ConnectionState.ToString().ToLowerInvariant(),
|
||||
connected = connector.ConnectionState == GatewayConnectionState.Connected,
|
||||
gatewayVersion = connector.GatewayVersion ?? "unknown",
|
||||
requiredVersion = connector.RequiredVersion,
|
||||
versionPinned = connector.RequiredVersion is not null,
|
||||
lastConnectedAt = connector.LastConnectedAt?.ToString("o"),
|
||||
reconnectAttempts = connector.ReconnectAttempts,
|
||||
message = connector.StatusMessage,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Nexus.Api.Integrations;
|
||||
@@ -7,6 +8,32 @@ namespace Nexus.Api.Controllers;
|
||||
[ApiController]
|
||||
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("/health/live")]
|
||||
public IResult Live()
|
||||
{
|
||||
var agentCount = 0;
|
||||
try
|
||||
{
|
||||
var path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(
|
||||
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? "/app",
|
||||
"..");
|
||||
var configPath = "/home/node/.openclaw/agents-sanitized.json";
|
||||
if (System.IO.File.Exists(configPath))
|
||||
{
|
||||
var json = System.IO.File.ReadAllText(configPath);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("agents", out var agentsEl)
|
||||
&& agentsEl.TryGetProperty("list", out var listEl))
|
||||
agentCount = listEl.GetArrayLength();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow, agentCount });
|
||||
}
|
||||
|
||||
[HttpGet("/health")]
|
||||
public async Task<IResult> Get(CancellationToken ct)
|
||||
{
|
||||
@@ -26,20 +53,25 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
||||
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(
|
||||
e => e.Key,
|
||||
e => new
|
||||
{
|
||||
status = e.Value.Status.ToString(),
|
||||
description = e.Value.Description,
|
||||
data = e.Value.Data
|
||||
data = NormalizeData(e.Value.Data)
|
||||
});
|
||||
|
||||
entries["runtime"] = new
|
||||
{
|
||||
status = runtimeStatus,
|
||||
description = runtimeDetail ?? "Runtime status checked",
|
||||
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
|
||||
description = runtimeDetail,
|
||||
data = new Dictionary<string, object?>() as IReadOnlyDictionary<string, object?>
|
||||
};
|
||||
|
||||
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
||||
|
||||
@@ -1,100 +1,20 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using System.Text.RegularExpressions;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/incidents")]
|
||||
public class IncidentsController : ControllerBase
|
||||
public class IncidentsController(IIncidentService incidentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll()
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
||||
if (!Directory.Exists(basePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var incidents = new List<object>();
|
||||
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > 1_000_000) continue;
|
||||
var name = Path.GetFileNameWithoutExtension(file);
|
||||
var content = await System.IO.File.ReadAllTextAsync(file);
|
||||
|
||||
var title = name;
|
||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
||||
if (titleMatch.Success)
|
||||
title = titleMatch.Groups[1].Value.Trim();
|
||||
|
||||
var date = (string?)null;
|
||||
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
|
||||
if (dateMatch.Success)
|
||||
date = dateMatch.Groups[1].Value;
|
||||
|
||||
var severity = "unknown";
|
||||
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
|
||||
if (severityMatch.Success)
|
||||
severity = severityMatch.Groups[1].Value.Trim();
|
||||
|
||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||
var excerpt = excerptEnd > 0
|
||||
? content[..excerptEnd].Trim()
|
||||
: content[..Math.Min(300, content.Length)].Trim();
|
||||
if (excerpt.Length > 200)
|
||||
excerpt = excerpt[..200] + "\u2026";
|
||||
|
||||
incidents.Add(new
|
||||
{
|
||||
name = Path.GetFileName(file),
|
||||
title,
|
||||
date,
|
||||
severity,
|
||||
excerpt,
|
||||
size = fi.Length
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(incidents);
|
||||
}
|
||||
=> Results.Ok(await incidentService.GetAllAsync());
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public async Task<IResult> GetOne(string name)
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
|
||||
return Results.BadRequest("Invalid filename.");
|
||||
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
{
|
||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
filePath = Path.Combine(basePath, name + ".md");
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
||||
var fi = new FileInfo(filePath!);
|
||||
var fileName = Path.GetFileName(filePath!);
|
||||
|
||||
var title = fileName;
|
||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
||||
if (titleMatch.Success)
|
||||
title = titleMatch.Groups[1].Value.Trim();
|
||||
|
||||
var date = (string?)null;
|
||||
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
|
||||
if (dateMatch.Success)
|
||||
date = dateMatch.Groups[1].Value;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
name = fileName,
|
||||
title,
|
||||
date,
|
||||
content,
|
||||
size = fi.Length
|
||||
});
|
||||
var incident = await incidentService.GetByNameAsync(name);
|
||||
return incident is null ? Results.NotFound() : Results.Ok(incident);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/memory")]
|
||||
public class MemoryController : ControllerBase
|
||||
public class MemoryController(IMemoryService memoryService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public IResult GetAll()
|
||||
{
|
||||
var basePath = "/mnt/workspace-iris/memory";
|
||||
if (!Directory.Exists(basePath))
|
||||
return Results.Ok(Array.Empty<object>());
|
||||
|
||||
var files = Directory.GetFiles(basePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.Name)
|
||||
.Select(f => new
|
||||
{
|
||||
name = f.Name,
|
||||
path = f.FullName.Replace(basePath, "").TrimStart('/'),
|
||||
size = f.Length,
|
||||
modifiedAt = f.LastWriteTimeUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (System.IO.File.Exists(longTermPath))
|
||||
{
|
||||
var fi = new FileInfo(longTermPath);
|
||||
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
||||
}
|
||||
|
||||
return Results.Ok(files);
|
||||
}
|
||||
public async Task<IResult> GetAll()
|
||||
=> Results.Ok(await memoryService.GetAllAsync());
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IResult> Search([FromQuery] string q)
|
||||
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||
return Results.BadRequest("Query must be at least 2 characters.");
|
||||
|
||||
var basePath = "/mnt/workspace-iris/memory";
|
||||
var results = new List<object>();
|
||||
|
||||
const int maxFiles = 50;
|
||||
const int maxFileSize = 1_000_000;
|
||||
|
||||
async Task SearchDir(string dir)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fi = new FileInfo(file);
|
||||
if (fi.Length > maxFileSize) continue;
|
||||
string content;
|
||||
using (var reader = new StreamReader(file))
|
||||
content = await reader.ReadToEndAsync();
|
||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
||||
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await SearchDir(basePath);
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (System.IO.File.Exists(longTermPath))
|
||||
{
|
||||
string content;
|
||||
using (var reader = new StreamReader(longTermPath))
|
||||
content = await reader.ReadToEndAsync();
|
||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
||||
var start = Math.Max(0, idx - 60);
|
||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
||||
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Ok(results);
|
||||
return Results.Ok(await memoryService.SearchAsync(q));
|
||||
}
|
||||
|
||||
[HttpGet("{name}")]
|
||||
public async Task<IResult> GetFile(string name)
|
||||
{
|
||||
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
|
||||
return Results.BadRequest("Invalid filename.");
|
||||
|
||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||
filePath = longTermPath;
|
||||
|
||||
if (!System.IO.File.Exists(filePath!))
|
||||
return Results.NotFound();
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
||||
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
|
||||
var file = await memoryService.GetFileAsync(name);
|
||||
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,71 +1,15 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/operations")]
|
||||
public class OperationsController(
|
||||
IAgentRuntime runtime,
|
||||
IAgentService agentService,
|
||||
IProjectRepository projectRepo,
|
||||
ITaskRepository taskRepo,
|
||||
IActivityRepository activityRepo) : ControllerBase
|
||||
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||
{
|
||||
[HttpGet("snapshot")]
|
||||
[Authorize]
|
||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||
{
|
||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
||||
|
||||
var tasks = tasksTask.Result;
|
||||
var projects = projectsTask.Result;
|
||||
var agents = agentsTask.Result;
|
||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||
|
||||
var runtimeStatus = runtimeTask.Result;
|
||||
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
|
||||
|
||||
var lastIncident = tasks
|
||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
.OrderByDescending(x => x.UpdatedAt)
|
||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||
.FirstOrDefault();
|
||||
|
||||
var projectHealth = new
|
||||
{
|
||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||
};
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
generatedAt = DateTimeOffset.UtcNow,
|
||||
runtime = runtimeStatus,
|
||||
models = Array.Empty<object>(),
|
||||
runtimeHealthy,
|
||||
metrics = new
|
||||
{
|
||||
activeAgents = agents.Count,
|
||||
queuedTasks = tasks.Count - completedTasks,
|
||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
},
|
||||
lastIncident,
|
||||
projectHealth,
|
||||
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
||||
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
||||
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
||||
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||
});
|
||||
}
|
||||
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
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;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/projects")]
|
||||
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
|
||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
=> Results.Ok(await projectRepo.GetAllAsync(ct));
|
||||
=> Results.Ok(await projectService.GetAllAsync(ct));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.GetByIdAsync(id, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||||
@@ -19,59 +27,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||
|
||||
var project = new Project
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Description = request.Description?.Trim() ?? string.Empty,
|
||||
Status = OperationalStatus.Online
|
||||
};
|
||||
await projectRepo.AddAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||
var project = await projectService.CreateAsync(request, ct);
|
||||
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
project.Name = request.Name.Trim();
|
||||
if (request.Description is not null)
|
||||
project.Description = request.Description.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||
project.Status = parsedStatus;
|
||||
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||
return Results.Ok(project);
|
||||
var project = await projectService.UpdateAsync(id, request, ct);
|
||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||
if (project is null) return Results.NotFound();
|
||||
|
||||
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
|
||||
if (hasTasks)
|
||||
var result = await projectService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
project.Status = OperationalStatus.Offline;
|
||||
await projectRepo.UpdateAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||
return Results.Ok(project);
|
||||
}
|
||||
|
||||
await projectRepo.DeleteAsync(project, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||
return Results.NoContent();
|
||||
ProjectDeleteOutcome.NotFound => Results.NotFound(),
|
||||
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
|
||||
public class TasksController(
|
||||
ITaskService taskService,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
IActivityRepository activityRepository) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
=> Results.Ok(await taskRepo.GetAllAsync(ct));
|
||||
=> Results.Ok(await taskService.GetAllAsync(ct));
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
||||
@@ -19,107 +28,173 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||
ProjectId = request.ProjectId
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||
var task = await taskService.CreateAsync(request, ct);
|
||||
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
||||
}
|
||||
|
||||
[HttpGet("pending-approval")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||
{
|
||||
var pending = await taskRepo.GetPendingApprovalAsync(ct);
|
||||
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return Results.Problem(
|
||||
var result = await taskService.ApproveAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Approval denied",
|
||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||
return Results.Ok(task);
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||
return Results.Problem(
|
||||
var result = await taskService.RejectAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Rejection denied",
|
||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
||||
return Results.Ok(task);
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/state")]
|
||||
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
||||
{
|
||||
var allowedStates = TaskStateHelper.AllStates;
|
||||
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
|
||||
if (!TaskStateHelper.IsValidState(request.State))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
||||
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
||||
return Results.Ok(task);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return Results.Problem(
|
||||
title: "Task deletion denied",
|
||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
return Results.NoContent();
|
||||
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return Results.NotFound();
|
||||
var result = await taskService.UpdateAsync(id, request, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
task.Title = request.Title.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||
task.Priority = request.Priority.Trim();
|
||||
if (request.ProjectId.HasValue)
|
||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.DeleteAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Task deletion denied",
|
||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
||||
return Results.Ok(task);
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/team")]
|
||||
public class TeamController(IAgentService agentService) : ControllerBase
|
||||
public class TeamController(ITeamService teamService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetTeam(CancellationToken ct)
|
||||
{
|
||||
var agents = await agentService.GetAgentsAsync(ct);
|
||||
var team = new List<object>();
|
||||
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
string identity = "";
|
||||
string workspace = agent.Workspace ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
|
||||
{
|
||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||
if (System.IO.File.Exists(identityFile))
|
||||
{
|
||||
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
|
||||
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
|
||||
identity = string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
|
||||
team.Add(new
|
||||
{
|
||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||
identity
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(team);
|
||||
}
|
||||
=> Results.Ok(await teamService.GetTeamAsync(ct));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Nexus.Api.DTOs;
|
||||
|
||||
public sealed record AdminResetPasswordRequest
|
||||
{
|
||||
/// <summary>The email of the user whose password should be reset.</summary>
|
||||
public required string Email { get; init; }
|
||||
|
||||
/// <summary>The new password to set.</summary>
|
||||
public required string NewPassword { get; init; }
|
||||
|
||||
/// <summary>Admin reset token from configuration (Admin:ResetToken).</summary>
|
||||
public required string AdminToken { get; init; }
|
||||
}
|
||||
@@ -26,6 +26,29 @@ public sealed record UserInfo
|
||||
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
|
||||
{
|
||||
[MaxLength(100)]
|
||||
|
||||
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
|
||||
string? Title,
|
||||
DateTimeOffset? Since
|
||||
);
|
||||
|
||||
|
||||
+124
-4
@@ -19,7 +19,8 @@ public enum TaskState
|
||||
Backlog,
|
||||
InProgress,
|
||||
Blocked,
|
||||
Done
|
||||
Done,
|
||||
Review
|
||||
}
|
||||
|
||||
public static class TaskStateHelper
|
||||
@@ -29,7 +30,8 @@ public static class TaskStateHelper
|
||||
[TaskState.Backlog] = "Backlog",
|
||||
[TaskState.InProgress] = "In progress",
|
||||
[TaskState.Blocked] = "Blocked",
|
||||
[TaskState.Done] = "Done"
|
||||
[TaskState.Done] = "Done",
|
||||
[TaskState.Review] = "Review"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -37,11 +39,22 @@ public static class TaskStateHelper
|
||||
["Backlog"] = TaskState.Backlog,
|
||||
["In progress"] = TaskState.InProgress,
|
||||
["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>
|
||||
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>
|
||||
public static string ToStateString(this TaskState state) => StateToString[state];
|
||||
@@ -54,6 +67,10 @@ public static class TaskStateHelper
|
||||
public static bool IsValidState(string? 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) =>
|
||||
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -61,6 +78,75 @@ public static class TaskStateHelper
|
||||
public static bool IsDoneOrBacklog(string? state) =>
|
||||
string.Equals(state, "Done", 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
|
||||
@@ -77,16 +163,50 @@ public sealed class WorkTask
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.NewGuid();
|
||||
public required string Title { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public string State { get; set; } = "Backlog";
|
||||
public string Priority { get; set; } = "Normal";
|
||||
public string Source { get; set; } = "bao";
|
||||
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 DateTimeOffset? DueDate { get; set; }
|
||||
public DateTimeOffset CreatedAt { 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 long Id { get; init; }
|
||||
public required string Type { get; set; }
|
||||
public required string Message { get; set; }
|
||||
public Guid? TaskId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Nexus.Api.Data;
|
||||
|
||||
@@ -28,6 +29,21 @@ public class NexusUser
|
||||
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 Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// <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("20260611154800_AddTaskDetailFields")]
|
||||
partial class AddTaskDetailFields
|
||||
{
|
||||
/// <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<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
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<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("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.NexusUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Nexus.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskDetailFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AssignedTo",
|
||||
table: "Tasks",
|
||||
type: "character varying(60)",
|
||||
maxLength: 60,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||
name: "CreatedAt",
|
||||
table: "Tasks",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValueSql: "NOW()");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Detail",
|
||||
table: "Tasks",
|
||||
type: "character varying(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Source",
|
||||
table: "Tasks",
|
||||
type: "character varying(60)",
|
||||
maxLength: 60,
|
||||
nullable: false,
|
||||
defaultValue: "bao");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_AssignedTo",
|
||||
table: "Tasks",
|
||||
column: "AssignedTo");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tasks_Source",
|
||||
table: "Tasks",
|
||||
column: "Source");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_AssignedTo",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Tasks_Source",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AssignedTo",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAt",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Detail",
|
||||
table: "Tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Source",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
.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");
|
||||
});
|
||||
|
||||
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
|
||||
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")
|
||||
@@ -166,12 +214,50 @@ namespace Nexus.Api.Migrations
|
||||
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");
|
||||
@@ -179,6 +265,11 @@ namespace Nexus.Api.Migrations
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Source")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -193,6 +284,16 @@ namespace Nexus.Api.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
});
|
||||
|
||||
@@ -207,10 +308,25 @@ namespace Nexus.Api.Migrations
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,45 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
||||
public DbSet<NexusUser> Users => Set<NexusUser>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
||||
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240);
|
||||
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
||||
modelBuilder.Entity<WorkTask>(entity =>
|
||||
{
|
||||
entity.Property(x => x.Title).HasMaxLength(240);
|
||||
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||
entity.Property(x => x.Source).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.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<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
||||
|
||||
@@ -6,8 +6,15 @@ COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||
ARG NEXUS_VERSION=dev
|
||||
ARG NEXUS_GIT_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="Nexus API" \
|
||||
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
|
||||
org.opencontainers.image.version="${NEXUS_VERSION}" \
|
||||
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN apk add --no-cache curl
|
||||
USER $APP_UID
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
|
||||
|
||||
@@ -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,268 @@
|
||||
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>();
|
||||
|
||||
// ── Gateway WebSocket Connector ──
|
||||
services.AddOptions<GatewayConnectorOptions>()
|
||||
.BindConfiguration(GatewayConnectorOptions.SectionName);
|
||||
services.AddSingleton<IGatewayConnector, GatewayConnector>();
|
||||
services.AddHostedService(sp => (GatewayConnector)sp.GetRequiredService<IGatewayConnector>());
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
|
||||
public static bool IsValidConfigFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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).
|
||||
///
|
||||
/// The MCP endpoint (/mcp) is intentionally skipped — the MCP SDK handles its own
|
||||
/// authentication via X-Agent-Id + X-Nexus-Api-Key headers through NexusMcpTools.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyMiddleware(RequestDelegate next)
|
||||
{
|
||||
private static readonly PathString McpPath = new("/mcp");
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
// MCP endpoint handles its own auth — skip ApiKey interference
|
||||
if (context.Request.Path.StartsWithSegments(McpPath))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
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>();
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
namespace Nexus.Api.Models;
|
||||
|
||||
public sealed record DashboardAgentInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
string Role,
|
||||
string Model,
|
||||
bool IsActive,
|
||||
string? CurrentTask,
|
||||
string? Description,
|
||||
string[] Tags,
|
||||
int Progress = 0,
|
||||
int Workload = 0,
|
||||
string? Goal = null,
|
||||
string RoleBadge = "badge-slate",
|
||||
string StatusLabel = "Bereit",
|
||||
string StatusKind = "ready",
|
||||
string? StatusDetail = null,
|
||||
string? Elapsed = null,
|
||||
string? Think = null,
|
||||
string? Next = null
|
||||
);
|
||||
|
||||
public sealed record MessageEntry(
|
||||
string Role,
|
||||
string Content,
|
||||
string Timestamp
|
||||
);
|
||||
|
||||
public sealed record ChatRequest(
|
||||
string Message,
|
||||
string? AgentId
|
||||
);
|
||||
|
||||
public sealed record ChatResponse(
|
||||
bool Ok,
|
||||
string? Reply,
|
||||
string? Error
|
||||
);
|
||||
|
||||
public sealed record FeedEntry(
|
||||
string Agent,
|
||||
string Action,
|
||||
string Timestamp,
|
||||
string Time,
|
||||
string? AgentId = null,
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record DashboardStatus(
|
||||
bool GatewayOk,
|
||||
string IrisStatus,
|
||||
int ActiveAgents,
|
||||
int PendingTasks
|
||||
);
|
||||
|
||||
public sealed record QueueItem(
|
||||
string Id,
|
||||
string Name,
|
||||
string Status,
|
||||
string Priority,
|
||||
string Source,
|
||||
string WaitTime
|
||||
);
|
||||
|
||||
public sealed record AgentModelInfo(
|
||||
string Model,
|
||||
string Provider
|
||||
);
|
||||
|
||||
public sealed record SetModelRequest(
|
||||
string Model
|
||||
);
|
||||
|
||||
public sealed record ModelOption(
|
||||
string Id,
|
||||
string Name,
|
||||
string Provider
|
||||
);
|
||||
|
||||
// ── Dashboard Task DTOs ──
|
||||
|
||||
public sealed record DashboardTaskDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Detail,
|
||||
string Source,
|
||||
string State,
|
||||
string Priority,
|
||||
string? AssignedTo,
|
||||
Guid? ParentTaskId,
|
||||
DateTimeOffset? DueDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
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
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
string Title,
|
||||
string? Detail,
|
||||
string? Source,
|
||||
string? Priority,
|
||||
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(
|
||||
string? Title,
|
||||
string? Detail,
|
||||
string? Source,
|
||||
string? Priority,
|
||||
string? AssignedTo,
|
||||
DateTimeOffset? DueDate = null
|
||||
);
|
||||
|
||||
public sealed record UpdateDashboardTaskStatusRequest(
|
||||
string Status
|
||||
);
|
||||
|
||||
public sealed record AgentActivityEntry(
|
||||
string Time,
|
||||
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
|
||||
);
|
||||
|
||||
// ── 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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -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
-205
@@ -1,217 +1,27 @@
|
||||
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 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;
|
||||
using Nexus.Api.Extensions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- JWT Configuration ---
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
||||
var jwtAudience = builder.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.");
|
||||
|
||||
builder.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)
|
||||
};
|
||||
});
|
||||
|
||||
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(5);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
});
|
||||
|
||||
// --- Application Services ---
|
||||
builder.Services.AddTransient<ModelRoutingService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||
|
||||
// --- 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 ---
|
||||
// --- Service Registration ---
|
||||
builder.Services.AddNexusAuth(builder.Configuration);
|
||||
builder.Services.AddNexusRateLimiting();
|
||||
builder.Services.AddNexusForwardedHeaders();
|
||||
builder.Services.AddNexusSwagger();
|
||||
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||
builder.Services.AddNexusApplicationServices();
|
||||
builder.Services.AddNexusRepositories();
|
||||
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// --- Database Migration & Owner Seeding ---
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Database Migration & Seeding ---
|
||||
await app.EnsureDatabaseAsync();
|
||||
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseSecurityHeaders();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapMcp("/mcp");
|
||||
app.MapControllers();
|
||||
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;
|
||||
|
||||
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
|
||||
{
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||
|
||||
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(
|
||||
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);
|
||||
}
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking()
|
||||
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent")
|
||||
public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
{
|
||||
var candidateCount = Math.Max(take * 8, 100);
|
||||
var recent = await db.Activity.AsNoTracking()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(take)
|
||||
.Take(candidateCount)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return recent
|
||||
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
|
||||
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
|
||||
db.Activity.Add(activity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
liveUpdates.Publish("activity.created", new
|
||||
{
|
||||
activity.Id,
|
||||
activity.Type,
|
||||
activity.Message,
|
||||
activity.TaskId,
|
||||
activity.CreatedAt,
|
||||
agentIds
|
||||
}, "activity");
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
||||
public interface IActivityRepository
|
||||
{
|
||||
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(
|
||||
string? type, string? sort, int page, int pageSize, 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);
|
||||
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
|
||||
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<int> CountAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -7,15 +7,16 @@ public interface IUserRepository
|
||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, 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 UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
|
||||
|
||||
// Refresh token operations
|
||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,45 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!db.Database.IsRelational())
|
||||
{
|
||||
var task = await db.Tasks
|
||||
.FirstOrDefaultAsync(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore, ct);
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
task.UpdatedAt = updatedAt;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
var affectedRows = await db.Tasks
|
||||
.Where(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
|
||||
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
|
||||
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
db.Tasks.Update(task);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||
=> 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)
|
||||
=> db.Users.AnyAsync(ct);
|
||||
|
||||
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
||||
=> 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)
|
||||
=> db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
@@ -43,6 +57,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
||||
=> db.SaveChangesAsync(ct);
|
||||
|
||||
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||
if (token is null || token.RevokedAt is not null) return;
|
||||
|
||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
|
||||
{
|
||||
var activeTokens = await db.RefreshTokens
|
||||
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (activeTokens.Count == 0) return;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||
@@ -51,9 +92,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (oldTokens.Count > 0)
|
||||
{
|
||||
db.RefreshTokens.RemoveRange(oldTokens);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
||||
=> db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class AgentConfigService : IAgentConfigService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||
};
|
||||
|
||||
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
|
||||
{
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return Array.Empty<AgentConfigFileInfo>();
|
||||
|
||||
return Directory.GetFiles(workspacePath, "*.md")
|
||||
.Select(f => new FileInfo(f))
|
||||
.Where(f => AllowedFiles.Contains(f.Name))
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
if (!AllowedFiles.Contains(fileName))
|
||||
return null;
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(safePath!, ct);
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
{
|
||||
var fileKind = DetermineFileKind(fileName);
|
||||
var validation = Validate(fileName, content, fileKind);
|
||||
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
|
||||
var reload = CreateReloadCheck();
|
||||
if (validation.Errors.Count > 0)
|
||||
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"workspace_not_found",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"invalid_path",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
var backupPath = safePath + ".bak";
|
||||
var backupCreated = false;
|
||||
try
|
||||
{
|
||||
if (File.Exists(safePath))
|
||||
{
|
||||
File.Copy(safePath, backupPath, overwrite: true);
|
||||
backupCreated = true;
|
||||
}
|
||||
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||
File.Move(tempPath, safePath!, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigSaveAttempt(
|
||||
new AgentConfigFileSaveResult(
|
||||
fileName,
|
||||
fi.Length,
|
||||
fi.LastWriteTimeUtc,
|
||||
new AgentConfigValidationResult("passed", fileKind, []),
|
||||
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
|
||||
CreateReloadCheck()),
|
||||
null);
|
||||
}
|
||||
|
||||
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
errors.Add("Filename is invalid.");
|
||||
else if (!AllowedFiles.Contains(fileName))
|
||||
errors.Add("File is not allowed for Mission Control editing.");
|
||||
|
||||
if (content.IndexOf('\0') >= 0)
|
||||
errors.Add("Content contains null bytes.");
|
||||
|
||||
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
|
||||
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
|
||||
|
||||
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonDocument.Parse(content);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"JSON validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
|
||||
}
|
||||
|
||||
private static string DetermineFileKind(string fileName)
|
||||
{
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return "json";
|
||||
|
||||
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
return "markdown";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
private static AgentConfigReloadCheckResult CreateReloadCheck()
|
||||
=> new(
|
||||
"not_supported",
|
||||
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public static class AgentIdentityCatalog
|
||||
{
|
||||
public static readonly string[] DefaultConfiguredAgentIds =
|
||||
[
|
||||
"main",
|
||||
"iris",
|
||||
"product-owner",
|
||||
"programmer",
|
||||
"programmer-fast",
|
||||
"reviewer",
|
||||
"architekt",
|
||||
"researcher",
|
||||
"executor"
|
||||
];
|
||||
|
||||
private static readonly string[] WorkflowActorIds =
|
||||
[
|
||||
"bao",
|
||||
"nexus-system"
|
||||
];
|
||||
|
||||
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
|
||||
{
|
||||
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var configuredAgentId in configuredAgentIds)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configuredAgentId))
|
||||
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actorId))
|
||||
return null;
|
||||
|
||||
var normalized = actorId.Trim().ToLowerInvariant();
|
||||
return allowedActorIds.Contains(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ public sealed record AgentConfig
|
||||
public string? AgentDir { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public string? Model { get; init; }
|
||||
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||
public AgentModelConfig? Model { get; init; }
|
||||
|
||||
[JsonPropertyName("identity")]
|
||||
public AgentIdentityConfig? Identity { get; init; }
|
||||
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
|
||||
public string Theme { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record AgentModelConfig
|
||||
{
|
||||
[JsonPropertyName("primary")]
|
||||
public string? Primary { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
|
||||
{
|
||||
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
return null;
|
||||
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var primaryModel = reader.GetString();
|
||||
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
throw new JsonException("Agent model must be either a string or an object.");
|
||||
|
||||
using var document = JsonDocument.ParseValue(ref reader);
|
||||
var root = document.RootElement;
|
||||
|
||||
string? primary = null;
|
||||
foreach (var property in root.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
primary = property.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => property.Value.GetString(),
|
||||
JsonValueKind.Null => null,
|
||||
_ => throw new JsonException("Agent model primary must be a string.")
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return new AgentModelConfig { Primary = primary };
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
if (!string.IsNullOrWhiteSpace(value.Primary))
|
||||
writer.WriteString("primary", value.Primary);
|
||||
else
|
||||
writer.WriteNull("primary");
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AgentInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
@@ -73,6 +128,7 @@ public interface IAgentService
|
||||
{
|
||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
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);
|
||||
foreach (var config in configs)
|
||||
{
|
||||
var model = config.Model ?? "deepseek/deepseek-v4-flash";
|
||||
var model = ResolveModel(config);
|
||||
var role = DeriveRole(config.Id);
|
||||
var description = config.Identity?.Theme ?? string.Empty;
|
||||
|
||||
@@ -140,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
Id: config.Id,
|
||||
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
||||
Role: role,
|
||||
Model: config.Model ?? "deepseek/deepseek-v4-flash",
|
||||
Model: ResolveModel(config),
|
||||
Status: runtimeStatus.Status,
|
||||
LastSeen: now,
|
||||
Workspace: config.Workspace,
|
||||
@@ -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
|
||||
{
|
||||
"iris" => "Orchestrator",
|
||||
"product-owner" => "Product Owner",
|
||||
"programmer" => "Developer",
|
||||
"programmer-fast" => "Developer",
|
||||
"reviewer" => "Reviewer",
|
||||
"architekt" => "Architect",
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
private static string ResolveModel(AgentConfig config)
|
||||
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
|
||||
|
||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
?? "/home/node/.openclaw/agents-sanitized.json";
|
||||
|
||||
if (!File.Exists(path))
|
||||
return Array.Empty<AgentConfig>();
|
||||
return BuildFallbackConfigs();
|
||||
|
||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
||||
var root = document.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("agents", out var agentsElement))
|
||||
return Array.Empty<AgentConfig>();
|
||||
return BuildFallbackConfigs();
|
||||
|
||||
if (!agentsElement.TryGetProperty("list", out var listElement))
|
||||
return Array.Empty<AgentConfig>();
|
||||
return BuildFallbackConfigs();
|
||||
|
||||
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
||||
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
||||
@@ -193,29 +264,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
// Inherit defaults for missing fields
|
||||
if (string.IsNullOrWhiteSpace(config.Name))
|
||||
config = config with { Name = config.Id };
|
||||
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
|
||||
config = config with { Model = defaults.Model.Primary };
|
||||
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
|
||||
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
|
||||
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
||||
config = config with { Workspace = defaults.Workspace };
|
||||
|
||||
configs.Add(config);
|
||||
}
|
||||
|
||||
return configs.AsReadOnly();
|
||||
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
|
||||
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
|
||||
.Select(id => new AgentConfig
|
||||
{
|
||||
Id = id,
|
||||
Name = id,
|
||||
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
|
||||
})
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
|
||||
private sealed record AgentDefaults
|
||||
{
|
||||
[JsonPropertyName("workspace")]
|
||||
public string? Workspace { get; init; }
|
||||
|
||||
[JsonPropertyName("model")]
|
||||
public AgentDefaultModel? Model { get; init; }
|
||||
}
|
||||
|
||||
private sealed record AgentDefaultModel
|
||||
{
|
||||
[JsonPropertyName("primary")]
|
||||
public string? Primary { get; init; }
|
||||
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||
public AgentModelConfig? Model { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public interface IAuthService
|
||||
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
||||
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
||||
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed record AuthSession(
|
||||
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<AuthService> _logger;
|
||||
|
||||
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
|
||||
|
||||
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
||||
{
|
||||
_users = users;
|
||||
@@ -53,6 +56,11 @@ public sealed class AuthService : IAuthService
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Persist user changes (password upgrade, login timestamp) immediately.
|
||||
// Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to
|
||||
// trigger SaveChangesAsync is fragile — if zero tokens are expired the
|
||||
// tracked changes might not be flushed before the response is produced.
|
||||
await _users.UpdateAsync(user, ct);
|
||||
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
||||
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
||||
}
|
||||
@@ -68,7 +76,7 @@ public sealed class AuthService : IAuthService
|
||||
|
||||
if (token.RevokedAt is not null)
|
||||
{
|
||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
||||
await _users.RevokeFamilyAsync(token.FamilyId, ct);
|
||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||
return null;
|
||||
}
|
||||
@@ -81,23 +89,12 @@ public sealed class AuthService : IAuthService
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||
|
||||
var tokenHash = HashToken(refreshToken);
|
||||
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
||||
if (token is null || token.RevokedAt is not null) return;
|
||||
|
||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
await _users.SaveChangesAsync(ct);
|
||||
await _users.RevokeTokenAsync(tokenHash, ct);
|
||||
}
|
||||
|
||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||
=> Task.Run(async () =>
|
||||
{
|
||||
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
||||
// For read-only access, we call it but the result shouldn't be mutated
|
||||
return await _users.GetByIdAsync(userId, ct);
|
||||
}, ct);
|
||||
=> _users.GetByIdAsync(userId, ct).AsTask();
|
||||
|
||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -128,6 +125,46 @@ public sealed class AuthService : IAuthService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
|
||||
{
|
||||
// Validate admin token
|
||||
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
|
||||
{
|
||||
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(adminToken),
|
||||
Encoding.UTF8.GetBytes(AdminResetToken)))
|
||||
{
|
||||
_logger.LogWarning("Invalid admin reset token provided");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
|
||||
return false;
|
||||
|
||||
if (newPassword.Length < 10)
|
||||
return false;
|
||||
|
||||
var normalizedEmail = NormalizeEmail(email);
|
||||
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
_logger.LogWarning("Admin password reset: user {Email} not found", email);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await _users.UpdateAsync(user, ct);
|
||||
|
||||
_logger.LogInformation("Admin password reset completed for {Email}", email);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<AuthSession?> CreateSessionAsync(
|
||||
NexusUser user,
|
||||
Guid familyId,
|
||||
@@ -185,19 +222,6 @@ public sealed class AuthService : IAuthService
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
||||
{
|
||||
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var token in activeTokens)
|
||||
{
|
||||
token.RevokedAt = now;
|
||||
token.ConcurrencyStamp = Guid.NewGuid();
|
||||
}
|
||||
|
||||
await _users.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateRefreshToken()
|
||||
{
|
||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class CalendarService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<CalendarService> logger) : ICalendarService
|
||||
{
|
||||
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateGatewayClient();
|
||||
var response = await client.GetAsync("/api/cron", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||
return data ?? new List<CronJobEntry>();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
|
||||
}
|
||||
|
||||
return BuildFallbackCronJobs();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var client = CreateGatewayClient();
|
||||
var response = await client.GetAsync("/api/cron/upcoming", ct);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||
return data ?? new List<UpcomingCronEntry>();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
|
||||
}
|
||||
|
||||
return BuildFallbackUpcomingJobs();
|
||||
}
|
||||
|
||||
private HttpClient CreateGatewayClient()
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("gateway");
|
||||
var token = configuration["Integrations:OpenClaw:Token"];
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return
|
||||
[
|
||||
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
|
||||
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
|
||||
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
|
||||
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
|
||||
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
|
||||
];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return
|
||||
[
|
||||
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
|
||||
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
|
||||
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
|
||||
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
|
||||
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class DashboardService(
|
||||
IOpenClawGatewayClient gateway,
|
||||
ITaskService taskService,
|
||||
ILogger<DashboardService> logger) : IDashboardService
|
||||
{
|
||||
public async Task<DashboardStatus> GetStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard status check failed");
|
||||
return new DashboardStatus(false, "Offline", 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||
{
|
||||
entries = entries
|
||||
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.SendChatMessageAsync(agentId, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||
return messages
|
||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cronTask = gateway.GetQueueAsync();
|
||||
var tasksTask = taskService.GetOpenAsync(ct);
|
||||
await Task.WhenAll(cronTask, tasksTask);
|
||||
|
||||
var merged = new List<QueueItem>(cronTask.Result);
|
||||
foreach (var t in tasksTask.Result)
|
||||
{
|
||||
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
|
||||
}
|
||||
|
||||
return merged
|
||||
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetGatewayInfoAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Gateway info fetch failed");
|
||||
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var ok = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||
}
|
||||
|
||||
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||
{
|
||||
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||
|
||||
var result = await taskService.CompleteViaQueueAsync(guid, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
|
||||
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
|
||||
};
|
||||
}
|
||||
|
||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||
}
|
||||
|
||||
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||
{
|
||||
if (!id.StartsWith("task-"))
|
||||
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
|
||||
|
||||
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
|
||||
|
||||
var result = await taskService.CyclePriorityAsync(guid, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
|
||||
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentModelAsync(agentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.SetAgentModelAsync(agentId, model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||
|
||||
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
|
||||
{
|
||||
"high" or "critical" or "urgent" => "high",
|
||||
"low" or "minor" => "low",
|
||||
_ => "medium"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["high"] = 0, ["medium"] = 1, ["low"] = 2
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class DocService : IDocService
|
||||
{
|
||||
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
|
||||
private static readonly string[] SearchRoots =
|
||||
[
|
||||
"/mnt/workspace-iris",
|
||||
"/home/node/.openclaw/workspace/nexus"
|
||||
];
|
||||
|
||||
private static readonly (string Dir, string Category)[] ScanDirectories =
|
||||
[
|
||||
("/mnt/workspace-iris/nexus-phases", "phases"),
|
||||
("/mnt/workspace-iris/skills", "skills"),
|
||||
("/mnt/workspace-iris", "workspace"),
|
||||
("/home/node/.openclaw/workspace/nexus", "nexus"),
|
||||
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
|
||||
];
|
||||
|
||||
public IReadOnlyList<DocFileInfo> GetAll()
|
||||
{
|
||||
var results = new List<DocFileInfo>();
|
||||
|
||||
foreach (var (dir, category) in ScanDirectories)
|
||||
{
|
||||
if (!Directory.Exists(dir)) continue;
|
||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||
{
|
||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext)) continue;
|
||||
|
||||
var fi = new FileInfo(file);
|
||||
results.Add(new DocFileInfo(
|
||||
fi.Name,
|
||||
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
|
||||
category,
|
||||
ext.Replace(".", ""),
|
||||
fi.Length,
|
||||
fi.LastWriteTimeUtc));
|
||||
}
|
||||
}
|
||||
|
||||
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
|
||||
}
|
||||
|
||||
public async Task<DocFileContent?> GetFileAsync(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
string? resolvedPath = null;
|
||||
foreach (var root in SearchRoots)
|
||||
{
|
||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
|
||||
{
|
||||
resolvedPath = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPath is null)
|
||||
return null;
|
||||
|
||||
var content = await File.ReadAllTextAsync(resolvedPath);
|
||||
var fi = new FileInfo(resolvedPath);
|
||||
var relativePath = resolvedPath
|
||||
.Replace("/mnt/workspace-iris/", "")
|
||||
.Replace("/home/node/.openclaw/workspace/nexus/", "");
|
||||
|
||||
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// BackgroundService that maintains a persistent WebSocket connection to the
|
||||
/// OpenClaw Gateway for real-time event streaming and health monitoring.
|
||||
///
|
||||
/// Uses exponential backoff for reconnects, never logs the gateway token,
|
||||
/// and reports connection state via <see cref="IGatewayConnector"/>.
|
||||
/// </summary>
|
||||
public sealed class GatewayConnector : BackgroundService, IGatewayConnector
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<GatewayConnector> _logger;
|
||||
private readonly GatewayConnectorOptions _options;
|
||||
|
||||
// ── Connection state (lock-protected) ──
|
||||
private readonly object _lock = new();
|
||||
private GatewayConnectionState _state = GatewayConnectionState.Initializing;
|
||||
private string? _gatewayVersion;
|
||||
private string? _requiredVersion;
|
||||
private DateTimeOffset? _lastConnectedAt;
|
||||
private int _reconnectAttempts;
|
||||
private string? _statusMessage;
|
||||
|
||||
public GatewayConnector(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
IOptions<GatewayConnectorOptions> options,
|
||||
ILogger<GatewayConnector> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
// ── IGatewayConnector ──
|
||||
|
||||
public GatewayConnectionState ConnectionState
|
||||
{
|
||||
get { lock (_lock) return _state; }
|
||||
}
|
||||
|
||||
public string? GatewayVersion
|
||||
{
|
||||
get { lock (_lock) return _gatewayVersion; }
|
||||
}
|
||||
|
||||
public string? RequiredVersion
|
||||
{
|
||||
get { lock (_lock) return _requiredVersion; }
|
||||
}
|
||||
|
||||
public DateTimeOffset? LastConnectedAt
|
||||
{
|
||||
get { lock (_lock) return _lastConnectedAt; }
|
||||
}
|
||||
|
||||
public int ReconnectAttempts
|
||||
{
|
||||
get { lock (_lock) return _reconnectAttempts; }
|
||||
}
|
||||
|
||||
public string? StatusMessage
|
||||
{
|
||||
get { lock (_lock) return _statusMessage; }
|
||||
}
|
||||
|
||||
// ── BackgroundService ──
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Load version pin from configuration once at startup
|
||||
var pinnedVersion = _configuration["Integrations:OpenClaw:RequiredVersion"];
|
||||
lock (_lock)
|
||||
{
|
||||
_requiredVersion = string.IsNullOrWhiteSpace(pinnedVersion) ? null : pinnedVersion.Trim();
|
||||
}
|
||||
|
||||
if (_requiredVersion is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Gateway version is UNPINNED (Integrations:OpenClaw:RequiredVersion is empty). "
|
||||
+ "Drift detection is disabled; set a required version to enable fail-fast on mismatch.");
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndReceiveAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "GatewayConnector connection loop exception (will retry)");
|
||||
}
|
||||
|
||||
// Exponential backoff before next reconnect attempt
|
||||
if (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = ComputeBackoff();
|
||||
_logger.LogInformation(
|
||||
"GatewayConnector reconnecting in {DelayMs}ms (attempt {Attempt})",
|
||||
(int)delay.TotalMilliseconds, GetReconnectAttempts() + 1);
|
||||
|
||||
SetState(GatewayConnectionState.Reconnecting);
|
||||
|
||||
try { await Task.Delay(delay, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("GatewayConnector background service stopped");
|
||||
}
|
||||
|
||||
private async Task ConnectAndReceiveAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
ConfigureWebSocketWithAuth(ws);
|
||||
|
||||
var baseUrl = _configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789";
|
||||
var wsBase = ConvertToWebSocketUrl(baseUrl);
|
||||
|
||||
// ── Step 1: Pre-flight version check via HTTP ──
|
||||
string? detectedVersion = null;
|
||||
try
|
||||
{
|
||||
detectedVersion = await FetchGatewayVersionAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Pre-flight version check failed (non-fatal)");
|
||||
}
|
||||
|
||||
// Version check against the pin
|
||||
var (versionOk, versionWarning) = EvaluateVersion(detectedVersion);
|
||||
|
||||
if (!versionOk)
|
||||
{
|
||||
if (_options.FailFastOnVersionMismatch)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Gateway version mismatch — fail-fast enabled. Detected: {Detected}, Required: {Required}",
|
||||
detectedVersion ?? "none", _requiredVersion);
|
||||
|
||||
IncrementReconnectAttempts();
|
||||
SetState(GatewayConnectionState.Failed,
|
||||
$"Version mismatch — fail-fast: detected '{detectedVersion}', required '{_requiredVersion}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Gateway version mismatch (non-fatal): {Warning}", versionWarning);
|
||||
}
|
||||
|
||||
// ── Step 2: Establish WebSocket connection ──
|
||||
var wsUri = new Uri(new Uri(wsBase), _options.WebSocketPath);
|
||||
_logger.LogInformation("GatewayConnector connecting to {Uri}", wsUri);
|
||||
|
||||
try
|
||||
{
|
||||
await ws.ConnectAsync(wsUri, stoppingToken);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket connection failed to {Uri} — gateway may not expose a WS endpoint", wsUri);
|
||||
IncrementReconnectAttempts();
|
||||
SetState(GatewayConnectionState.Disconnected,
|
||||
$"Connection refused — WebSocket endpoint may not be available at {wsUri}. Error: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket HTTP upgrade failed to {Uri}", wsUri);
|
||||
IncrementReconnectAttempts();
|
||||
SetState(GatewayConnectionState.Disconnected,
|
||||
$"HTTP upgrade failed: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 3: Submit authentication token (if configured) ──
|
||||
var token = ResolveToken();
|
||||
if (token is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var authFrame = Encoding.UTF8.GetBytes(
|
||||
JsonSerializer.Serialize(new { type = "auth", token }));
|
||||
await ws.SendAsync(
|
||||
new ArraySegment<byte>(authFrame),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken: stoppingToken);
|
||||
|
||||
_logger.LogDebug("WebSocket auth token sent");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to send WebSocket auth frame");
|
||||
}
|
||||
}
|
||||
|
||||
// ── After successful connection ──
|
||||
lock (_lock)
|
||||
{
|
||||
_state = GatewayConnectionState.Connected;
|
||||
_gatewayVersion = detectedVersion;
|
||||
_lastConnectedAt = DateTimeOffset.UtcNow;
|
||||
_reconnectAttempts = 0;
|
||||
_statusMessage = versionWarning;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"GatewayConnector connected. Version: {Version}, Required: {Required}",
|
||||
detectedVersion ?? "unknown", _requiredVersion ?? "unpinned");
|
||||
|
||||
// ── Step 4: Receive loop (heartbeat / event processing) ──
|
||||
await ReceiveLoopAsync(ws, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken stoppingToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"GatewayConnector received close frame: {Description}",
|
||||
result.CloseStatusDescription ?? "no description");
|
||||
break;
|
||||
}
|
||||
|
||||
// Process text frames (events, heartbeats, status updates)
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
ProcessGatewayMessage(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Expected on shutdown
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket connection lost");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (ws.State == WebSocketState.Open || ws.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ws.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure, "Connector shutdown", CancellationToken.None);
|
||||
}
|
||||
catch { /* Best effort */ }
|
||||
}
|
||||
|
||||
SetState(GatewayConnectionState.Disconnected, "Connection closed");
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessGatewayMessage(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
var type = root.TryGetProperty("type", out var typeEl) ? typeEl.GetString() : null;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "heartbeat":
|
||||
case "pong":
|
||||
_logger.LogDebug("Gateway heartbeat received");
|
||||
break;
|
||||
|
||||
case "event":
|
||||
var eventName = root.TryGetProperty("name", out var nameEl)
|
||||
? nameEl.GetString() : "unknown";
|
||||
_logger.LogDebug("Gateway event: {EventName}", eventName);
|
||||
// Future: fan-out to other services via a channel/event bus
|
||||
break;
|
||||
|
||||
case "error":
|
||||
var errorMsg = root.TryGetProperty("message", out var msgEl)
|
||||
? msgEl.GetString() : "unknown error";
|
||||
_logger.LogWarning("Gateway error frame received: {Error}", errorMsg);
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.LogDebug("Gateway message (type={Type}): {Preview}",
|
||||
type ?? "none", Truncate(message, 120));
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
_logger.LogDebug("Non-JSON gateway message: {Preview}", Truncate(message, 80));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private (bool Ok, string? Warning) EvaluateVersion(string? detectedVersion)
|
||||
{
|
||||
if (_requiredVersion is null)
|
||||
return (true, null); // Unpinned — always ok
|
||||
|
||||
if (detectedVersion is null)
|
||||
return (_options.FailFastOnMissingVersion ? false : true,
|
||||
"Gateway version not detected while version pin is set.");
|
||||
|
||||
if (!string.Equals(detectedVersion, _requiredVersion, StringComparison.OrdinalIgnoreCase))
|
||||
return (false, $"Version drift: detected '{detectedVersion}', required '{_requiredVersion}'.");
|
||||
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
private async Task<string?> FetchGatewayVersionAsync(CancellationToken ct)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("gateway");
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
ApplyAuth(request);
|
||||
|
||||
using var response = await client.SendAsync(request, ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
// Check X-OpenClaw-Version header first
|
||||
if (response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues))
|
||||
{
|
||||
var version = headerValues.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(version))
|
||||
return version.Trim();
|
||||
}
|
||||
|
||||
// Try JSON body
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var v = TryGetString(root, "version")
|
||||
?? TryGetString(root, "gatewayVersion")
|
||||
?? TryGetString(root, "openclawVersion");
|
||||
if (v is not null) return v;
|
||||
}
|
||||
catch { /* Not JSON */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private TimeSpan ComputeBackoff()
|
||||
{
|
||||
var attempts = GetReconnectAttempts();
|
||||
var backoffMs = (int)Math.Min(
|
||||
_options.ReconnectInitialDelayMs * Math.Pow(2, attempts),
|
||||
_options.ReconnectMaxDelayMs);
|
||||
|
||||
// Add jitter (±25%)
|
||||
var jitter = (int)(backoffMs * 0.25 * (Random.Shared.NextDouble() * 2 - 1));
|
||||
return TimeSpan.FromMilliseconds(Math.Max(100, backoffMs + jitter));
|
||||
}
|
||||
|
||||
private void IncrementReconnectAttempts()
|
||||
{
|
||||
lock (_lock) { _reconnectAttempts++; }
|
||||
}
|
||||
|
||||
private int GetReconnectAttempts()
|
||||
{
|
||||
lock (_lock) { return _reconnectAttempts; }
|
||||
}
|
||||
|
||||
private void SetState(GatewayConnectionState state, string? message = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_state = state;
|
||||
if (message is not null) _statusMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveToken()
|
||||
{
|
||||
// Token NEVER logged — only read from config here
|
||||
var token = _configuration["Integrations:OpenClaw:Password"]
|
||||
?? _configuration["Integrations:OpenClaw:Token"];
|
||||
|
||||
return string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
}
|
||||
|
||||
private void ApplyAuth(HttpRequestMessage request)
|
||||
{
|
||||
var token = ResolveToken();
|
||||
if (token is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
private static void ConfigureWebSocket(ClientWebSocket ws)
|
||||
{
|
||||
// Add auth header to the initial HTTP upgrade request
|
||||
ws.Options.KeepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
ws.Options.UseDefaultCredentials = false;
|
||||
}
|
||||
|
||||
// Overload with auth header via cookie (WebSocket can't do Authorization header natively in all runtimes)
|
||||
private void ConfigureWebSocketWithAuth(ClientWebSocket ws)
|
||||
{
|
||||
var token = ResolveToken();
|
||||
if (token is not null)
|
||||
{
|
||||
ws.Options.SetRequestHeader("Authorization", "Bearer " + token);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConvertToWebSocketUrl(string baseUrl)
|
||||
{
|
||||
var trimmed = baseUrl.TrimEnd('/');
|
||||
if (trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return "wss://" + trimmed["https://".Length..];
|
||||
if (trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
|
||||
return "ws://" + trimmed["http://".Length..];
|
||||
return "ws://" + trimmed;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
=> value.Length <= maxLength ? value : value[..maxLength] + "\u2026";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the GatewayConnector background service.
|
||||
/// </summary>
|
||||
public sealed class GatewayConnectorOptions
|
||||
{
|
||||
public const string SectionName = "GatewayConnector";
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket path relative to the gateway base URL.
|
||||
/// Default: "/ws"
|
||||
/// </summary>
|
||||
public string WebSocketPath { get; set; } = "/ws";
|
||||
|
||||
/// <summary>
|
||||
/// Initial reconnect delay in milliseconds.
|
||||
/// Default: 1000 (1 second)
|
||||
/// </summary>
|
||||
public int ReconnectInitialDelayMs { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum reconnect delay in milliseconds.
|
||||
/// Default: 300_000 (5 minutes)
|
||||
/// </summary>
|
||||
public int ReconnectMaxDelayMs { get; set; } = 300_000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of consecutive reconnect attempts before entering Failed state.
|
||||
/// Default: 0 (no limit — keep retrying forever)
|
||||
/// </summary>
|
||||
public int ReconnectMaxAttempts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, the connector enters Failed state immediately if the gateway version
|
||||
/// doesn't match the pinned required version (instead of connecting with a warning).
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool FailFastOnVersionMismatch { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, missing version (gateway doesn't report one) with a version pin set
|
||||
/// also triggers fail-fast. Default: false (allows pinned-version gateways that
|
||||
/// don't expose a version endpoint).
|
||||
/// </summary>
|
||||
public bool FailFastOnMissingVersion { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
|
||||
|
||||
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
|
||||
|
||||
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(
|
||||
string FileName,
|
||||
long Size,
|
||||
DateTime ModifiedAt,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveFailure(
|
||||
string Code,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveAttempt(
|
||||
AgentConfigFileSaveResult? SaveResult,
|
||||
AgentConfigSaveFailure? Failure
|
||||
);
|
||||
|
||||
public interface IAgentConfigService
|
||||
{
|
||||
const int MaxConfigFileBytes = 500 * 1024;
|
||||
|
||||
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Nexus.Api.DTOs;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface ICalendarService
|
||||
{
|
||||
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
|
||||
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
|
||||
|
||||
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
|
||||
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
|
||||
|
||||
public interface IDashboardService
|
||||
{
|
||||
Task<DashboardStatus> GetStatusAsync();
|
||||
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
|
||||
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
|
||||
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
|
||||
List<ModelOption> GetAvailableModels();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record DocFileInfo(
|
||||
string Name,
|
||||
string Path,
|
||||
string Category,
|
||||
string Type,
|
||||
long Size,
|
||||
DateTime ModifiedAt);
|
||||
|
||||
public sealed record DocFileContent(
|
||||
string Name,
|
||||
string Path,
|
||||
string Content,
|
||||
long Size,
|
||||
DateTime ModifiedAt);
|
||||
|
||||
public interface IDocService
|
||||
{
|
||||
IReadOnlyList<DocFileInfo> GetAll();
|
||||
Task<DocFileContent?> GetFileAsync(string path);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Maintains a persistent WebSocket connection to the OpenClaw Gateway
|
||||
/// for real-time event streaming and health monitoring.
|
||||
/// </summary>
|
||||
public interface IGatewayConnector
|
||||
{
|
||||
/// <summary>
|
||||
/// Current WebSocket connection state.
|
||||
/// </summary>
|
||||
GatewayConnectionState ConnectionState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gateway version reported at last connection.
|
||||
/// </summary>
|
||||
string? GatewayVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Required version from configuration, or null when unpinned.
|
||||
/// </summary>
|
||||
string? RequiredVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the last successful connection attempt.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastConnectedAt { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failed connection attempts.
|
||||
/// </summary>
|
||||
int ReconnectAttempts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed status message (e.g. error or version info).
|
||||
/// </summary>
|
||||
string? StatusMessage { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current connection state of the GatewayConnector.
|
||||
/// </summary>
|
||||
public enum GatewayConnectionState
|
||||
{
|
||||
/// <summary>Not yet attempted or initializing.</summary>
|
||||
Initializing,
|
||||
/// <summary>Connected and healthy.</summary>
|
||||
Connected,
|
||||
/// <summary>Disconnected, waiting for reconnect.</summary>
|
||||
Disconnected,
|
||||
/// <summary>Recoverable error, retrying.</summary>
|
||||
Reconnecting,
|
||||
/// <summary>Failed after maximum retries.</summary>
|
||||
Failed
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record IncidentSummary(
|
||||
string Name,
|
||||
string Title,
|
||||
string? Date,
|
||||
string Severity,
|
||||
string Excerpt,
|
||||
long Size);
|
||||
|
||||
public sealed record IncidentDetail(
|
||||
string Name,
|
||||
string Title,
|
||||
string? Date,
|
||||
string Content,
|
||||
long Size);
|
||||
|
||||
public interface IIncidentService
|
||||
{
|
||||
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
|
||||
Task<IncidentDetail?> GetByNameAsync(string name);
|
||||
}
|
||||
@@ -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 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
|
||||
|
||||
public interface IMemoryService
|
||||
{
|
||||
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
|
||||
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
|
||||
Task<MemoryFileContent?> GetFileAsync(string name);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IOpenClawGatewayClient
|
||||
{
|
||||
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
|
||||
Task<DashboardStatus> GetStatusAsync();
|
||||
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
|
||||
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||
Task<List<QueueItem>> GetQueueAsync();
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
|
||||
Task<bool> DeleteCronJobAsync(string id);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
|
||||
List<ModelOption> GetAvailableModels();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IOperationsService
|
||||
{
|
||||
Task<object> GetSnapshotAsync(CancellationToken ct = default);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user