Compare commits

..

5 Commits

Author SHA1 Message Date
devops 95495a8332 feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:23:49 +02:00
devops 68b428e411 fix: prioritize rollback over queued deploys 2026-06-24 01:22:55 +02:00
devops 1214cf9a4d chore: simplify nexus cicd pipeline 2026-06-24 01:22:55 +02:00
devops 195c497c88 fix: route standalone views via route metadata 2026-06-24 01:22:55 +02:00
devops a2272c5df6 fix: harden owner bootstrap and auth persistence 2026-06-24 01:22:55 +02:00
44 changed files with 1613 additions and 903 deletions
-12
View File
@@ -1,12 +0,0 @@
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=replace-with-a-strong-database-password
JWT_KEY=replace-with-at-least-32-random-bytes
OWNER_EMAIL=owner@example.com
OWNER_PASSWORD=replace-with-at-least-14-characters
OWNER_DISPLAY_NAME=Owner
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_PASSWORD=
OLLAMA_BASE_URL=http://host.docker.internal:11434
NVIDIA_API_KEY=
+2 -4
View File
@@ -15,10 +15,8 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
JWT_ISSUER=nexus JWT_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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
-199
View File
@@ -1,199 +0,0 @@
name: Deploy Now
run-name: 🚀 Deploy Now by @${{ gitea.actor }}
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
- name: Resolve Version
id: version
run: |
set -euo pipefail
if [ ! -f VERSION ]; then
echo "ERROR: VERSION file not found"
exit 1
fi
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: Invalid semver in VERSION: $VERSION"
exit 1
fi
GIT_REF=$(git rev-parse --short HEAD)
echo "Deploy version: v${VERSION} git:${GIT_REF}"
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
- name: Prepare .env
run: |
set -euo pipefail
HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true)
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}"
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}"
chmod 600 "${ENV_TMPFILE}"
echo "OK .env written to ${ENV_TMPFILE}"
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "cd /src && find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -r {} /dest/ \; && DEST_OWNER=\$(stat -c '%u:%g' /dest) && chown -R \"\$DEST_OWNER\" /dest"
echo "OK synced to ${DEPLOY_PATH}"
- name: Build and Deploy
run: |
set -euo pipefail
SCRIPT=/tmp/nexus-deploy-script.sh
printf '#!/bin/sh\n' > "$SCRIPT"
printf 'set -e\n' >> "$SCRIPT"
printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT"
printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT"
printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT"
printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT"
printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT"
printf 'else\n' >> "$SCRIPT"
printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT"
printf 'fi\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Waiting for services to become healthy (up to 180s)..."\n' >> "$SCRIPT"
printf 'for i in $(seq 1 36); do\n' >> "$SCRIPT"
printf ' STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)\n' >> "$SCRIPT"
printf ' if echo "$STATUS" | grep -q unhealthy; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Unhealthy containers - failing fast"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env logs --tail=30\n' >> "$SCRIPT"
printf ' exit 1\n' >> "$SCRIPT"
printf ' elif echo "$STATUS" | grep -q starting; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Still starting..."\n' >> "$SCRIPT"
printf ' sleep 5\n' >> "$SCRIPT"
printf ' else\n' >> "$SCRIPT"
printf ' echo "All containers healthy"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' exit 0\n' >> "$SCRIPT"
printf ' fi\n' >> "$SCRIPT"
printf 'done\n' >> "$SCRIPT"
printf 'echo "Timeout waiting for services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env logs --tail=20\n' >> "$SCRIPT"
printf 'exit 1\n' >> "$SCRIPT"
chmod +x "$SCRIPT"
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${SCRIPT}:/deploy.sh:ro" \
-w /workspace/nexus \
-i \
docker:cli \
sh /deploy.sh < "${ENV_TMPFILE}"
rm -f "$SCRIPT"
echo "OK deployed"
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "OK cleaned"
fi
- name: Health Check
run: |
echo "Health check..."
RETRY=0; MAX=6; WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "OK Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "ERROR Health check failed after $MAX attempts"
exit 1
- name: Smoke Test
run: |
PASS=0; FAIL=0; BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " OK"
PASS=$((PASS + 1))
else
echo " FAIL (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "ERROR Smoke test failed"
exit 1
fi
echo "OK Smoke test passed"
- name: Summary
if: always()
run: |
echo "========================================"
echo " Deploy Summary"
echo "========================================"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: main"
echo " Service: all"
echo " Trigger: Manual"
echo " Status: ${{ job.status }}"
echo "========================================"
+15 -39
View File
@@ -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:-}"
+7 -16
View File
@@ -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=
-1
View File
@@ -6,7 +6,6 @@
# Environment # Environment
.env .env
!.env.example
!.env.template !.env.template
# IDE # IDE
+15 -17
View File
@@ -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`)
+108 -6
View File
@@ -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" }
} }
] ]
} }
+397
View File
@@ -0,0 +1,397 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Tests for AuthService login, change-password, admin-reset, and related flows.
/// These are unit-level tests using an in-memory EF Core database so no
/// external PostgreSQL instance is needed.
/// </summary>
public sealed class AuthServiceTests
{
// ── Fixture helpers ─────────────────────────────────────────────────
/// <summary>
/// Creates a test fixture with an in-memory database, a UserRepository,
/// and an AuthService backed by an in-memory configuration.
/// </summary>
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
var repo = new UserRepository(db);
// In-memory config with minimum required JWT settings
var config = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
var auth = new AuthService(repo, config, logger);
return (db, repo, auth);
}
private static LoginRequest Login(string email, string password)
=> new() { Email = email, Password = password };
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
{
var user = new NexusUser
{
Email = email,
NormalizedEmail = AuthService.NormalizeEmail(email),
DisplayName = email.Split('@')[0],
PasswordHash = PasswordSecurity.Hash(password),
Role = role
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
// ══════════════════════════════════════════════════════════════════
// Password Security Unit Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public void Hash_And_Verify_RoundTrip_Succeeds()
{
const string password = "MyTestPassword123!";
var hash = PasswordSecurity.Hash(password);
Assert.NotNull(hash);
Assert.StartsWith("v1.", hash);
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
Assert.True(ok);
Assert.False(needsUpgrade);
}
[Fact]
public void Verify_WrongPassword_Fails()
{
var hash = PasswordSecurity.Hash("CorrectPassword123!");
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalse()
{
Assert.False(PasswordSecurity.Verify("password", "", out _));
}
[Fact]
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
{
const string password = "OldFormatPassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
Assert.True(ok);
Assert.True(needsUpgrade);
}
// ══════════════════════════════════════════════════════════════════
// Login Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task Login_WithValidCredentials_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string password = "ValidPassword123!";
await SeedUserAsync(db, "test@example.com", password);
var session = await auth.LoginAsync(Login("test@example.com", password));
Assert.NotNull(session);
Assert.Equal("test", session.User.DisplayName);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
}
[Fact]
public async Task Login_WithNonexistentEmail_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
}
[Fact]
public async Task Login_UpdatesLastLoginAt()
{
var (db, repo, auth) = CreateFixture();
const string password = "TestPassword123!";
var user = await SeedUserAsync(db, "test@example.com", password);
var beforeLogin = user.LastLoginAt;
await Task.Delay(10);
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated!.LastLoginAt);
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
}
/// <summary>
/// Validates that LoginAsync persists a password hash upgrade AND login
/// timestamps even when there are NO expired refresh tokens. Previously
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
/// but that only happens when oldTokens.Count > 0.
/// </summary>
[Fact]
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
{
var (db, repo, auth) = CreateFixture();
const string password = "LegacyUpgradePassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var user = new NexusUser
{
Email = "legacy@example.com",
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
DisplayName = "Legacy",
PasswordHash = legacyHash,
Role = "user"
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Login triggers hash upgrade
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.StartsWith("v1.", updated.PasswordHash);
Assert.NotEqual(legacyHash, updated.PasswordHash);
// Second login with the upgraded hash should also work
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
}
[Fact]
public async Task Login_WithExistingHash_DoesNotChangeHash()
{
var (db, repo, auth) = CreateFixture();
const string password = "StablePassword123!";
var user = await SeedUserAsync(db, "stable@example.com", password);
var originalHash = user.PasswordHash;
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(originalHash, updated.PasswordHash);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string oldPw = "OldPassword123!";
const string newPw = "NewPassword456!";
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = oldPw,
NewPassword = newPw
});
Assert.True(result);
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
}
[Fact]
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = "WrongPassword456!",
NewPassword = "NewPassword789!"
}));
}
// ══════════════════════════════════════════════════════════════════
// Admin Reset Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task AdminResetPassword_WithValidToken_Succeeds()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
const string oldPw = "OldPassword123!";
const string newPw = "NewAdminPassword456!";
await SeedUserAsync(db, "adminreset@example.com", oldPw);
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
}
[Fact]
public async Task AdminResetPassword_WithInvalidToken_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
}
[Fact]
public async Task AdminResetPassword_NonexistentUser_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
}
[Fact]
public async Task AdminResetPassword_ShortPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
}
// ══════════════════════════════════════════════════════════════════
// Profile Update Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateProfile_ChangesDisplayName()
{
var (db, repo, auth) = CreateFixture();
const string password = "Password123!";
var user = await SeedUserAsync(db, "profile@example.com", password);
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
{
DisplayName = "New Name"
});
Assert.NotNull(updated);
Assert.Equal("New Name", updated.DisplayName);
}
// ══════════════════════════════════════════════════════════════════
// NormalizeEmail
// ══════════════════════════════════════════════════════════════════
[Fact]
public void NormalizeEmail_TrimsAndUppercases()
{
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
}
}
/// <summary>
/// Minimal in-memory IConfiguration implementation for unit tests.
/// Reads from a case-insensitive dictionary.
/// </summary>
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly Dictionary<string, string?> _data;
private readonly Dictionary<string, MemoryConfigSection> _sections;
public MemoryConfig(Dictionary<string, string?> data)
{
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
}
public string? this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : null;
set => _data[key] = value ?? string.Empty;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (!_sections.TryGetValue(key, out var section))
{
section = new MemoryConfigSection(key, this);
_sections[key] = section;
}
return section;
}
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
{
public string Key => path.Split(':').Last();
public string Path => path;
public string? Value { get => root[path]; set => root[path] = value; }
public string? this[string key]
{
get => root[$"{path}:{key}"];
set => root[$"{path}:{key}"] = value;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
=> root.GetSection($"{path}:{key}");
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
internal sealed class NeverToken : IChangeToken
{
public static readonly NeverToken Instance = new();
public bool HasChanged => false;
public bool ActiveChangeCallbacks => false;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
}
internal sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
+1
View File
@@ -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" />
+548
View File
@@ -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() => [];
}
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
*.user
*.suo
.vs/
.vscode/
.git/
.gitignore
.env
*.log
+34 -5
View File
@@ -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;
}
} }
+12 -5
View File
@@ -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);
+17 -22
View File
@@ -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,46 +34,49 @@ 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();
if (!hasUsers) // ── Double-check SeedAudit after the migration — if another pod wrote it
// while we were reading, bail out early. ──
alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
// ── Use a strategy-based transaction so the user + audit row are
// persisted atomically. If the DB crashes after SaveChanges the
// entire transaction is rolled back, preventing partial-seed states.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{ {
if (string.IsNullOrWhiteSpace(ownerEmail)) await using var tx = await db.Database.BeginTransactionAsync();
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) if (!hasUsers)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{ {
Email = ownerEmail, if (string.IsNullOrWhiteSpace(ownerEmail))
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail), throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword), var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
Role = "owner" var initialPassword = PasswordHelper.GenerateTemporaryPassword();
});
await db.SaveChangesAsync(); db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); 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();
});
} }
} }
+3 -1
View File
@@ -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(
+44
View File
@@ -0,0 +1,44 @@
namespace Nexus.Api.Services;
public static class AgentIdentityCatalog
{
public static readonly string[] DefaultConfiguredAgentIds =
[
"main",
"iris",
"product-owner",
"programmer",
"programmer-fast",
"reviewer",
"architekt",
"researcher",
"executor"
];
private static readonly string[] WorkflowActorIds =
[
"bao",
"nexus-system"
];
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
foreach (var configuredAgentId in configuredAgentIds)
{
if (!string.IsNullOrWhiteSpace(configuredAgentId))
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
}
return ids;
}
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+84 -16
View File
@@ -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; }
} }
} }
+5
View File
@@ -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);
} }
+1
View File
@@ -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>
+2 -1
View File
@@ -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);
}
}
+34 -18
View File
@@ -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,
+65 -32
View File
@@ -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); {
return new TaskOperationResult(TaskOperationOutcome.Success, task); var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(
task,
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
actor: "nexus-system",
activityType: "delegation",
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
ct: ct);
} }
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default) 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
View File
@@ -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:
+5 -2
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.pnpm-store/
.pnpm-home/
.corepack-home/
.git/
.gitignore
.env
*.log
+5 -1
View File
@@ -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()
+19
View File
@@ -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',
+4
View File
@@ -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
View File
@@ -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' },
] ]
+8
View File
@@ -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}`)
+13 -26
View File
@@ -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">
+13 -10
View File
@@ -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>
-70
View File
@@ -1,70 +0,0 @@
#!/bin/bash
# Nexus Deployment Script
# Auf dem VPS-HOST ausführen, nicht im Container!
set -e
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
NEXUS_DIR="$(dirname "$SCRIPT_DIR")"
echo "=== Nexus Deployment ==="
echo "Verzeichnis: $NEXUS_DIR"
cd "$NEXUS_DIR"
echo ""
echo "[1/4] Prüfe Konfiguration..."
docker compose config --quiet && echo " ✅ Konfiguration gültig"
echo ""
echo "[2/4] Starte Stack (mit Healthchecks)..."
docker compose up -d --wait
echo ""
echo "[3/4] Status nach Deployment..."
docker compose ps
echo ""
echo "[4/4] Verifikation..."
check_code() {
local path="$1"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
}
HEALTH_CODE=$(check_code /health)
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
WEB_CID="$(docker compose ps -q web || true)"
if [ -n "$WEB_CID" ]; then
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
if [ "$WEB_STATE" = "created" ]; then
echo " ️ API healthy, aber web noch im Status 'created' — starte web nach"
docker compose up -d web
sleep 2
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
fi
fi
fi
echo " /health -> ${HEALTH_CODE}"
echo " /dashboard -> ${DASHBOARD_CODE}"
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
echo " ❌ Verifikation fehlgeschlagen"
exit 1
fi
echo " ✅ Health-Check bestanden"
echo " ✅ Dashboard erreichbar"
echo " ✅ Operations API fordert Auth an"
echo ""
echo "=== Deployment abgeschlossen ==="
echo "Dashboard: https://nexus.noveria.net/dashboard"
echo "Health-API: https://nexus.noveria.net/health"
echo ""
echo "Login-Informationen: docker compose logs api | grep 'Initial owner'"
echo "Status: docker compose ps"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL="${OLLAMA_MODEL:-qwen3:4b}"
BIND_ADDRESS="${OLLAMA_BIND_ADDRESS:-172.18.0.1:11434}"
BACKUP_DIR="/root/security-backups/ollama-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ "${EUID}" -ne 0 ]]; then
echo "Run this script as root on the Ubuntu host." >&2
exit 1
fi
mkdir -p "${BACKUP_DIR}"
if systemctl cat ollama.service >/dev/null 2>&1; then
systemctl cat ollama.service > "${BACKUP_DIR}/ollama.service.before.txt"
fi
if [[ -d /etc/systemd/system/ollama.service.d ]]; then
cp -a /etc/systemd/system/ollama.service.d "${BACKUP_DIR}/"
fi
if ! command -v ollama >/dev/null 2>&1; then
curl -fsSL https://ollama.com/install.sh -o /tmp/ollama-install.sh
sh /tmp/ollama-install.sh
fi
install -d -m 755 /etc/systemd/system/ollama.service.d
cat > /etc/systemd/system/ollama.service.d/10-openclaw.conf <<OVERRIDE
[Service]
Environment="OLLAMA_HOST=${BIND_ADDRESS}"
Environment="OLLAMA_KEEP_ALIVE=15m"
OVERRIDE
systemctl daemon-reload
systemctl enable --now ollama
systemctl restart ollama
max_attempts=30
attempt=1
while [[ "${attempt}" -le "${max_attempts}" ]]; do
if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
break
fi
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
systemctl status ollama --no-pager
exit 1
fi
attempt=$((attempt + 1))
sleep 2
done
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama pull "${MODEL}"
OLLAMA_HOST="http://${BIND_ADDRESS}" ollama show "${MODEL}" >/dev/null
curl -fsS "http://${BIND_ADDRESS}/api/tags"
echo
echo "Ollama ${MODEL} is ready on ${BIND_ADDRESS}. Backup: ${BACKUP_DIR}"
-55
View File
@@ -1,55 +0,0 @@
# ==============================================================================
# Noveria.net Landingpage — Nginx Server Block
# ==============================================================================
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
#
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
# ==============================================================================
server {
listen 443 ssl http2;
server_name noveria.net www.noveria.net;
# SSL (gleiche Zertifikate wie nexus)
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
# Security Header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
location / {
proxy_pass http://127.0.0.1:18881;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name noveria.net www.noveria.net;
return 301 https://$host$request_uri;
}
# ==============================================================================
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
# ==============================================================================
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
# grep -rn "noveria.net" /etc/nginx/sites-available/
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
#
# 2. Config testen nach Änderung
# nginx -t
#
# 3. Nginx neuladen
# systemctl reload nginx
# ==============================================================================
-81
View File
@@ -1,81 +0,0 @@
# /etc/nginx/sites-available/nexus.noveria.net
# Symlink: ln -s /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
server {
listen 80;
server_name nexus.noveria.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name nexus.noveria.net;
# SSL wird per certbot automatisch befüllt
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
# Security-Header
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
client_max_body_size 16m;
# Bridge-Endpunkte: Gateway-zu-Backend-Agent-Pfad
# X-Agent-Id wird durchgereicht für Agent-Identität
location /api/bridge/ {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Agent-Id $http_x_agent_id;
proxy_buffering off;
proxy_read_timeout 120s;
}
# Dashboard SSE stream: single dedicated non-buffered block.
location = /api/dashboard/live {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Accel-Buffering no always;
}
location / {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API-Direktzugriff falls nötig
location /api/ {
proxy_pass http://127.0.0.1:18880;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-107
View File
@@ -1,107 +0,0 @@
#!/bin/bash
# HTTPS-Setup für nexus.noveria.net
# Auf dem VPS-HOST ausführen!
set -e
echo "=== HTTPS Setup für nexus.noveria.net ==="
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
# 1. Zuerst nur HTTP-Config ausrollen (keine SSL-Referenz!)
echo "[1/5] Installiere HTTP-only Nginx-Config..."
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXEOF'
server {
listen 80;
server_name nexus.noveria.net;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
NGINXEOF
sudo ln -sf /etc/nginx/sites-available/nexus.noveria.net /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
echo " ✅ HTTP-Config aktiv"
# 2. Firewall
echo "[2/5] Firewall..."
if command -v ufw &>/dev/null; then
sudo ufw allow 80/tcp 2>/dev/null || true
sudo ufw allow 443/tcp 2>/dev/null || true
echo " ✅ ufw: 80+443 offen"
else
echo " ⏭ ufw nicht installiert"
fi
# 3. HTTP-Test
echo "[3/5] Teste HTTP..."
sleep 1
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://nexus.noveria.net)
echo " HTTP-Status: $STATUS"
# 4. Zertifikat holen
echo "[4/5] Fordere Let's-Encrypt-Zertifikat an..."
sudo certbot certonly --webroot -w /var/www/html -d nexus.noveria.net --non-interactive --agree-tos --email vmbao62@hotmail.de 2>&1 || {
echo " ⚠️ certbot fehlgeschlagen manuell nachholen:"
echo " sudo certbot --nginx -d nexus.noveria.net"
exit 1
}
echo " ✅ Zertifikat erhalten"
# 5. HTTPS-Config ausrollen
echo "[5/5] Aktiviere HTTPS-Config..."
sudo tee /etc/nginx/sites-available/nexus.noveria.net > /dev/null << 'NGINXSSL'
server {
listen 80;
server_name nexus.noveria.net;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name nexus.noveria.net;
ssl_certificate /etc/letsencrypt/live/nexus.noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nexus.noveria.net/privkey.pem;
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
client_max_body_size 16m;
location / {
proxy_pass http://127.0.0.1:18880;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
NGINXSSL
sudo nginx -t && sudo systemctl reload nginx
echo " ✅ HTTPS aktiv"
# Test
echo ""
sleep 2
curl -s -o /dev/null -w "HTTPS-Status: %{http_code}\n" https://nexus.noveria.net
echo ""
echo "=== Fertig ==="
echo "Nexus: https://nexus.noveria.net"
+3 -3
View File
@@ -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
View File
@@ -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