Compare commits
5 Commits
5df5194651
...
95495a8332
| Author | SHA1 | Date | |
|---|---|---|---|
| 95495a8332 | |||
| 68b428e411 | |||
| 1214cf9a4d | |||
| 195c497c88 | |||
| a2272c5df6 |
@@ -1,12 +0,0 @@
|
|||||||
POSTGRES_DB=nexus
|
|
||||||
POSTGRES_USER=nexus
|
|
||||||
POSTGRES_PASSWORD=replace-with-a-strong-database-password
|
|
||||||
JWT_KEY=replace-with-at-least-32-random-bytes
|
|
||||||
OWNER_EMAIL=owner@example.com
|
|
||||||
OWNER_PASSWORD=replace-with-at-least-14-characters
|
|
||||||
OWNER_DISPLAY_NAME=Owner
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
|
||||||
OPENCLAW_GATEWAY_TOKEN=
|
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
|
||||||
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
|
||||||
NVIDIA_API_KEY=
|
|
||||||
+2
-4
@@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
|
|||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
|
|
||||||
# ── Owner Account ───────────────────────────────────────
|
# ── Bootstrap Owner (first seed only) ───────────────────
|
||||||
OWNER_EMAIL=***
|
BOOTSTRAP_OWNER_EMAIL=***
|
||||||
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
|
|
||||||
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
|
|
||||||
|
|
||||||
# ── OpenClaw Integration ────────────────────────────────
|
# ── OpenClaw Integration ────────────────────────────────
|
||||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
echo "🗄️ Dumping PostgreSQL cluster..."
|
echo "🗄️ Dumping PostgreSQL cluster..."
|
||||||
|
|
||||||
docker exec "${BACKUP_CONTAINER_NAME}" \
|
docker exec "${BACKUP_CONTAINER_NAME}" \
|
||||||
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
|
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus" \
|
||||||
| gzip > "${{ steps.meta.outputs.filename }}"
|
| gzip > "${{ steps.meta.outputs.filename }}"
|
||||||
|
|
||||||
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
run: |
|
run: |
|
||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@10.12.1 --activate
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
name: Deploy Now
|
|
||||||
run-name: 🚀 Deploy Now by @${{ gitea.actor }}
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy Nexus
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
|
||||||
ENV_TMPFILE: /tmp/nexus-deploy-env
|
|
||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
|
||||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
|
||||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- name: Resolve Version
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [ ! -f VERSION ]; then
|
|
||||||
echo "ERROR: VERSION file not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
|
||||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
||||||
echo "ERROR: Invalid semver in VERSION: $VERSION"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
GIT_REF=$(git rev-parse --short HEAD)
|
|
||||||
echo "Deploy version: v${VERSION} git:${GIT_REF}"
|
|
||||||
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
|
||||||
|
|
||||||
- name: Prepare .env
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true)
|
|
||||||
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
|
||||||
echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}"
|
|
||||||
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
|
|
||||||
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
|
|
||||||
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
|
|
||||||
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}"
|
|
||||||
chmod 600 "${ENV_TMPFILE}"
|
|
||||||
echo "OK .env written to ${ENV_TMPFILE}"
|
|
||||||
|
|
||||||
- name: Sync code to host
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker run --rm \
|
|
||||||
-v "${{ gitea.workspace }}:/src:ro" \
|
|
||||||
-v "${DEPLOY_PATH}:/dest" \
|
|
||||||
alpine:latest \
|
|
||||||
sh -c "cd /src && find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -r {} /dest/ \; && DEST_OWNER=\$(stat -c '%u:%g' /dest) && chown -R \"\$DEST_OWNER\" /dest"
|
|
||||||
echo "OK synced to ${DEPLOY_PATH}"
|
|
||||||
|
|
||||||
- name: Build and Deploy
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SCRIPT=/tmp/nexus-deploy-script.sh
|
|
||||||
printf '#!/bin/sh\n' > "$SCRIPT"
|
|
||||||
printf 'set -e\n' >> "$SCRIPT"
|
|
||||||
printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
|
|
||||||
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
|
|
||||||
printf '\n' >> "$SCRIPT"
|
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT"
|
|
||||||
printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT"
|
|
||||||
printf '\n' >> "$SCRIPT"
|
|
||||||
printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT"
|
|
||||||
printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT"
|
|
||||||
printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT"
|
|
||||||
printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT"
|
|
||||||
printf 'else\n' >> "$SCRIPT"
|
|
||||||
printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT"
|
|
||||||
printf 'fi\n' >> "$SCRIPT"
|
|
||||||
printf '\n' >> "$SCRIPT"
|
|
||||||
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
|
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
|
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate\n' >> "$SCRIPT"
|
|
||||||
printf '\n' >> "$SCRIPT"
|
|
||||||
printf 'echo "Waiting for services to become healthy (up to 180s)..."\n' >> "$SCRIPT"
|
|
||||||
printf 'for i in $(seq 1 36); do\n' >> "$SCRIPT"
|
|
||||||
printf ' STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)\n' >> "$SCRIPT"
|
|
||||||
printf ' if echo "$STATUS" | grep -q unhealthy; then\n' >> "$SCRIPT"
|
|
||||||
printf ' echo " [$i/36] Unhealthy containers - failing fast"\n' >> "$SCRIPT"
|
|
||||||
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
|
||||||
printf ' docker compose --env-file /tmp/nexus-deploy-env logs --tail=30\n' >> "$SCRIPT"
|
|
||||||
printf ' exit 1\n' >> "$SCRIPT"
|
|
||||||
printf ' elif echo "$STATUS" | grep -q starting; then\n' >> "$SCRIPT"
|
|
||||||
printf ' echo " [$i/36] Still starting..."\n' >> "$SCRIPT"
|
|
||||||
printf ' sleep 5\n' >> "$SCRIPT"
|
|
||||||
printf ' else\n' >> "$SCRIPT"
|
|
||||||
printf ' echo "All containers healthy"\n' >> "$SCRIPT"
|
|
||||||
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
|
||||||
printf ' exit 0\n' >> "$SCRIPT"
|
|
||||||
printf ' fi\n' >> "$SCRIPT"
|
|
||||||
printf 'done\n' >> "$SCRIPT"
|
|
||||||
printf 'echo "Timeout waiting for services"\n' >> "$SCRIPT"
|
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
|
||||||
printf 'docker compose --env-file /tmp/nexus-deploy-env logs --tail=20\n' >> "$SCRIPT"
|
|
||||||
printf 'exit 1\n' >> "$SCRIPT"
|
|
||||||
chmod +x "$SCRIPT"
|
|
||||||
|
|
||||||
docker run --rm \
|
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-v "${SCRIPT}:/deploy.sh:ro" \
|
|
||||||
-w /workspace/nexus \
|
|
||||||
-i \
|
|
||||||
docker:cli \
|
|
||||||
sh /deploy.sh < "${ENV_TMPFILE}"
|
|
||||||
|
|
||||||
rm -f "$SCRIPT"
|
|
||||||
echo "OK deployed"
|
|
||||||
|
|
||||||
- name: Clean up temp .env
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
if [ -f "${ENV_TMPFILE}" ]; then
|
|
||||||
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
|
||||||
echo "OK cleaned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Health Check
|
|
||||||
run: |
|
|
||||||
echo "Health check..."
|
|
||||||
RETRY=0; MAX=6; WAIT=1
|
|
||||||
while [ $RETRY -lt $MAX ]; do
|
|
||||||
RETRY=$((RETRY + 1))
|
|
||||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
|
||||||
echo ""
|
|
||||||
echo "OK Health check passed (attempt $RETRY/$MAX)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
|
||||||
sleep $WAIT
|
|
||||||
NEXT=$((WAIT + RETRY))
|
|
||||||
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
|
||||||
done
|
|
||||||
echo "ERROR Health check failed after $MAX attempts"
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Smoke Test
|
|
||||||
run: |
|
|
||||||
PASS=0; FAIL=0; BASE="https://nexus.noveria.net"
|
|
||||||
check() {
|
|
||||||
local path="$1" label="$2" expected="${3:-200}"
|
|
||||||
local code
|
|
||||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
|
||||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
|
||||||
if [ "$code" = "$expected" ]; then
|
|
||||||
echo " OK"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " FAIL (expected $expected)"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
check "/dashboard" "Dashboard" 200
|
|
||||||
check "/health" "Health API" 200
|
|
||||||
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
|
||||||
if [ "$FAIL" -gt 0 ]; then
|
|
||||||
echo "ERROR Smoke test failed"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "OK Smoke test passed"
|
|
||||||
|
|
||||||
- name: Summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "========================================"
|
|
||||||
echo " Deploy Summary"
|
|
||||||
echo "========================================"
|
|
||||||
echo " Version: v${{ steps.version.outputs.version }}"
|
|
||||||
echo " Git ref: main"
|
|
||||||
echo " Service: all"
|
|
||||||
echo " Trigger: Manual"
|
|
||||||
echo " Status: ${{ job.status }}"
|
|
||||||
echo "========================================"
|
|
||||||
@@ -7,7 +7,7 @@ run-name: 🚀 Deploy v2 by @${{ gitea.actor }}
|
|||||||
#
|
#
|
||||||
# Triggers:
|
# Triggers:
|
||||||
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
||||||
# → Uses safe defaults: patch bump, all services, main ref.
|
# → Deploys main with the VERSION already present in the repo.
|
||||||
# → Commits marked with [skip ci] are filtered at job level
|
# → Commits marked with [skip ci] are filtered at job level
|
||||||
# (prevents version-bump loops).
|
# (prevents version-bump loops).
|
||||||
# 2. MANUAL via workflow_dispatch with full parameter control.
|
# 2. MANUAL via workflow_dispatch with full parameter control.
|
||||||
@@ -17,9 +17,8 @@ run-name: 🚀 Deploy v2 by @${{ gitea.actor }}
|
|||||||
#
|
#
|
||||||
# Version Management:
|
# Version Management:
|
||||||
# The VERSION file in the repo root is the single source of truth.
|
# The VERSION file in the repo root is the single source of truth.
|
||||||
# Version bumps happen in the Dev workflow BEFORE merge to main.
|
# Deploy only reads, validates, and logs the version.
|
||||||
# The deploy workflow only reads, validates, and logs the version.
|
# Version changes happen before merge to main, not during deploy.
|
||||||
# The [skip ci] filter remains as a safety layer for auto-triggers.
|
|
||||||
# ───────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
@@ -52,9 +51,8 @@ jobs:
|
|||||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
|
# Owner password is not injected at deploy time.
|
||||||
# This ensures the password stays consistent across deploys and the DB is the
|
# After first seed, the database is the only password source.
|
||||||
# single source of truth after initial seed (enforced by SeedAudit guard).
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
@@ -113,37 +111,25 @@ jobs:
|
|||||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Step 4: Build .env from secrets + host .env (SAFE)
|
# Step 4: Build .env from secrets (SAFE)
|
||||||
#
|
#
|
||||||
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||||
# to a file inside the workspace that gets rsync'd to
|
# to a file inside the workspace that gets rsync'd to
|
||||||
# the host. The temp file is deleted immediately after
|
# the host. The temp file is deleted immediately after
|
||||||
# compose operations complete.
|
# compose operations complete.
|
||||||
#
|
#
|
||||||
# OWNER_PASSWORD is read from the host's persistent .env
|
# Owner password is deliberately omitted so production deploys
|
||||||
# to ensure it stays the single source of truth. Other
|
# cannot overwrite the persisted DB password.
|
||||||
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
# Other secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
||||||
# come from Gitea secrets.
|
# come from Gitea secrets.
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Prepare .env (secrets + host .env → temp file)
|
- name: Prepare .env (secrets → temp file)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Read OWNER_PASSWORD from the host's persistent .env
|
|
||||||
HOST_OWNER_PASSWORD=""
|
|
||||||
if [ -f "${DEPLOY_PATH}/.env" ]; then
|
|
||||||
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
|
|
||||||
fi
|
|
||||||
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
|
||||||
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
|
||||||
echo " The host .env is the single source of truth for the owner password."
|
|
||||||
echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "${ENV_TMPFILE}" <<EOF
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
# Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
|
# Managed via Gitea Secrets → do NOT edit manually.
|
||||||
# This file lives in /tmp and is removed after deploy completes.
|
# This file lives in /tmp and is removed after deploy completes.
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
@@ -151,9 +137,7 @@ jobs:
|
|||||||
JWT_KEY=${ENV_JWT_KEY}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
|
||||||
OWNER_DISPLAY_NAME=
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
@@ -205,18 +189,10 @@ set -e
|
|||||||
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
cat > /tmp/nexus-deploy-env
|
cat > /tmp/nexus-deploy-env
|
||||||
|
|
||||||
# ── Clean up zombie containers ──
|
# ── Graceful shutdown (preserves DB volume integrity) ──
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env stop postgres 2>/dev/null || true
|
||||||
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
|
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
|
||||||
docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true
|
echo "Postgres volume preserved (nexus-postgres) — no WAL reset"
|
||||||
|
|
||||||
# ── WAL recovery ──
|
|
||||||
PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)
|
|
||||||
if [ -n "$PG_VOL" ]; then
|
|
||||||
echo "Checking postgres WAL integrity..."
|
|
||||||
docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'" 2>&1 || echo "pg_resetwal failed (may be benign)"
|
|
||||||
else
|
|
||||||
echo "Postgres volume not found - will be created fresh"
|
|
||||||
fi
|
|
||||||
|
|
||||||
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
||||||
SERVICE="${DEPLOY_SERVICE:-}"
|
SERVICE="${DEPLOY_SERVICE:-}"
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ run-name: 🔙 Rollback by @${{ gitea.actor }}
|
|||||||
# migrations). If the tag predates a destructive migration, manual
|
# migrations). If the tag predates a destructive migration, manual
|
||||||
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
||||||
# ───────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Rollback wins over queued/in-progress deploys.
|
||||||
|
# It shares deploy-production with deploy.yaml so rollback and deploy never run together,
|
||||||
|
# but cancel-in-progress=true prevents a queued auto-deploy from running after rollback.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: true
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -94,22 +97,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Step 3: Prepare .env from secrets + host .env (safe temp file)
|
# Step 3: Prepare .env from secrets (safe temp file)
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Prepare .env (secrets + host .env → temp file)
|
- name: Prepare .env (secrets → temp file)
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Read OWNER_PASSWORD from the host's persistent .env
|
|
||||||
HOST_OWNER_PASSWORD=""
|
|
||||||
if [ -f "${DEPLOY_PATH}/.env" ]; then
|
|
||||||
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
|
|
||||||
fi
|
|
||||||
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
|
||||||
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "${ENV_TMPFILE}" <<EOF
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
@@ -118,9 +111,7 @@ jobs:
|
|||||||
JWT_KEY=${ENV_JWT_KEY}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
|
||||||
OWNER_DISPLAY_NAME=
|
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
!.env.example
|
|
||||||
!.env.template
|
!.env.template
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||||
|
|
||||||
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||||
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
> on main or can be triggered **manually** (workflow_dispatch). Deploy reads
|
||||||
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
> `VERSION` but does not mutate Git or create tags. Rollback and database backup
|
||||||
> stay read-only. Rollback and database backup are separate manual workflows.
|
> are separate manual workflows.
|
||||||
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||||
|
|
||||||
## Current foundation
|
## Current foundation
|
||||||
@@ -26,16 +26,16 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
## Local/container start
|
## Local/container start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.template .env
|
||||||
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
|
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
|
||||||
# OWNER_EMAIL and OWNER_PASSWORD.
|
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
curl http://127.0.0.1:18880/health
|
curl http://127.0.0.1:18880/health
|
||||||
```
|
```
|
||||||
|
|
||||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
|
||||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
|
derives the initial display name from that email, and logs a generated temporary password once.
|
||||||
characters. Existing databases are never overwritten by the bootstrap process.
|
After first seed the password lives only in PostgreSQL. Existing databases are
|
||||||
|
never overwritten by the bootstrap process.
|
||||||
|
|
||||||
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||||
Health checks, rate limiting, and security headers are active.
|
Health checks, rate limiting, and security headers are active.
|
||||||
@@ -358,17 +358,15 @@ Deployment can happen automatically or manually:
|
|||||||
#### Auto-Deploy (after successful CI on main)
|
#### Auto-Deploy (after successful CI on main)
|
||||||
|
|
||||||
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
||||||
- Uses safe defaults: `patch` bump, all services, main ref
|
- Deploys the current `main` version after CI succeeds.
|
||||||
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
|
- Skips automatically if the triggering commit contains `[skip ci]`
|
||||||
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
|
- The deploy workflow reads `VERSION`; it does not mutate Git, bump versions, or create tags
|
||||||
|
|
||||||
#### Manual Deploy (`workflow_dispatch`)
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
|
1. DevOps triggers `Deploy Nexus v2` in Gitea Actions
|
||||||
2. Chooses version bump type: patch (default) / minor / major
|
2. Workflow validates `VERSION`, builds and deploys `main`
|
||||||
3. Optionally scopes to a single service or specific git ref
|
3. Health check + smoke test verify the deployment
|
||||||
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
|
||||||
5. Health check + smoke test verify the deployment
|
|
||||||
|
|
||||||
#### Rollback (`workflow_dispatch`)
|
#### Rollback (`workflow_dispatch`)
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,94 @@ public class AgentServiceTests
|
|||||||
Assert.Null(agent);
|
Assert.Null(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
|
||||||
|
{
|
||||||
|
var configPath = CreateAgentConfigFile();
|
||||||
|
var config = CreateConfiguration(configPath);
|
||||||
|
var runtime = new FakeRuntime();
|
||||||
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
|
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains("product-owner", ids);
|
||||||
|
Assert.Contains("programmer-fast", ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
|
||||||
|
{
|
||||||
|
var configPath = CreateAgentConfigFile();
|
||||||
|
var config = CreateConfiguration(configPath);
|
||||||
|
var runtime = new FakeRuntime();
|
||||||
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
|
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(agent);
|
||||||
|
Assert.Equal("Developer", agent.Role);
|
||||||
|
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
|
||||||
|
{
|
||||||
|
var configPath = CreateAgentConfigFile(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": "deepseek/deepseek-v4-flash"
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "iris",
|
||||||
|
"name": "iris",
|
||||||
|
"model": "openai/gpt-5.5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var config = CreateConfiguration(configPath);
|
||||||
|
var service = new AgentService(config, new FakeRuntime());
|
||||||
|
|
||||||
|
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(agent);
|
||||||
|
Assert.Equal("openai/gpt-5.5", agent!.Model);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
|
||||||
|
{
|
||||||
|
var configPath = CreateAgentConfigFile(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": "openai/gpt-5.5-mini"
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "reviewer",
|
||||||
|
"name": "reviewer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
var config = CreateConfiguration(configPath);
|
||||||
|
var service = new AgentService(config, new FakeRuntime());
|
||||||
|
|
||||||
|
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(agent);
|
||||||
|
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
|
||||||
|
}
|
||||||
|
|
||||||
private static IConfiguration CreateConfiguration(string configPath)
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
=> new ConfigurationBuilder()
|
=> new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
@@ -53,10 +141,10 @@ public class AgentServiceTests
|
|||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
private static string CreateAgentConfigFile()
|
private static string CreateAgentConfigFile(string? json = null)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
File.WriteAllText(path,
|
File.WriteAllText(path, json ??
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -69,19 +157,33 @@ public class AgentServiceTests
|
|||||||
"list": [
|
"list": [
|
||||||
{
|
{
|
||||||
"id": "iris",
|
"id": "iris",
|
||||||
"name": "iris"
|
"name": "iris",
|
||||||
|
"model": { "primary": "openai/gpt-5.5" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "product-owner",
|
||||||
|
"name": "product-owner",
|
||||||
|
"model": { "primary": "openai/gpt-5.5" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "programmer",
|
"id": "programmer",
|
||||||
"name": "programmer"
|
"name": "programmer",
|
||||||
|
"model": { "primary": "openai/gpt-5.4" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "programmer-fast",
|
||||||
|
"name": "programmer-fast",
|
||||||
|
"model": { "primary": "openai/gpt-5.3-codex-spark" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "reviewer",
|
"id": "reviewer",
|
||||||
"name": "reviewer"
|
"name": "reviewer",
|
||||||
|
"model": { "primary": "openai/gpt-5.5" }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "architekt",
|
"id": "architekt",
|
||||||
"name": "architekt"
|
"name": "architekt",
|
||||||
|
"model": { "primary": "openai/gpt-5.5" }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for AuthService login, change-password, admin-reset, and related flows.
|
||||||
|
/// These are unit-level tests using an in-memory EF Core database so no
|
||||||
|
/// external PostgreSQL instance is needed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthServiceTests
|
||||||
|
{
|
||||||
|
// ── Fixture helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a test fixture with an in-memory database, a UserRepository,
|
||||||
|
/// and an AuthService backed by an in-memory configuration.
|
||||||
|
/// </summary>
|
||||||
|
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
var db = new NexusDbContext(options);
|
||||||
|
var repo = new UserRepository(db);
|
||||||
|
|
||||||
|
// In-memory config with minimum required JWT settings
|
||||||
|
var config = new MemoryConfig(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
|
||||||
|
["Jwt:Issuer"] = "nexus-test",
|
||||||
|
["Jwt:Audience"] = "nexus-test-web",
|
||||||
|
});
|
||||||
|
|
||||||
|
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
|
||||||
|
var auth = new AuthService(repo, config, logger);
|
||||||
|
return (db, repo, auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoginRequest Login(string email, string password)
|
||||||
|
=> new() { Email = email, Password = password };
|
||||||
|
|
||||||
|
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
|
||||||
|
{
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail(email),
|
||||||
|
DisplayName = email.Split('@')[0],
|
||||||
|
PasswordHash = PasswordSecurity.Hash(password),
|
||||||
|
Role = role
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Password Security Unit Tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Hash_And_Verify_RoundTrip_Succeeds()
|
||||||
|
{
|
||||||
|
const string password = "MyTestPassword123!";
|
||||||
|
var hash = PasswordSecurity.Hash(password);
|
||||||
|
Assert.NotNull(hash);
|
||||||
|
Assert.StartsWith("v1.", hash);
|
||||||
|
|
||||||
|
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.False(needsUpgrade);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_WrongPassword_Fails()
|
||||||
|
{
|
||||||
|
var hash = PasswordSecurity.Hash("CorrectPassword123!");
|
||||||
|
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_EmptyHash_ReturnsFalse()
|
||||||
|
{
|
||||||
|
Assert.False(PasswordSecurity.Verify("password", "", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
|
||||||
|
{
|
||||||
|
const string password = "OldFormatPassword123!";
|
||||||
|
var legacyHash = Convert.ToHexString(
|
||||||
|
System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(password)));
|
||||||
|
|
||||||
|
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.True(needsUpgrade);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Login Tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithValidCredentials_Succeeds()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string password = "ValidPassword123!";
|
||||||
|
await SeedUserAsync(db, "test@example.com", password);
|
||||||
|
|
||||||
|
var session = await auth.LoginAsync(Login("test@example.com", password));
|
||||||
|
Assert.NotNull(session);
|
||||||
|
Assert.Equal("test", session.User.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithWrongPassword_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
|
||||||
|
|
||||||
|
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithNonexistentEmail_ReturnsNull()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_UpdatesLastLoginAt()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string password = "TestPassword123!";
|
||||||
|
var user = await SeedUserAsync(db, "test@example.com", password);
|
||||||
|
|
||||||
|
var beforeLogin = user.LastLoginAt;
|
||||||
|
await Task.Delay(10);
|
||||||
|
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
|
||||||
|
|
||||||
|
var updated = await repo.GetByIdAsync(user.Id);
|
||||||
|
Assert.NotNull(updated!.LastLoginAt);
|
||||||
|
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that LoginAsync persists a password hash upgrade AND login
|
||||||
|
/// timestamps even when there are NO expired refresh tokens. Previously
|
||||||
|
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
|
||||||
|
/// but that only happens when oldTokens.Count > 0.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string password = "LegacyUpgradePassword123!";
|
||||||
|
|
||||||
|
var legacyHash = Convert.ToHexString(
|
||||||
|
System.Security.Cryptography.SHA256.HashData(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(password)));
|
||||||
|
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Email = "legacy@example.com",
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
|
||||||
|
DisplayName = "Legacy",
|
||||||
|
PasswordHash = legacyHash,
|
||||||
|
Role = "user"
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Login triggers hash upgrade
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
|
||||||
|
|
||||||
|
var updated = await repo.GetByIdAsync(user.Id);
|
||||||
|
Assert.NotNull(updated);
|
||||||
|
Assert.StartsWith("v1.", updated.PasswordHash);
|
||||||
|
Assert.NotEqual(legacyHash, updated.PasswordHash);
|
||||||
|
|
||||||
|
// Second login with the upgraded hash should also work
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithExistingHash_DoesNotChangeHash()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string password = "StablePassword123!";
|
||||||
|
var user = await SeedUserAsync(db, "stable@example.com", password);
|
||||||
|
|
||||||
|
var originalHash = user.PasswordHash;
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
|
||||||
|
|
||||||
|
var updated = await repo.GetByIdAsync(user.Id);
|
||||||
|
Assert.NotNull(updated);
|
||||||
|
Assert.Equal(originalHash, updated.PasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Change Password Tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string oldPw = "OldPassword123!";
|
||||||
|
const string newPw = "NewPassword456!";
|
||||||
|
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
|
||||||
|
|
||||||
|
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
|
||||||
|
{
|
||||||
|
CurrentPassword = oldPw,
|
||||||
|
NewPassword = newPw
|
||||||
|
});
|
||||||
|
Assert.True(result);
|
||||||
|
|
||||||
|
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
|
||||||
|
|
||||||
|
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
|
||||||
|
{
|
||||||
|
CurrentPassword = "WrongPassword456!",
|
||||||
|
NewPassword = "NewPassword789!"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Admin Reset Password Tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdminResetPassword_WithValidToken_Succeeds()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
|
||||||
|
|
||||||
|
const string oldPw = "OldPassword123!";
|
||||||
|
const string newPw = "NewAdminPassword456!";
|
||||||
|
await SeedUserAsync(db, "adminreset@example.com", oldPw);
|
||||||
|
|
||||||
|
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
|
||||||
|
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
|
||||||
|
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdminResetPassword_WithInvalidToken_Fails()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
|
||||||
|
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
|
||||||
|
|
||||||
|
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdminResetPassword_NonexistentUser_Fails()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
|
||||||
|
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AdminResetPassword_ShortPassword_Fails()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
|
||||||
|
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Profile Update Tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_ChangesDisplayName()
|
||||||
|
{
|
||||||
|
var (db, repo, auth) = CreateFixture();
|
||||||
|
const string password = "Password123!";
|
||||||
|
var user = await SeedUserAsync(db, "profile@example.com", password);
|
||||||
|
|
||||||
|
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
|
||||||
|
{
|
||||||
|
DisplayName = "New Name"
|
||||||
|
});
|
||||||
|
Assert.NotNull(updated);
|
||||||
|
Assert.Equal("New Name", updated.DisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// NormalizeEmail
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeEmail_TrimsAndUppercases()
|
||||||
|
{
|
||||||
|
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
|
||||||
|
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal in-memory IConfiguration implementation for unit tests.
|
||||||
|
/// Reads from a case-insensitive dictionary.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string?> _data;
|
||||||
|
private readonly Dictionary<string, MemoryConfigSection> _sections;
|
||||||
|
|
||||||
|
public MemoryConfig(Dictionary<string, string?> data)
|
||||||
|
{
|
||||||
|
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
|
||||||
|
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? this[string key]
|
||||||
|
{
|
||||||
|
get => _data.TryGetValue(key, out var val) ? val : null;
|
||||||
|
set => _data[key] = value ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
|
||||||
|
{
|
||||||
|
if (!_sections.TryGetValue(key, out var section))
|
||||||
|
{
|
||||||
|
section = new MemoryConfigSection(key, this);
|
||||||
|
_sections[key] = section;
|
||||||
|
}
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||||
|
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||||
|
|
||||||
|
public IChangeToken GetReloadToken()
|
||||||
|
=> NeverToken.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
|
||||||
|
{
|
||||||
|
public string Key => path.Split(':').Last();
|
||||||
|
public string Path => path;
|
||||||
|
public string? Value { get => root[path]; set => root[path] = value; }
|
||||||
|
|
||||||
|
public string? this[string key]
|
||||||
|
{
|
||||||
|
get => root[$"{path}:{key}"];
|
||||||
|
set => root[$"{path}:{key}"] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
|
||||||
|
=> root.GetSection($"{path}:{key}");
|
||||||
|
|
||||||
|
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||||
|
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||||
|
|
||||||
|
public IChangeToken GetReloadToken()
|
||||||
|
=> NeverToken.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
|
||||||
|
internal sealed class NeverToken : IChangeToken
|
||||||
|
{
|
||||||
|
public static readonly NeverToken Instance = new();
|
||||||
|
public bool HasChanged => false;
|
||||||
|
public bool ActiveChangeCallbacks => false;
|
||||||
|
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class NoopDisposable : IDisposable
|
||||||
|
{
|
||||||
|
public static readonly NoopDisposable Instance = new();
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<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.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
@@ -0,0 +1,548 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["X-Agent-Id"] = "programmer-fast"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.GetBoard(CancellationToken.None);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = TaskWorkflowFixture.CreateHttpContext()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
await result.ExecuteAsync(httpContext);
|
||||||
|
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly NexusDbContext _db;
|
||||||
|
|
||||||
|
private TaskWorkflowFixture(
|
||||||
|
NexusDbContext db,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ITaskRepository taskRepository,
|
||||||
|
IActivityRepository activityRepository,
|
||||||
|
INotificationService notificationService,
|
||||||
|
ILiveUpdateService liveUpdateService,
|
||||||
|
ITaskService taskService,
|
||||||
|
ITaskBridgeService taskBridgeService,
|
||||||
|
IAgentService agentService,
|
||||||
|
HttpContextAccessor httpContextAccessor)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
Configuration = configuration;
|
||||||
|
TaskRepository = taskRepository;
|
||||||
|
ActivityRepository = activityRepository;
|
||||||
|
NotificationService = notificationService;
|
||||||
|
LiveUpdateService = liveUpdateService;
|
||||||
|
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 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);
|
||||||
|
var taskRepository = new TaskRepository(db);
|
||||||
|
var notificationService = new NotificationService(db, liveUpdateService);
|
||||||
|
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
||||||
|
|
||||||
|
var taskService = new TaskService(
|
||||||
|
taskRepository,
|
||||||
|
activityRepository,
|
||||||
|
notificationService,
|
||||||
|
agentService,
|
||||||
|
httpContextAccessor,
|
||||||
|
liveUpdateService);
|
||||||
|
|
||||||
|
var taskBridgeService = new TaskBridgeService(
|
||||||
|
taskService,
|
||||||
|
agentService,
|
||||||
|
activityRepository,
|
||||||
|
notificationService,
|
||||||
|
liveUpdateService);
|
||||||
|
|
||||||
|
return new TaskWorkflowFixture(
|
||||||
|
db,
|
||||||
|
configuration,
|
||||||
|
taskRepository,
|
||||||
|
activityRepository,
|
||||||
|
notificationService,
|
||||||
|
liveUpdateService,
|
||||||
|
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<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
|
||||||
@@ -16,6 +16,8 @@ public class DashboardController(
|
|||||||
ITaskService taskService,
|
ITaskService taskService,
|
||||||
IActivityRepository activityService,
|
IActivityRepository activityService,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IAgentService agentService,
|
||||||
|
IConfiguration configuration,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
ILiveUpdateService liveUpdateService) : ControllerBase
|
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -191,9 +193,15 @@ public class DashboardController(
|
|||||||
|
|
||||||
// ── Task Board Endpoints ──
|
// ── Task Board Endpoints ──
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpGet("tasks/board")]
|
[HttpGet("tasks/board")]
|
||||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||||
=> await taskService.GetBoardAsync(ct);
|
{
|
||||||
|
if (!await CanReadBoardAsync(ct))
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
return Ok(await taskService.GetBoardAsync(ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("live")]
|
[HttpGet("live")]
|
||||||
public async Task Live(
|
public async Task Live(
|
||||||
@@ -320,8 +328,17 @@ public class DashboardController(
|
|||||||
[HttpGet("tasks/{id:guid}/children")]
|
[HttpGet("tasks/{id:guid}/children")]
|
||||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var children = await taskService.GetChildTasksAsync(id, ct);
|
var board = await taskService.GetBoardAsync(ct);
|
||||||
return Ok(children.Select(MapToDto).ToList());
|
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}")]
|
[HttpGet("tasks/{id:guid}")]
|
||||||
@@ -401,7 +418,7 @@ public class DashboardController(
|
|||||||
var task = await taskService.CreateAgentTaskAsync(
|
var task = await taskService.CreateAgentTaskAsync(
|
||||||
request.Title, request.Detail, request.Source ?? "iris",
|
request.Title, request.Detail, request.Source ?? "iris",
|
||||||
request.Priority, request.AssignedTo, request.ExpectedFrom,
|
request.Priority, request.AssignedTo, request.ExpectedFrom,
|
||||||
request.ParentTaskId, ct);
|
request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
|
||||||
|
|
||||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
}
|
}
|
||||||
@@ -415,4 +432,16 @@ public class DashboardController(
|
|||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
t.IsAgentTask, t.ExpectedFrom);
|
t.IsAgentTask, t.ExpectedFrom);
|
||||||
|
|
||||||
|
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
|
||||||
|
if (!string.IsNullOrWhiteSpace(allowedAgent))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return User.Identity?.IsAuthenticated == true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers;
|
|||||||
public class GatewayBridgeController(
|
public class GatewayBridgeController(
|
||||||
ITaskBridgeService bridge,
|
ITaskBridgeService bridge,
|
||||||
IAgentService agentService,
|
IAgentService agentService,
|
||||||
|
IConfiguration configuration,
|
||||||
ILogger<GatewayBridgeController> logger) : ControllerBase
|
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
private const string ApikeyErrorMessage =
|
private const string ApikeyErrorMessage =
|
||||||
@@ -101,6 +102,7 @@ public class GatewayBridgeController(
|
|||||||
priority: command.Priority ?? "Normal",
|
priority: command.Priority ?? "Normal",
|
||||||
assignedTo: command.AssignedTo,
|
assignedTo: command.AssignedTo,
|
||||||
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||||
|
startsInProgress: command.StartsInProgress,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
|
|
||||||
return MapResult(result, "create_child_task");
|
return MapResult(result, "create_child_task");
|
||||||
@@ -232,12 +234,13 @@ public class GatewayBridgeController(
|
|||||||
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||||
|
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
|
||||||
|
|
||||||
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
{
|
{
|
||||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||||
if (allowedAgentIds.Contains(normalizedHeader))
|
if (allowedActorIds.Contains(normalizedHeader))
|
||||||
return (true, normalizedHeader, null);
|
return (true, normalizedHeader, null);
|
||||||
|
|
||||||
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||||
@@ -248,14 +251,17 @@ public class GatewayBridgeController(
|
|||||||
if (User.Identity?.IsAuthenticated == true)
|
if (User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
|
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
|
||||||
return (true, normalizedClaim, null);
|
return (true, normalizedClaim, null);
|
||||||
|
|
||||||
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
|
// Browser JWT fallback is intentionally restricted to board owners/admins.
|
||||||
|
// Agent/service traffic should authenticate as an allowed agent or service principal.
|
||||||
|
if (User.IsInRole("owner") || User.IsInRole("admin"))
|
||||||
return (true, "bao", null);
|
return (true, "bao", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
|
if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
|
||||||
|
allowedActorIds.Contains("nexus-system"))
|
||||||
return (true, "nexus-system", null);
|
return (true, "nexus-system", null);
|
||||||
|
|
||||||
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||||
@@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand(
|
|||||||
string? Detail = null,
|
string? Detail = null,
|
||||||
string? Priority = null,
|
string? Priority = null,
|
||||||
string? AssignedTo = null,
|
string? AssignedTo = null,
|
||||||
string? ExpectedFrom = null
|
string? ExpectedFrom = null,
|
||||||
|
bool StartsInProgress = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record BridgeUpdateStatusCommand(string State);
|
public sealed record BridgeUpdateStatusCommand(string State);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace Nexus.Api.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
|
public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
@@ -117,12 +117,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
|||||||
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
||||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpGet("board")]
|
[HttpGet("board")]
|
||||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
|
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
|
||||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
|
||||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
|
||||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
||||||
@@ -136,33 +136,28 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
|||||||
/// Wird vom Iris Autonomous Worker genutzt.
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
///
|
///
|
||||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||||
/// X-Nexus-Api-Key / JWT-authenticated user.
|
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
|
||||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpPost("reset-stale")]
|
[HttpPost("reset-stale")]
|
||||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
|
||||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
|
||||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
|
||||||
|
|
||||||
|
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!isIris && !isService && !isPrivilegedUser)
|
||||||
|
{
|
||||||
|
// A presented but unrecognized agent header is an invalid credential, not a missing one.
|
||||||
|
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
|
||||||
|
return Results.Forbid();
|
||||||
|
|
||||||
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
|
|
||||||
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
|
|
||||||
if (!isIris && !isApiKey && !isAuth)
|
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||||
return Results.Ok(new ResetStaleResponse(count));
|
return Results.Ok(new ResetStaleResponse(count));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
|
|
||||||
{
|
|
||||||
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
|
||||||
if (string.IsNullOrWhiteSpace(headerValue))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var normalized = headerValue.Trim().ToLowerInvariant();
|
|
||||||
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
|
|
||||||
return allowed.Contains(normalized) ? normalized : null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public static class ApplicationBuilderExtensions
|
|||||||
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
/// 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
|
/// 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.
|
/// 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>
|
/// </summary>
|
||||||
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
||||||
{
|
{
|
||||||
@@ -30,25 +34,30 @@ public static class ApplicationBuilderExtensions
|
|||||||
if (alreadySeeded)
|
if (alreadySeeded)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
|
||||||
var ownerPassword = configuration["Owner:Password"];
|
|
||||||
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
|
|
||||||
var hasUsers = await db.Users.AnyAsync();
|
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 (!hasUsers)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
|
||||||
|
|
||||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
|
||||||
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
|
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
|
||||||
: ownerDisplayName;
|
|
||||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
|
||||||
? PasswordHelper.GenerateTemporaryPassword()
|
|
||||||
: ownerPassword;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
|
||||||
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
|
||||||
|
|
||||||
db.Users.Add(new NexusUser
|
db.Users.Add(new NexusUser
|
||||||
{
|
{
|
||||||
@@ -58,18 +67,16 @@ public static class ApplicationBuilderExtensions
|
|||||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||||
Role = "owner"
|
Role = "owner"
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Record the seed attempt regardless of whether users already existed.
|
// Record the seed attempt regardless of whether users already existed.
|
||||||
// This prevents re-seeding even if the Users table is wiped.
|
// This prevents re-seeding even if the Users table is wiped.
|
||||||
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
|
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
await tx.CommitAsync();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ public sealed record CreateAgentTaskRequest(
|
|||||||
string? Priority,
|
string? Priority,
|
||||||
string? AssignedTo,
|
string? AssignedTo,
|
||||||
string? ExpectedFrom,
|
string? ExpectedFrom,
|
||||||
Guid? ParentTaskId = null
|
Guid? ParentTaskId = null,
|
||||||
|
bool StartsInProgress = true,
|
||||||
|
string? InitialState = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateDashboardTaskRequest(
|
public sealed record UpdateDashboardTaskRequest(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public static class AgentIdentityCatalog
|
||||||
|
{
|
||||||
|
public static readonly string[] DefaultConfiguredAgentIds =
|
||||||
|
[
|
||||||
|
"main",
|
||||||
|
"iris",
|
||||||
|
"product-owner",
|
||||||
|
"programmer",
|
||||||
|
"programmer-fast",
|
||||||
|
"reviewer",
|
||||||
|
"architekt",
|
||||||
|
"researcher",
|
||||||
|
"executor"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] WorkflowActorIds =
|
||||||
|
[
|
||||||
|
"bao",
|
||||||
|
"nexus-system"
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
|
||||||
|
{
|
||||||
|
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var configuredAgentId in configuredAgentIds)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(configuredAgentId))
|
||||||
|
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actorId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var normalized = actorId.Trim().ToLowerInvariant();
|
||||||
|
return allowedActorIds.Contains(normalized) ? normalized : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ public sealed record AgentConfig
|
|||||||
public string? AgentDir { get; init; }
|
public string? AgentDir { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public string? Model { get; init; }
|
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||||
|
public AgentModelConfig? Model { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("identity")]
|
[JsonPropertyName("identity")]
|
||||||
public AgentIdentityConfig? Identity { get; init; }
|
public AgentIdentityConfig? Identity { get; init; }
|
||||||
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
|
|||||||
public string Theme { get; init; } = string.Empty;
|
public string Theme { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AgentModelConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("primary")]
|
||||||
|
public string? Primary { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
|
||||||
|
{
|
||||||
|
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
if (reader.TokenType == JsonTokenType.Null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (reader.TokenType == JsonTokenType.String)
|
||||||
|
{
|
||||||
|
var primary = reader.GetString();
|
||||||
|
return string.IsNullOrWhiteSpace(primary) ? null : new AgentModelConfig { Primary = primary };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reader.TokenType != JsonTokenType.StartObject)
|
||||||
|
throw new JsonException("Agent model must be either a string or an object.");
|
||||||
|
|
||||||
|
using var document = JsonDocument.ParseValue(ref reader);
|
||||||
|
var root = document.RootElement;
|
||||||
|
|
||||||
|
string? primary = null;
|
||||||
|
foreach (var property in root.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
primary = property.Value.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.String => property.Value.GetString(),
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
_ => throw new JsonException("Agent model primary must be a string.")
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AgentModelConfig { Primary = primary };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
if (!string.IsNullOrWhiteSpace(value.Primary))
|
||||||
|
writer.WriteString("primary", value.Primary);
|
||||||
|
else
|
||||||
|
writer.WriteNull("primary");
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record AgentInfo(
|
public sealed record AgentInfo(
|
||||||
string Id,
|
string Id,
|
||||||
string Name,
|
string Name,
|
||||||
@@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
var agents = new List<AgentInfo>(configs.Count);
|
var agents = new List<AgentInfo>(configs.Count);
|
||||||
foreach (var config in configs)
|
foreach (var config in configs)
|
||||||
{
|
{
|
||||||
var model = config.Model ?? "deepseek/deepseek-v4-flash";
|
var model = ResolveModel(config);
|
||||||
var role = DeriveRole(config.Id);
|
var role = DeriveRole(config.Id);
|
||||||
var description = config.Identity?.Theme ?? string.Empty;
|
var description = config.Identity?.Theme ?? string.Empty;
|
||||||
|
|
||||||
@@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
Id: config.Id,
|
Id: config.Id,
|
||||||
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
Name: config.Identity?.Name ?? config.Name ?? config.Id,
|
||||||
Role: role,
|
Role: role,
|
||||||
Model: config.Model ?? "deepseek/deepseek-v4-flash",
|
Model: ResolveModel(config),
|
||||||
Status: runtimeStatus.Status,
|
Status: runtimeStatus.Status,
|
||||||
LastSeen: now,
|
LastSeen: now,
|
||||||
Workspace: config.Workspace,
|
Workspace: config.Workspace,
|
||||||
@@ -159,36 +214,43 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
return configs
|
return configs
|
||||||
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
||||||
.Select(config => config.Id.Trim().ToLowerInvariant())
|
.Select(config => config.Id.Trim().ToLowerInvariant())
|
||||||
|
.DefaultIfEmpty()
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"iris" => "Orchestrator",
|
"iris" => "Orchestrator",
|
||||||
|
"product-owner" => "Product Owner",
|
||||||
"programmer" => "Developer",
|
"programmer" => "Developer",
|
||||||
|
"programmer-fast" => "Developer",
|
||||||
"reviewer" => "Reviewer",
|
"reviewer" => "Reviewer",
|
||||||
"architekt" => "Architect",
|
"architekt" => "Architect",
|
||||||
"main" => "Assistant",
|
"main" => "Assistant",
|
||||||
_ => "Custom"
|
_ => "Custom"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string ResolveModel(AgentConfig config)
|
||||||
|
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
|
||||||
|
|
||||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||||
?? "/home/node/.openclaw/openclaw.json";
|
?? "/home/node/.openclaw/openclaw.json";
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
var json = await File.ReadAllTextAsync(path, cancellationToken);
|
||||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
|
||||||
var root = document.RootElement;
|
var root = document.RootElement;
|
||||||
|
|
||||||
if (!root.TryGetProperty("agents", out var agentsElement))
|
if (!root.TryGetProperty("agents", out var agentsElement))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
if (!agentsElement.TryGetProperty("list", out var listElement))
|
if (!agentsElement.TryGetProperty("list", out var listElement))
|
||||||
return Array.Empty<AgentConfig>();
|
return BuildFallbackConfigs();
|
||||||
|
|
||||||
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
|
||||||
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
|
||||||
@@ -204,29 +266,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
|||||||
// Inherit defaults for missing fields
|
// Inherit defaults for missing fields
|
||||||
if (string.IsNullOrWhiteSpace(config.Name))
|
if (string.IsNullOrWhiteSpace(config.Name))
|
||||||
config = config with { Name = config.Id };
|
config = config with { Name = config.Id };
|
||||||
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
|
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
|
||||||
config = config with { Model = defaults.Model.Primary };
|
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
|
||||||
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
|
||||||
config = config with { Workspace = defaults.Workspace };
|
config = config with { Workspace = defaults.Workspace };
|
||||||
|
|
||||||
configs.Add(config);
|
configs.Add(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
return configs.AsReadOnly();
|
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
|
||||||
|
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
|
||||||
|
.Select(id => new AgentConfig
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Name = id,
|
||||||
|
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
.AsReadOnly();
|
||||||
|
|
||||||
private sealed record AgentDefaults
|
private sealed record AgentDefaults
|
||||||
{
|
{
|
||||||
[JsonPropertyName("workspace")]
|
[JsonPropertyName("workspace")]
|
||||||
public string? Workspace { get; init; }
|
public string? Workspace { get; init; }
|
||||||
|
|
||||||
[JsonPropertyName("model")]
|
[JsonPropertyName("model")]
|
||||||
public AgentDefaultModel? Model { get; init; }
|
[JsonConverter(typeof(AgentModelConfigConverter))]
|
||||||
}
|
public AgentModelConfig? Model { get; init; }
|
||||||
|
|
||||||
private sealed record AgentDefaultModel
|
|
||||||
{
|
|
||||||
[JsonPropertyName("primary")]
|
|
||||||
public string? Primary { get; init; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
|
|||||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
user.UpdatedAt = DateTimeOffset.UtcNow;
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Persist user changes (password upgrade, login timestamp) immediately.
|
||||||
|
// Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to
|
||||||
|
// trigger SaveChangesAsync is fragile — if zero tokens are expired the
|
||||||
|
// tracked changes might not be flushed before the response is produced.
|
||||||
|
await _users.UpdateAsync(user, ct);
|
||||||
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
await _users.RemoveExpiredTokensAsync(user.Id, ct);
|
||||||
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
|
|||||||
string? priority = "Normal",
|
string? priority = "Normal",
|
||||||
string? assignedTo = null,
|
string? assignedTo = null,
|
||||||
string? expectedFrom = null,
|
string? expectedFrom = null,
|
||||||
|
bool startsInProgress = false,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ public interface ITaskService
|
|||||||
// Dashboard-facing task operations
|
// Dashboard-facing task operations
|
||||||
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||||
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
|
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||||
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
|
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
|
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||||
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public static class RequestAuthorizationHelper
|
||||||
|
{
|
||||||
|
public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized);
|
||||||
|
|
||||||
|
public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) =>
|
||||||
|
httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration);
|
||||||
|
|
||||||
|
public static bool IsPrivilegedUser(HttpContext httpContext) =>
|
||||||
|
httpContext.User.Identity?.IsAuthenticated == true &&
|
||||||
|
(httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin"));
|
||||||
|
|
||||||
|
public static async Task<string?> ResolveAllowedAgentHeaderAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAgentService agentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
|
||||||
|
|
||||||
|
public static async Task<AgentHeaderResolution> ResolveAgentHeaderAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAgentService agentService,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (string.IsNullOrWhiteSpace(headerValue))
|
||||||
|
return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false);
|
||||||
|
|
||||||
|
var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
||||||
|
var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed);
|
||||||
|
return new AgentHeaderResolution(
|
||||||
|
normalized,
|
||||||
|
HeaderProvided: true,
|
||||||
|
IsRecognized: normalized is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var configuredApiKey = configuration["NexusApiKey"];
|
||||||
|
if (string.IsNullOrWhiteSpace(configuredApiKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ namespace Nexus.Api.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TaskBridgeService(
|
public sealed class TaskBridgeService(
|
||||||
ITaskService taskService,
|
ITaskService taskService,
|
||||||
|
IAgentService agentService,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
ILiveUpdateService liveUpdateService) : ITaskBridgeService
|
ILiveUpdateService liveUpdateService) : ITaskBridgeService
|
||||||
@@ -37,12 +38,11 @@ public sealed class TaskBridgeService(
|
|||||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||||
|
|
||||||
var normalizedSource = NormalizeSource(source);
|
var normalizedSource = NormalizeSource(source);
|
||||||
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
|
|
||||||
|
|
||||||
var task = await taskService.CreateDashboardTaskAsync(
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
|
title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
|
||||||
|
|
||||||
var dto = MapToDto(task);
|
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
return Success(dto);
|
return Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ public sealed class TaskBridgeService(
|
|||||||
string? priority = "Normal",
|
string? priority = "Normal",
|
||||||
string? assignedTo = null,
|
string? assignedTo = null,
|
||||||
string? expectedFrom = null,
|
string? expectedFrom = null,
|
||||||
|
bool startsInProgress = false,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
@@ -66,19 +67,23 @@ public sealed class TaskBridgeService(
|
|||||||
if (parent is null)
|
if (parent is null)
|
||||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
|
||||||
|
|
||||||
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
|
|
||||||
|
|
||||||
var task = await taskService.CreateAgentTaskAsync(
|
var task = await taskService.CreateAgentTaskAsync(
|
||||||
title.Trim(), detail?.Trim(), NormalizeSource(source),
|
title.Trim(), detail?.Trim(), NormalizeSource(source),
|
||||||
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
|
priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct);
|
||||||
|
|
||||||
// If parent was in Backlog, move it to InProgress (coordination starts)
|
// If parent was in Backlog, move it to InProgress (coordination starts)
|
||||||
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
|
var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct);
|
||||||
|
if (parentTransition.Outcome != TaskOperationOutcome.Success)
|
||||||
|
{
|
||||||
|
return Error<DashboardTaskDto>(
|
||||||
|
TaskBridgeOutcome.InvalidState,
|
||||||
|
$"Parent task {parentTaskId} could not be moved to In progress for coordination.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = MapToDto(task);
|
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
return Success(dto);
|
return Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +112,7 @@ public sealed class TaskBridgeService(
|
|||||||
if (result.Outcome != TaskOperationOutcome.Success)
|
if (result.Outcome != TaskOperationOutcome.Success)
|
||||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
|
||||||
|
|
||||||
var dto = MapToDto(result.Task!);
|
var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
|
||||||
return Success(dto);
|
return Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +162,10 @@ public sealed class TaskBridgeService(
|
|||||||
if (task is null)
|
if (task is null)
|
||||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||||
|
|
||||||
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
|
var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
|
||||||
|
if (normalizedTarget is null)
|
||||||
|
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
|
||||||
|
|
||||||
var handoffNote = string.IsNullOrWhiteSpace(note)
|
var handoffNote = string.IsNullOrWhiteSpace(note)
|
||||||
? $"Handoff → {normalizedTarget}"
|
? $"Handoff → {normalizedTarget}"
|
||||||
: $"Handoff → {normalizedTarget}: {note.Trim()}";
|
: $"Handoff → {normalizedTarget}: {note.Trim()}";
|
||||||
@@ -186,7 +194,7 @@ public sealed class TaskBridgeService(
|
|||||||
task.Id,
|
task.Id,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
var dto = MapToDto(task);
|
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
|
||||||
return Success(dto);
|
return Success(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,8 +215,11 @@ public sealed class TaskBridgeService(
|
|||||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||||
Guid parentTaskId, CancellationToken ct = default)
|
Guid parentTaskId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
|
var board = await taskService.GetBoardAsync(ct);
|
||||||
return children.Select(MapToDto).ToList();
|
return FlattenBoard(board)
|
||||||
|
.Where(task => task.ParentTaskId == parentTaskId)
|
||||||
|
.OrderByDescending(task => task.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||||
@@ -233,14 +244,19 @@ public sealed class TaskBridgeService(
|
|||||||
private static string NormalizeSource(string? source) =>
|
private static string NormalizeSource(string? source) =>
|
||||||
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
|
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
private static string? NormalizeAssignedTo(string? assignedTo)
|
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
||||||
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
|
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
|
||||||
return valid.Contains(lower) ? lower : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
||||||
|
=> board.Offen
|
||||||
|
.Concat(board.InProgress)
|
||||||
|
.Concat(board.Review)
|
||||||
|
.Concat(board.Blocked)
|
||||||
|
.Concat(board.Done);
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ public sealed class TaskService(
|
|||||||
ITaskRepository taskRepo,
|
ITaskRepository taskRepo,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
INotificationService notificationService,
|
INotificationService notificationService,
|
||||||
|
IAgentService agentService,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILiveUpdateService liveUpdateService) : ITaskService
|
ILiveUpdateService liveUpdateService) : ITaskService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> ValidAssignees =
|
|
||||||
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
=> await taskRepo.GetAllAsync(ct);
|
=> await taskRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
@@ -90,12 +88,7 @@ public sealed class TaskService(
|
|||||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
task.State = canonical;
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
|
|
||||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
|
||||||
await PublishBoardSnapshotAsync(ct);
|
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||||
@@ -204,7 +197,7 @@ public sealed class TaskService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||||
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
|
||||||
var isVisibleDelegation = parentTaskId.HasValue;
|
var isVisibleDelegation = parentTaskId.HasValue;
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = new WorkTask
|
||||||
@@ -250,14 +243,14 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
public async Task<WorkTask> CreateAgentTaskAsync(
|
public async Task<WorkTask> CreateAgentTaskAsync(
|
||||||
string title, string? detail, string? source, string? priority,
|
string title, string? detail, string? source, string? priority,
|
||||||
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
|
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
|
||||||
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||||
|
|
||||||
task.IsAgentTask = true;
|
task.IsAgentTask = true;
|
||||||
task.ExpectedFrom = normalizedExpectedFrom;
|
task.ExpectedFrom = normalizedExpectedFrom;
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
@@ -322,7 +315,7 @@ public sealed class TaskService(
|
|||||||
}
|
}
|
||||||
if (assignedTo is not null)
|
if (assignedTo is not null)
|
||||||
{
|
{
|
||||||
var validated = ValidateAssignedTo(assignedTo);
|
var validated = await NormalizeActorAsync(assignedTo, ct);
|
||||||
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
|
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
|
||||||
@@ -373,12 +366,24 @@ public sealed class TaskService(
|
|||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||||
task.State = canonical;
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
}
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
|
|
||||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
|
||||||
await PublishBoardSnapshotAsync(ct);
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
|
||||||
|
return await UpdateTaskStatusInternalAsync(
|
||||||
|
task,
|
||||||
|
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
|
||||||
|
actor: "nexus-system",
|
||||||
|
activityType: "delegation",
|
||||||
|
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
|
||||||
|
ct: ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||||
@@ -481,12 +486,7 @@ public sealed class TaskService(
|
|||||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
task.State = canonical;
|
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
|
|
||||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
|
||||||
await PublishBoardSnapshotAsync(ct);
|
|
||||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||||
@@ -577,11 +577,25 @@ public sealed class TaskService(
|
|||||||
t.ParentTaskId.HasValue || t.IsAgentTask);
|
t.ParentTaskId.HasValue || t.IsAgentTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ValidateAssignedTo(string? assignedTo)
|
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
|
||||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||||
return ValidAssignees.Contains(lower) ? lower : null;
|
}
|
||||||
|
|
||||||
|
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(initialState))
|
||||||
|
{
|
||||||
|
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
|
||||||
|
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (canonical is not null)
|
||||||
|
return canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
return startsInProgress
|
||||||
|
? TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||||
|
: TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ResolveCaller()
|
private string ResolveCaller()
|
||||||
@@ -598,10 +612,29 @@ public sealed class TaskService(
|
|||||||
return nameClaim?.ToLowerInvariant() ?? "";
|
return nameClaim?.ToLowerInvariant() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
|
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
|
||||||
|
WorkTask task,
|
||||||
|
string canonical,
|
||||||
|
string actor,
|
||||||
|
string activityType,
|
||||||
|
string? activityMessage,
|
||||||
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var caller = ResolveCaller();
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = activityType,
|
||||||
|
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
|
||||||
|
await PublishBoardSnapshotAsync(ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
|
||||||
|
{
|
||||||
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await notificationService.CreateAsync(
|
await notificationService.CreateAsync(
|
||||||
|
|||||||
+5
-24
@@ -1,9 +1,8 @@
|
|||||||
name: nexus
|
name: nexus
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -29,22 +28,16 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
memory: 512M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 128M
|
memory: 128M
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 5s
|
|
||||||
max_attempts: 3
|
|
||||||
window: 120s
|
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -52,12 +45,8 @@ services:
|
|||||||
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
|
||||||
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
||||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||||
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
|
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||||
# OWNER_PASSWORD is only used during initial seed (first deploy).
|
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||||
# After that the DB is the single source of truth, enforced by SeedAudit.
|
|
||||||
# Default: empty (seed uses a random password if unset on first run).
|
|
||||||
Owner__Password: ${OWNER_PASSWORD:-}
|
|
||||||
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
|
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
@@ -91,22 +80,16 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 128M
|
memory: 128M
|
||||||
reservations:
|
reservations:
|
||||||
memory: 32M
|
memory: 32M
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 5s
|
|
||||||
max_attempts: 3
|
|
||||||
window: 120s
|
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
||||||
@@ -133,13 +116,11 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nexus:
|
nexus:
|
||||||
openclaw_default:
|
openclaw_default:
|
||||||
external: true
|
external: true
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nexus-postgres:
|
nexus-postgres:
|
||||||
|
|||||||
@@ -266,8 +266,10 @@ Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Do
|
|||||||
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
|
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
|
||||||
|
|
||||||
### Mögliche Child-Tasks
|
### Mögliche Child-Tasks
|
||||||
- **Backend-State-Handling anpassen** — Owner: `developer`
|
- **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner`
|
||||||
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer`
|
- **Schnelle Voranalyse / kleiner Patch** — Owner: `programmer-fast`
|
||||||
|
- **Backend-State-Handling anpassen** — Owner: `programmer`
|
||||||
|
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `programmer`
|
||||||
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
|
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
|
||||||
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
|
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
|
||||||
|
|
||||||
@@ -309,6 +311,7 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
|
|||||||
|
|
||||||
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
|
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
|
||||||
- `AssignedTo` zeigt den operativen Owner
|
- `AssignedTo` zeigt den operativen Owner
|
||||||
|
- Child-Tasks dürfen geplant in `Backlog` erstellt werden; nur aktiv gestartete Delegationen beginnen direkt in `In progress`
|
||||||
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
|
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
|
||||||
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
|
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
|
||||||
- UI und Doku müssen dieselbe Sprache sprechen
|
- UI und Doku müssen dieselbe Sprache sprechen
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.pnpm-store/
|
||||||
|
.pnpm-home/
|
||||||
|
.corepack-home/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -32,7 +32,11 @@ const navigate = (label: string) => {
|
|||||||
}
|
}
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
|
|
||||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
|
const standaloneViews = computed(() => {
|
||||||
|
if (route.name === 'Dashboard') return true
|
||||||
|
if (route.meta?.standalone) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAuthenticated) store.refresh()
|
if (auth.isAuthenticated) store.refresh()
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
import type { AgentNodeData } from '../types/agentNode'
|
import type { AgentNodeData } from '../types/agentNode'
|
||||||
|
|
||||||
|
export const TASK_AGENT_OPTIONS = [
|
||||||
|
{ id: '', label: 'Nicht zugewiesen' },
|
||||||
|
{ id: 'bao', label: '👤 Bao' },
|
||||||
|
{ id: 'iris', label: '🤖 Iris' },
|
||||||
|
{ id: 'product-owner', label: '📋 Product Owner' },
|
||||||
|
{ id: 'programmer', label: '🛠 Programmer' },
|
||||||
|
{ id: 'programmer-fast', label: '⚡ Programmer Fast' },
|
||||||
|
{ id: 'reviewer', label: '🔎 Reviewer' },
|
||||||
|
{ id: 'architekt', label: '🏛 Architekt' },
|
||||||
|
{ id: 'researcher', label: '🔬 Researcher' },
|
||||||
|
{ id: 'executor', label: '🚀 Executor' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const TASK_AGENT_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
TASK_AGENT_OPTIONS
|
||||||
|
.filter(option => option.id)
|
||||||
|
.map(option => [option.id, option.label])
|
||||||
|
) as Record<string, string>
|
||||||
|
|
||||||
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
|
export const EXTRA_AGENT_POOL: AgentNodeData[] = [
|
||||||
{
|
{
|
||||||
id: 'qa',
|
id: 'qa',
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ interface CatalogEntry {
|
|||||||
|
|
||||||
const AGENT_CATALOG: Record<string, CatalogEntry> = {
|
const AGENT_CATALOG: Record<string, CatalogEntry> = {
|
||||||
iris: { elapsed: '--', think: null, next: 'Standby' },
|
iris: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
'product-owner': { elapsed: '--', think: null, next: 'Standby' },
|
||||||
programmer: { elapsed: '--', think: null, next: 'Standby' },
|
programmer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
|
'programmer-fast': { elapsed: '--', think: null, next: 'Standby' },
|
||||||
developer: { elapsed: '--', think: null, next: 'Standby' },
|
developer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
architekt: { elapsed: '--', think: null, next: 'Standby' },
|
architekt: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
reviewer: { elapsed: '--', think: null, next: 'Standby' },
|
reviewer: { elapsed: '--', think: null, next: 'Standby' },
|
||||||
@@ -33,7 +35,9 @@ function resolveStatus(isActive: boolean, currentTask: string | null): AgentNode
|
|||||||
|
|
||||||
function resolveAvatar(id: string, name: string): string {
|
function resolveAvatar(id: string, name: string): string {
|
||||||
if (id === 'iris') return 'IR'
|
if (id === 'iris') return 'IR'
|
||||||
|
if (id === 'product-owner') return 'PO'
|
||||||
if (id === 'programmer' || id === 'developer') return '</>'
|
if (id === 'programmer' || id === 'developer') return '</>'
|
||||||
|
if (id === 'programmer-fast') return 'PF'
|
||||||
return name.slice(0, 2).toUpperCase()
|
return name.slice(0, 2).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -28,22 +28,22 @@ const routes = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{ path: '/memory', name: 'Memory', component: MemoryView },
|
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
|
||||||
{ path: '/docs', name: 'Docs', component: DocsView },
|
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
|
||||||
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
|
||||||
{ path: '/security', name: 'Security', component: SecurityView },
|
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
|
||||||
{ path: '/incidents', name: 'Incidents', component: IncidentsView },
|
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
|
||||||
{ path: '/calendar', name: 'Calendar', component: CalendarView },
|
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
|
||||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
||||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
|
||||||
{ path: '/tasks', name: 'Task Board', component: TaskBoardView },
|
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
|
||||||
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
|
||||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
|
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
|
||||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
||||||
{ path: '/notifications', name: 'Notifications', component: NotificationsView },
|
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
|
||||||
{ path: '/settings', name: 'Settings', component: SettingsView },
|
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export interface DashboardTaskDto {
|
|||||||
expectedFrom?: string | null
|
expectedFrom?: string | null
|
||||||
lastActivityMessage?: string | null
|
lastActivityMessage?: string | null
|
||||||
lastActivityAt?: string | null
|
lastActivityAt?: string | null
|
||||||
|
childTasks?: DashboardTaskDto[] | null
|
||||||
|
childTaskCount?: number
|
||||||
|
openChildTaskCount?: number
|
||||||
|
hasVisibleDelegation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardGroup {
|
export interface BoardGroup {
|
||||||
@@ -319,6 +323,8 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
assignedTo?: string
|
assignedTo?: string
|
||||||
expectedFrom?: string
|
expectedFrom?: string
|
||||||
parentTaskId?: string | null
|
parentTaskId?: string | null
|
||||||
|
startsInProgress?: boolean
|
||||||
|
initialState?: string | null
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/dashboard/tasks/agent', {
|
const res = await apiFetch('/api/dashboard/tasks/agent', {
|
||||||
@@ -331,6 +337,8 @@ export const useTaskStore = defineStore('tasks', {
|
|||||||
assignedTo: data.assignedTo ?? null,
|
assignedTo: data.assignedTo ?? null,
|
||||||
expectedFrom: data.expectedFrom ?? null,
|
expectedFrom: data.expectedFrom ?? null,
|
||||||
parentTaskId: data.parentTaskId ?? null,
|
parentTaskId: data.parentTaskId ?? null,
|
||||||
|
startsInProgress: data.startsInProgress ?? true,
|
||||||
|
initialState: data.initialState ?? null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTaskStore } from '../stores/tasks'
|
import { useTaskStore } from '../stores/tasks'
|
||||||
import { useLiveSyncStore } from '../stores/liveSync'
|
import { useLiveSyncStore } from '../stores/live-sync'
|
||||||
|
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||||
|
|
||||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||||
|
|
||||||
@@ -222,16 +223,7 @@ const liveModeClass = computed(() => `live-pill-${liveSyncStore.connectionHealth
|
|||||||
|
|
||||||
function expectedFromLabel(expected: string | null | undefined): string {
|
function expectedFromLabel(expected: string | null | undefined): string {
|
||||||
if (!expected) return ''
|
if (!expected) return ''
|
||||||
const map: Record<string, string> = {
|
return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
|
||||||
'bao': '👤 Bao',
|
|
||||||
'iris': '🤖 Iris',
|
|
||||||
'programmer': '🛠 Programmer',
|
|
||||||
'reviewer': '🔎 Reviewer',
|
|
||||||
'architekt': '🏛 Architekt',
|
|
||||||
'researcher': '🔬 Researcher',
|
|
||||||
'executor': '⚡ Executor',
|
|
||||||
}
|
|
||||||
return map[expected.toLowerCase()] ?? expected
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursSince(dateStr: string): number {
|
function hoursSince(dateStr: string): number {
|
||||||
@@ -827,13 +819,13 @@ onUnmounted(() => {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="task-assignee">Zugewiesen an</label>
|
<label for="task-assignee">Zugewiesen an</label>
|
||||||
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
|
<select id="task-assignee" v-model="formAssignedTo" class="field-input field-select">
|
||||||
<option value="bao">👤 Bao</option>
|
<option
|
||||||
<option value="iris">🤖 Iris</option>
|
v-for="option in TASK_AGENT_OPTIONS.filter(entry => entry.id)"
|
||||||
<option value="programmer">🛠 Programmer</option>
|
:key="option.id"
|
||||||
<option value="reviewer">🔎 Reviewer</option>
|
:value="option.id"
|
||||||
<option value="architekt">🏛 Architekt</option>
|
>
|
||||||
<option value="researcher">🔬 Researcher</option>
|
{{ option.label }}
|
||||||
<option value="executor">⚡ Executor</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,14 +943,9 @@ onUnmounted(() => {
|
|||||||
<label class="sidebar-field">
|
<label class="sidebar-field">
|
||||||
<span>Zuständig</span>
|
<span>Zuständig</span>
|
||||||
<select v-model="detailForm.assignedTo" class="field-input field-select slim">
|
<select v-model="detailForm.assignedTo" class="field-input field-select slim">
|
||||||
<option value="">Nicht zugewiesen</option>
|
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
|
||||||
<option value="bao">👤 Bao</option>
|
{{ option.label }}
|
||||||
<option value="iris">🤖 Iris</option>
|
</option>
|
||||||
<option value="programmer">🛠 Programmer</option>
|
|
||||||
<option value="reviewer">🔎 Reviewer</option>
|
|
||||||
<option value="architekt">🏛 Architekt</option>
|
|
||||||
<option value="researcher">🔬 Researcher</option>
|
|
||||||
<option value="executor">⚡ Executor</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="sidebar-field">
|
<label class="sidebar-field">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { apiFetch } from '../services/api'
|
import { apiFetch } from '../services/api'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||||
|
|
||||||
/* ── Types ──────────────────────────────────── */
|
/* ── Types ──────────────────────────────────── */
|
||||||
interface TaskDto {
|
interface TaskDto {
|
||||||
@@ -166,7 +167,9 @@ function childStatusSummary(taskId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
|
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
|
||||||
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
return taskLike.lastActivityMessage?.trim()
|
||||||
|
|| childStatusSummary(taskLike.id)
|
||||||
|
|| (taskLike.expectedFrom ? `Wartet auf ${TASK_AGENT_LABELS[taskLike.expectedFrom.toLowerCase()] ?? taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
|
||||||
}
|
}
|
||||||
|
|
||||||
function delegationSummary(taskLike: TaskDto): string | null {
|
function delegationSummary(taskLike: TaskDto): string | null {
|
||||||
@@ -451,7 +454,11 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<option value="Medium">Medium</option>
|
<option value="Medium">Medium</option>
|
||||||
<option value="Low">Low</option>
|
<option value="Low">Low</option>
|
||||||
</select>
|
</select>
|
||||||
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris, researcher…)" />
|
<select v-model="subtaskAssign" class="galaxy-input galaxy-select narrow">
|
||||||
|
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
|
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
|
||||||
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
|
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -565,14 +572,9 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<label class="sidebar-field">
|
<label class="sidebar-field">
|
||||||
<span>Zuständig</span>
|
<span>Zuständig</span>
|
||||||
<select v-model="form.assignedTo" class="galaxy-input galaxy-select">
|
<select v-model="form.assignedTo" class="galaxy-input galaxy-select">
|
||||||
<option value="">Nicht zugewiesen</option>
|
<option v-for="option in TASK_AGENT_OPTIONS" :key="option.id || 'unassigned'" :value="option.id">
|
||||||
<option value="bao">👤 Bao</option>
|
{{ option.label }}
|
||||||
<option value="iris">🤖 Iris</option>
|
</option>
|
||||||
<option value="programmer">🛠 Programmer</option>
|
|
||||||
<option value="reviewer">🔎 Reviewer</option>
|
|
||||||
<option value="architekt">🏛 Architekt</option>
|
|
||||||
<option value="researcher">🔬 Researcher</option>
|
|
||||||
<option value="executor">⚡ Executor</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="sidebar-field">
|
<label class="sidebar-field">
|
||||||
@@ -590,6 +592,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
|
||||||
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
|
||||||
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
|
||||||
|
<div v-if="task.expectedFrom"><dt>Erwartet von</dt><dd>{{ TASK_AGENT_LABELS[task.expectedFrom.toLowerCase()] ?? task.expectedFrom }}</dd></div>
|
||||||
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
|
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Nexus Deployment Script
|
|
||||||
# Auf dem VPS-HOST ausführen, nicht im Container!
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
||||||
NEXUS_DIR="$(dirname "$SCRIPT_DIR")"
|
|
||||||
|
|
||||||
echo "=== Nexus Deployment ==="
|
|
||||||
echo "Verzeichnis: $NEXUS_DIR"
|
|
||||||
|
|
||||||
cd "$NEXUS_DIR"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[1/4] Prüfe Konfiguration..."
|
|
||||||
docker compose config --quiet && echo " ✅ Konfiguration gültig"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[2/4] Starte Stack (mit Healthchecks)..."
|
|
||||||
docker compose up -d --wait
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[3/4] Status nach Deployment..."
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "[4/4] Verifikation..."
|
|
||||||
check_code() {
|
|
||||||
local path="$1"
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
|
|
||||||
}
|
|
||||||
|
|
||||||
HEALTH_CODE=$(check_code /health)
|
|
||||||
DASHBOARD_CODE=$(check_code /dashboard)
|
|
||||||
OPS_CODE=$(check_code /api/v1/operations/snapshot)
|
|
||||||
|
|
||||||
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
|
|
||||||
WEB_CID="$(docker compose ps -q web || true)"
|
|
||||||
if [ -n "$WEB_CID" ]; then
|
|
||||||
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
|
|
||||||
if [ "$WEB_STATE" = "created" ]; then
|
|
||||||
echo " ℹ️ API healthy, aber web noch im Status 'created' — starte web nach"
|
|
||||||
docker compose up -d web
|
|
||||||
sleep 2
|
|
||||||
DASHBOARD_CODE=$(check_code /dashboard)
|
|
||||||
OPS_CODE=$(check_code /api/v1/operations/snapshot)
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " /health -> ${HEALTH_CODE}"
|
|
||||||
echo " /dashboard -> ${DASHBOARD_CODE}"
|
|
||||||
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
|
|
||||||
|
|
||||||
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
|
|
||||||
echo " ❌ Verifikation fehlgeschlagen"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " ✅ Health-Check bestanden"
|
|
||||||
echo " ✅ Dashboard erreichbar"
|
|
||||||
echo " ✅ Operations API fordert Auth an"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Deployment abgeschlossen ==="
|
|
||||||
echo "Dashboard: https://nexus.noveria.net/dashboard"
|
|
||||||
echo "Health-API: https://nexus.noveria.net/health"
|
|
||||||
echo ""
|
|
||||||
echo "Login-Informationen: docker compose logs api | grep 'Initial owner'"
|
|
||||||
echo "Status: docker compose ps"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODEL="${OLLAMA_MODEL:-qwen3:4b}"
|
|
||||||
BIND_ADDRESS="${OLLAMA_BIND_ADDRESS:-172.18.0.1:11434}"
|
|
||||||
BACKUP_DIR="/root/security-backups/ollama-$(date -u +%Y%m%dT%H%M%SZ)"
|
|
||||||
|
|
||||||
if [[ "${EUID}" -ne 0 ]]; then
|
|
||||||
echo "Run this script as root on the Ubuntu host." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "${BACKUP_DIR}"
|
|
||||||
if systemctl cat ollama.service >/dev/null 2>&1; then
|
|
||||||
systemctl cat ollama.service > "${BACKUP_DIR}/ollama.service.before.txt"
|
|
||||||
fi
|
|
||||||
if [[ -d /etc/systemd/system/ollama.service.d ]]; then
|
|
||||||
cp -a /etc/systemd/system/ollama.service.d "${BACKUP_DIR}/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v ollama >/dev/null 2>&1; then
|
|
||||||
curl -fsSL https://ollama.com/install.sh -o /tmp/ollama-install.sh
|
|
||||||
sh /tmp/ollama-install.sh
|
|
||||||
fi
|
|
||||||
|
|
||||||
install -d -m 755 /etc/systemd/system/ollama.service.d
|
|
||||||
cat > /etc/systemd/system/ollama.service.d/10-openclaw.conf <<OVERRIDE
|
|
||||||
[Service]
|
|
||||||
Environment="OLLAMA_HOST=${BIND_ADDRESS}"
|
|
||||||
Environment="OLLAMA_KEEP_ALIVE=15m"
|
|
||||||
OVERRIDE
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
systemctl enable --now ollama
|
|
||||||
systemctl restart ollama
|
|
||||||
|
|
||||||
max_attempts=30
|
|
||||||
attempt=1
|
|
||||||
while [[ "${attempt}" -le "${max_attempts}" ]]; do
|
|
||||||
if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
|
|
||||||
systemctl status ollama --no-pager
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
attempt=$((attempt + 1))
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama pull "${MODEL}"
|
|
||||||
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama show "${MODEL}" >/dev/null
|
|
||||||
curl -fsS "http://${BIND_ADDRESS}/api/tags"
|
|
||||||
echo
|
|
||||||
echo "Ollama ${MODEL} is ready on ${BIND_ADDRESS}. Backup: ${BACKUP_DIR}"
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# ==============================================================================
|
|
||||||
# Noveria.net Landingpage — Nginx Server Block
|
|
||||||
# ==============================================================================
|
|
||||||
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
|
|
||||||
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
|
|
||||||
#
|
|
||||||
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
|
|
||||||
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
|
|
||||||
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
|
|
||||||
# ==============================================================================
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name noveria.net www.noveria.net;
|
|
||||||
|
|
||||||
# SSL (gleiche Zertifikate wie nexus)
|
|
||||||
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
|
|
||||||
include /etc/nginx/snippets/ssl-params.conf;
|
|
||||||
|
|
||||||
# Security Header
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
|
||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:18881;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# HTTP → HTTPS redirect
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name noveria.net www.noveria.net;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
|
||||||
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
|
|
||||||
# ==============================================================================
|
|
||||||
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
|
|
||||||
# grep -rn "noveria.net" /etc/nginx/sites-available/
|
|
||||||
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
|
|
||||||
#
|
|
||||||
# 2. Config testen nach Änderung
|
|
||||||
# nginx -t
|
|
||||||
#
|
|
||||||
# 3. Nginx neuladen
|
|
||||||
# systemctl reload nginx
|
|
||||||
# ==============================================================================
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# /etc/nginx/sites-available/nexus.noveria.net
|
|
||||||
# Symlink: ln -s /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name nexus.noveria.net;
|
|
||||||
|
|
||||||
# Let's Encrypt challenge
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name nexus.noveria.net;
|
|
||||||
|
|
||||||
# SSL wird per certbot automatisch befüllt
|
|
||||||
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
|
|
||||||
|
|
||||||
# Security-Header
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
|
|
||||||
client_max_body_size 16m;
|
|
||||||
|
|
||||||
# Bridge-Endpunkte: Gateway-zu-Backend-Agent-Pfad
|
|
||||||
# X-Agent-Id wird durchgereicht für Agent-Identität
|
|
||||||
location /api/bridge/ {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Agent-Id $http_x_agent_id;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 120s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Dashboard SSE stream: single dedicated non-buffered block.
|
|
||||||
location = /api/dashboard/live {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Connection "";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_cache off;
|
|
||||||
proxy_read_timeout 1h;
|
|
||||||
proxy_send_timeout 1h;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
|
||||||
add_header X-Accel-Buffering no always;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API-Direktzugriff falls nötig
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# HTTPS-Setup für nexus.noveria.net
|
|
||||||
# Auf dem VPS-HOST ausführen!
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== HTTPS Setup für nexus.noveria.net ==="
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
|
|
||||||
|
|
||||||
# 1. Zuerst nur HTTP-Config ausrollen (keine SSL-Referenz!)
|
|
||||||
echo "[1/5] Installiere HTTP-only Nginx-Config..."
|
|
||||||
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXEOF'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name nexus.noveria.net;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINXEOF
|
|
||||||
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
echo " ✅ HTTP-Config aktiv"
|
|
||||||
|
|
||||||
# 2. Firewall
|
|
||||||
echo "[2/5] Firewall..."
|
|
||||||
if command -v ufw &>/dev/null; then
|
|
||||||
sudo ufw allow 80/tcp 2>/dev/null || true
|
|
||||||
sudo ufw allow 443/tcp 2>/dev/null || true
|
|
||||||
echo " ✅ ufw: 80+443 offen"
|
|
||||||
else
|
|
||||||
echo " ⏭ ufw nicht installiert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. HTTP-Test
|
|
||||||
echo "[3/5] Teste HTTP..."
|
|
||||||
sleep 1
|
|
||||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://nexus.noveria.net)
|
|
||||||
echo " HTTP-Status: $STATUS"
|
|
||||||
|
|
||||||
# 4. Zertifikat holen
|
|
||||||
echo "[4/5] Fordere Let's-Encrypt-Zertifikat an..."
|
|
||||||
sudo certbot certonly --webroot -w /var/www/html -d nexus.noveria.net --non-interactive --agree-tos --email vmbao62@hotmail.de 2>&1 || {
|
|
||||||
echo " ⚠️ certbot fehlgeschlagen – manuell nachholen:"
|
|
||||||
echo " sudo certbot --nginx -d nexus.noveria.net"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
echo " ✅ Zertifikat erhalten"
|
|
||||||
|
|
||||||
# 5. HTTPS-Config ausrollen
|
|
||||||
echo "[5/5] Aktiviere HTTPS-Config..."
|
|
||||||
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXSSL'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name nexus.noveria.net;
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name nexus.noveria.net;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
|
|
||||||
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
|
||||||
add_header X-Content-Type-Options nosniff;
|
|
||||||
add_header X-Frame-Options DENY;
|
|
||||||
|
|
||||||
client_max_body_size 16m;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:18880;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NGINXSSL
|
|
||||||
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
echo " ✅ HTTPS aktiv"
|
|
||||||
|
|
||||||
# Test
|
|
||||||
echo ""
|
|
||||||
sleep 2
|
|
||||||
curl -s -o /dev/null -w "HTTPS-Status: %{http_code}\n" https://nexus.noveria.net
|
|
||||||
echo ""
|
|
||||||
echo "=== Fertig ==="
|
|
||||||
echo "Nexus: https://nexus.noveria.net"
|
|
||||||
+3
-3
@@ -3,10 +3,10 @@
|
|||||||
> Letzte Aktualisierung: 2026-06-21
|
> Letzte Aktualisierung: 2026-06-21
|
||||||
|
|
||||||
- 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).**
|
- 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).**
|
||||||
- Root Cause: Dual-Source-Architektur (Gitea-Secret vs Host-.env) verursachte Passwort-Drift nach DB-Reseed.
|
- Root Cause: Passwort-Injektion über Deploy-Runtime erzeugte einen unnötigen zweiten Pfad neben der DB und verursachte Drift nach DB-Reseed.
|
||||||
- Code-Fix: `SeedAudit`-Entity + Migration (`20260621081500_AddSeedAudit`) eingebaut. `EnsureDatabaseAsync` prueft jetzt `SeedAudit` VOR dem Seeden. Key `owner_created` blockiert erneutes Seeden permanent.
|
- Code-Fix: `SeedAudit`-Entity + Migration (`20260621081500_AddSeedAudit`) eingebaut. `EnsureDatabaseAsync` prueft jetzt `SeedAudit` VOR dem Seeden. Key `owner_created` blockiert erneutes Seeden permanent.
|
||||||
- Workflow-Fix: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` aus dem Host-`.env` (Single Source of Truth), nicht mehr aus Gitea-Secret.
|
- Workflow-Fix: Deploy- und Rollback-Workflows injizieren kein `OWNER_PASSWORD` mehr.
|
||||||
- `compose.yaml`: Kommentar hinzugefuegt dass OWNER_PASSWORD nur beim initialen Seed verwendet wird.
|
- `compose.yaml`: `Owner__Password` entfernt; Bootstrap-Konfig auf `BOOTSTRAP_OWNER_EMAIL` reduziert; Initialpasswort wird nur noch einmalig beim ersten Seed generiert.
|
||||||
- Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`.
|
- Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`.
|
||||||
- Git: Commit `f95463e`, manuell ausgerollt.
|
- Git: Commit `f95463e`, manuell ausgerollt.
|
||||||
- Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`.
|
- Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`.
|
||||||
|
|||||||
+23
-30
@@ -7,10 +7,9 @@
|
|||||||
## CD-Philosophie (v3)
|
## CD-Philosophie (v3)
|
||||||
|
|
||||||
- **CI läuft automatisch** bei jedem Push → darf nie brechen
|
- **CI läuft automatisch** bei jedem Push → darf nie brechen
|
||||||
- **CD auto + manuell**: Automaticher Deploy nach CI-Success auf main (patch default), manueller Deploy mit voller Kontrolle via `workflow_dispatch`
|
- **CD auto + manuell**: Automatischer Deploy nach CI-Success auf main; manueller Deploy via `workflow_dispatch`
|
||||||
- **Loop-Schutz**: Version-Bump-Commits enthalten `[skip ci]` — kein Re-Trigger der CI, kein Infinite-Loop
|
- **Loop-Schutz**: Commits mit `[skip ci]` werden von Auto-Deploys ignoriert
|
||||||
- **Main-Deploys** duerfen VERSION bumpen und einen Git-Tag setzen
|
- Deploy liest und validiert `VERSION`, mutiert aber weder Git noch Tags
|
||||||
- **Nicht-Main-Deploys** (anderer `git_ref`) deployen read-only und mutieren Git nicht
|
|
||||||
- **Rollback** als eigener Workflow, manuell triggerbar
|
- **Rollback** als eigener Workflow, manuell triggerbar
|
||||||
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)
|
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@
|
|||||||
|
|
||||||
**Trigger**:
|
**Trigger**:
|
||||||
- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`)
|
- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`)
|
||||||
→ Default-Parameter: patch bump, all services, main ref
|
→ Deployt `main` mit dem im Repo gesetzten `VERSION`-Wert
|
||||||
- **Manuell**: Via Gitea Actions → `workflow_dispatch`
|
- **Manuell**: Via Gitea Actions → `workflow_dispatch`
|
||||||
|
|
||||||
**Loop-Schutz**:
|
**Loop-Schutz**:
|
||||||
@@ -28,26 +27,19 @@
|
|||||||
- Auto-Deploy prüft zusätzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]`
|
- Auto-Deploy prüft zusätzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]`
|
||||||
- Beide Mechanismen zusammen verhindern Endlosschleife: CI → Deploy → Bump → CI …
|
- Beide Mechanismen zusammen verhindern Endlosschleife: CI → Deploy → Bump → CI …
|
||||||
|
|
||||||
**Inputs** (nur bei `workflow_dispatch`):
|
**Inputs**: keine. Der manuelle Deploy nutzt denselben Main-Deploy-Pfad wie der Auto-Deploy.
|
||||||
| Input | Typ | Default | Beschreibung |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `version_bump` | choice (patch/minor/major) | patch | Version-Bump-Typ |
|
|
||||||
| `service` | string | (all) | Einzelner Service oder alle |
|
|
||||||
| `no_cache` | boolean | false | Docker-Build-Cache deaktivieren |
|
|
||||||
| `git_ref` | string | main | Branch/Tag/Commit zum Deployen |
|
|
||||||
|
|
||||||
**Ablauf**:
|
**Ablauf**:
|
||||||
1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet
|
1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet
|
||||||
2. Checkout des gewählten Git-Refs
|
2. Checkout von `main`
|
||||||
3. Wenn `git_ref = main`: Version-Bump + Git-Tag + Push
|
3. `VERSION` lesen und SemVer validieren
|
||||||
4. Wenn `git_ref != main`: VERSION nur lesen, kein Push, kein Tag
|
4. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
|
||||||
5. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
|
5. Code-Sync zum Host-Deploy-Pfad
|
||||||
6. Code-Sync zum Host-Deploy-Pfad
|
6. `docker compose build && up -d --force-recreate`
|
||||||
7. `docker compose build && up -d --wait --force-recreate`
|
7. `.env`-Tempfile wird mit `shred` gelöscht
|
||||||
8. `.env`-Tempfile wird mit `shred` gelöscht
|
8. Health-Check (Backoff, 6 Versuche)
|
||||||
9. Health-Check (exponentieller Backoff, 6 Versuche)
|
9. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
|
||||||
10. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
|
10. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL
|
||||||
11. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL
|
|
||||||
|
|
||||||
### Backup (`.gitea/workflows/backup.yaml`)
|
### Backup (`.gitea/workflows/backup.yaml`)
|
||||||
|
|
||||||
@@ -87,6 +79,8 @@ schedule:
|
|||||||
|
|
||||||
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`
|
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`
|
||||||
|
|
||||||
|
**Concurrency**: Rollback nutzt dieselbe `deploy-production`-Gruppe wie Deploy, aber mit `cancel-in-progress: true`. Dadurch gewinnt Rollback gegenüber laufenden oder wartenden Deploys und verhindert, dass ein Auto-Deploy direkt nach einem Rollback den Rollback wieder überschreibt.
|
||||||
|
|
||||||
**Inputs**:
|
**Inputs**:
|
||||||
| Input | Typ | Beschreibung |
|
| Input | Typ | Beschreibung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -109,12 +103,12 @@ schedule:
|
|||||||
|
|
||||||
### Owner Password Persistence (2026-06-21, permanent fix)
|
### Owner Password Persistence (2026-06-21, permanent fix)
|
||||||
|
|
||||||
**Root Cause**: Dual-Source-Architektur fuer das Owner-Passwort (Gitea-Secret `ENV_OWNER_PASSWORD` vs Host `.env` `OWNER_PASSWORD`) verursachte Drift wenn die DB jemals neu geseedet wurde.
|
**Root Cause**: Die fruehere Passwort-Injektion ueber Deploy-Runtime schuf einen unnötigen zweiten Pfad neben der DB und machte Passwort-Drift/Re-Seeding-Folgen möglich.
|
||||||
|
|
||||||
**Fix (3 Schichten)**:
|
**Fix (3 Schichten)**:
|
||||||
1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird.
|
1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird.
|
||||||
2. **Single Source of Truth**: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` jetzt aus dem persistenten Host-`.env` (via `grep` auf dem Deploy-Pfad), NICHT mehr aus separatem Gitea-Secret. Das Host-`.env` ist die kanonische Quelle.
|
2. **Single Source of Truth**: Deploy- und Rollback-Workflows injizieren gar kein `OWNER_PASSWORD` mehr. Nach dem ersten Seed ist ausschließlich die DB kanonisch.
|
||||||
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`).
|
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`). Bootstrap läuft nur noch über `BOOTSTRAP_OWNER_EMAIL`.
|
||||||
|
|
||||||
**Verifikation (2026-06-21)**:
|
**Verifikation (2026-06-21)**:
|
||||||
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
|
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
|
||||||
@@ -122,7 +116,7 @@ schedule:
|
|||||||
- Login funktioniert nach `docker compose restart`
|
- Login funktioniert nach `docker compose restart`
|
||||||
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup
|
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup
|
||||||
|
|
||||||
**Regel gegen Wiederholung**: `OWNER_PASSWORD` nur im Host-`.env` aendern. Das Host-`.env` wird von CI-Deploys gelesen. Niemals ein separates Gitea-Secret fuer OWNER_PASSWORD anlegen.
|
**Regel gegen Wiederholung**: Kein `OWNER_PASSWORD` mehr in Deploy-Runtime, Host-`.env` oder Secrets pflegen. Passwort-Änderungen laufen nur noch über App/DB-Pfade.
|
||||||
|
|
||||||
### Secrets in Gitea
|
### Secrets in Gitea
|
||||||
|
|
||||||
@@ -134,8 +128,7 @@ Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfi
|
|||||||
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
|
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
|
||||||
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
|
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
|
||||||
|
|
||||||
> **Hinweis**: `ENV_OWNER_PASSWORD` wurde aus den Gitea-Secrets ENTFERNT (2026-06-21).
|
> **Hinweis**: `ENV_OWNER_PASSWORD` bleibt entfernt. `OWNER_PASSWORD` wird auch nicht mehr aus Host-`.env` eingelesen.
|
||||||
> OWNER_PASSWORD kommt ausschliesslich aus dem Host-`.env` auf dem Deploy-Pfad.
|
|
||||||
|
|
||||||
### Safe Secret Handling (v3)
|
### Safe Secret Handling (v3)
|
||||||
|
|
||||||
@@ -191,7 +184,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
|
|||||||
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
|
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
|
||||||
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
|
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
|
||||||
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
|
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
|
||||||
- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13)
|
- [x] Deploy liest und validiert `VERSION`, mutiert aber keine Git-Tags oder Version-Dateien (2026-06-23)
|
||||||
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
|
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
|
||||||
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
|
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
|
||||||
- [x] Live-Recheck nach Deploy-Stoerung: `/health`, SPA-Root und `GET /api/dashboard/tasks` wieder 200; Bao-Folgetask zur Agent-Progress-Visibility erstellt (2026-06-20)
|
- [x] Live-Recheck nach Deploy-Stoerung: `/health`, SPA-Root und `GET /api/dashboard/tasks` wieder 200; Bao-Folgetask zur Agent-Progress-Visibility erstellt (2026-06-20)
|
||||||
@@ -223,7 +216,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
|
|||||||
2. `curl http://127.0.0.1:18880/health`
|
2. `curl http://127.0.0.1:18880/health`
|
||||||
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
|
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
|
||||||
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
|
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
|
||||||
- Der manuelle Helper [`ops/deploy.sh`](/home/node/.openclaw/workspace/nexus/ops/deploy.sh) verifiziert deshalb jetzt nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
|
- Der CD-Pfad verifiziert deshalb nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
|
||||||
|
|
||||||
## Offene Arbeit
|
## Offene Arbeit
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user