Compare commits

...

4 Commits

Author SHA1 Message Date
devops 20dedcd6fa chore: bump version to 0.2.52 [skip ci] 2026-06-14 06:42:37 +00:00
reviewer 4ad0f9e493 refactor: SOLID architecture — backend service layer + frontend V2 components
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
## Backend — Service Layer & Repository Refactoring

### Neue Services (21 neue Dateien)

**Interfaces & Implementierungen:**
- `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse)
- `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert
- `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP)
- `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController)
- `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation
- `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController
- `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController
- `IMemoryService` / `MemoryService` — File-I/O aus MemoryController
- `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController
- `IDocService` / `DocService` — Directory-Scan aus DocsController
- `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController

### Repository-Fixes

**IUserRepository / UserRepository:**
- `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern)
- `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges
- `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges
- `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save)

### AuthService-Fixes
- `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()`
- `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync`
- `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync`

### Bug-Fix
- `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt)

### Controller (16 Stück — alle slim)
Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben.
Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log).

**Program.cs — neue Registrierungen:**
- `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse)
- Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService
- Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService

---

## Frontend — Dashboard V2 Components

**AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:**
- V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente
- Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern
- NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:34:58 +02:00
devops ac4e1cd3cf chore: bump version to v0.2.51 [skip ci] 2026-06-13 19:04:36 +00:00
reviewer 01c9bda339 fix(ops): change api healthcheck from curl to wget (curl not in base dotnet-aspnet image) [skip ci] 2026-06-13 21:04:29 +02:00
56 changed files with 3069 additions and 2318 deletions
+156
View File
@@ -0,0 +1,156 @@
name: Database Backup
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: Manual (workflow_dispatch) + optional schedule.
#
# Strategy:
# 1. Connects to the live PostgreSQL container via docker exec.
# 2. Runs pg_dumpall (full cluster dump, single file).
# 3. Compresses with gzip.
# 4. Uploads as a Gitea Action artifact (or writes to host path).
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
#
# Rotation: Gitea artifact expiration handles old backups automatically.
# For longer retention, configure an external cron job or use the
# host_path output to copy the backup elsewhere.
#
# Restoration: See phases/deployment.md for step-by-step instructions.
# ───────────────────────────────────────────────────────
concurrency:
group: db-backup
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
keep_on_host:
description: 'Also copy backup to host path?'
required: false
default: false
type: boolean
host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)'
required: false
default: '/opt/openclaw/backups'
type: string
# Optional: uncomment to enable nightly automatic backups
# schedule:
# - cron: '0 3 * * *' # Every night at 03:00 UTC
jobs:
backup:
name: Backup PostgreSQL
runs-on: ubuntu-latest
env:
ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1
steps:
# ═══════════════════════════════════════════════════
# Step 1: Generate backup filename
# ═══════════════════════════════════════════════════
- name: Generate backup identifier
id: meta
run: |
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
echo "📅 Backup ID: ${TIMESTAMP}"
# ═══════════════════════════════════════════════════
# Step 2: Dump PostgreSQL via docker exec
# ═══════════════════════════════════════════════════
- name: Dump database
run: |
set -euo pipefail
echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
| gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
# ═══════════════════════════════════════════════════
# Step 3: Upload backup as Gitea artifact
# ═══════════════════════════════════════════════════
- name: Upload backup artifact
uses: actions/upload-artifact@v4
with:
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
path: ${{ steps.meta.outputs.filename }}
retention-days: 90
compression-level: 0 # already gzipped
# ═══════════════════════════════════════════════════
# Step 4: Optional — copy to host filesystem
# ═══════════════════════════════════════════════════
- name: Copy backup to host (optional)
if: inputs.keep_on_host == true
run: |
set -euo pipefail
HOST_PATH="${{ inputs.host_backup_path }}"
# Create host dir if it doesn't exist
docker run --rm \
-v "${HOST_PATH}:/backup-target" \
-v "${{ gitea.workspace }}:/src:ro" \
alpine:latest \
sh -c "
mkdir -p /backup-target && \
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
"
# ═══════════════════════════════════════════════════
# Step 5: Verify backup integrity
# ═══════════════════════════════════════════════════
- name: Verify backup integrity
run: |
echo "🔍 Verifying backup integrity..."
if gzip -t "${{ steps.meta.outputs.filename }}"; then
echo "✅ Backup gzip integrity check passed"
else
echo "❌ Backup file is corrupted!"
exit 1
fi
# Quick content check: should start with PostgreSQL dump header
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
echo "✅ Backup content header check passed"
else
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
fi
# ═══════════════════════════════════════════════════
# Step 6: Backup Summary
# ═══════════════════════════════════════════════════
- name: Backup Summary
if: always()
run: |
STATUS="${{ job.status }}"
echo ""
echo "═══════════════════════════════════════"
echo " 💾 Database Backup Summary"
echo "═══════════════════════════════════════"
echo " File: ${{ steps.meta.outputs.filename }}"
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
echo " Triggered: @${{ gitea.actor }}"
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
echo " Status: ${STATUS}"
echo "═══════════════════════════════════════"
if [ "${STATUS}" = "success" ]; then
echo ""
echo "💡 Restore command (manual, on host):"
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
fi
+19 -6
View File
@@ -34,7 +34,6 @@ jobs:
- name: Test
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
continue-on-error: true
# ─── Frontend ──────────────────────────────────
frontend:
@@ -54,16 +53,18 @@ jobs:
corepack enable
corepack prepare pnpm@latest --activate
# --prefer-offline: use cached packages if available in the runner image
# Lockfile IS committed — regenerated on changes via pnpm install.
- name: Install dependencies
run: pnpm install --no-frozen-lockfile --prefer-offline
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Type check
run: pnpm exec vue-tsc --noEmit
working-directory: frontend
- name: Test
run: pnpm test
working-directory: frontend
- name: Build
run: pnpm build
working-directory: frontend
@@ -79,8 +80,20 @@ jobs:
- name: Check for .env leaks
run: |
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then
echo "⚠️ Warning: Potential secrets in source code (review manually)"
echo "🔍 Scanning for potential secrets in source code..."
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
echo "$HITS"
echo ""
echo "Remove these values and use environment variables or a secrets manager instead."
exit 1
fi
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$LOOSE" ]; then
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
echo "$LOOSE"
else
echo "✅ No obvious secrets found"
fi
+237 -105
View File
@@ -1,158 +1,249 @@
name: Deploy to Production
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
run-name: 🚀 Deploy by @${{ gitea.actor }}
# ── Concurrency: one deploy at a time, cancel queued ones ──
# Why: prevents race conditions when CI triggers deploy while
# a manual deploy is still running. The latest deploy wins.
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version-Bump / CI Loop Prevention:
# The version-bump commit includes "[skip ci]" in its message,
# which Gitea Actions respects. The auto-trigger additionally
# checks for "[skip ci]" as a second safety layer. Together
# they guarantee that a version-bump commit does NOT trigger
# another CI → Deploy → Bump → CI cycle.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
# ───────────────────────────────────────────────────
# Trigger: automatic after CI success, or manual dispatch.
# Runner: uses ubuntu-latest label (consistently present on
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
# Standard labels avoid custom-label matching edge cases.
# ───────────────────────────────────────────────────
on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run:
workflows: ["CI - Build & Test"]
types: [completed]
branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch:
inputs:
bump_version:
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
required: false
version_bump:
description: 'Version bump type'
required: true
default: 'patch'
type: string
type: choice
options:
- 'patch'
- 'minor'
- 'major'
- patch
- minor
- major
service:
description: 'Service to deploy (empty = all)'
required: false
default: ''
type: string
no_cache:
description: 'Disable build cache'
description: 'Disable Docker build cache'
required: false
default: false
type: boolean
git_ref:
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
required: false
default: 'main'
type: string
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
if: |
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
# ── Env for the deploy target path ──
env:
DEPLOY_PATH: /opt/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_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
# ── Step 1: Checkout ─────────────────────
- name: Checkout latest code
# ═══════════════════════════════════════════════════
# Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
fetch-depth: 0
fetch-tags: true
# ── Step 2: Version bump (race-free) ─────
# Derives current version from git tags (not VERSION file) to
# avoid race conditions where tag exists but VERSION is stale.
# Uses --force on tag+push to handle retries after failed runs.
- name: Version Bump
# ═══════════════════════════════════════════════════
# Step 2: Set up Git identity
# ═══════════════════════════════════════════════════
- name: Configure Git
run: |
git config user.email "devops@noveria.net"
git config user.name "DevOps"
# ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version
#
# Deploying main: DevOps may bump VERSION and create a tag.
# Deploying any other ref: deploy exactly that ref, but DO NOT
# mutate main or create a version-bump commit on another branch.
#
# For auto-deploys (workflow_run): always "patch" bump on main.
# ═══════════════════════════════════════════════════
- name: Resolve Version
id: version
run: |
set -euo pipefail
# Source of truth: latest git tag
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
CURRENT_VERSION="${TAG#v}"
echo "📦 Current version (from git tags): $CURRENT_VERSION"
# Determine bump type (auto-deploy → patch; manual → user choice)
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
# Read current version
if [ ! -f VERSION ]; then
echo "❌ VERSION file not found"
exit 1
fi
case "${{ inputs.bump_version }}" in
major)
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor)
MINOR=$((MINOR + 1)); PATCH=0 ;;
patch|*)
PATCH=$((PATCH + 1)) ;;
CURRENT=$(cat VERSION | tr -d '[:space:]')
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$CURRENT'"
exit 1
fi
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
case "$BUMP_TYPE" in
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "🏷️ New version: $NEW_VERSION"
# Determine git ref — auto-deploy always uses main
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
echo "$NEW_VERSION" > VERSION
git config user.email "devops@noveria.net"
git config user.name "DevOps"
git add VERSION
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin HEAD:main --tags
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
echo "mutated_main=true" >> "$GITEA_OUTPUT"
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
else
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
fi
# --force avoids "tag already exists" when re-running after a failed attempt
git tag -f "v${NEW_VERSION}"
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
echo "✅ Version bumped to v${NEW_VERSION}"
# ── Step 3: Sync code + .env to host ──────
# Creates .env from Gitea secrets in the workspace, then syncs
# everything (except .git) to the host deploy path via DIND.
- name: Sync code + .env to host
# ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets (SAFE)
#
# Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after
# compose operations complete.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets → temp file)
run: |
# Create .env from Gitea secrets in the workspace
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
set -euo pipefail
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea secrets → do not edit manually on the host
# Managed via Gitea Secrets → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
ENVEOF
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
# Sync everything (except .git) from workspace to host
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -a {} /dest/ \;
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Code + .env synced to host deploy path"
# ── Step 4: Docker Buildx ─────────────────
- name: Set up Docker Buildx
run: docker buildx create --use 2>/dev/null || true
echo "✅ Code synced to ${DEPLOY_PATH}"
# ── Step 5: Build & Deploy ────────────────
# ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy
run: |
set -euo pipefail
# Auto-deploy: always use cache. Manual: respect no_cache input.
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
BUILD_ARGS=""
if [ "${{ inputs.no_cache }}" = "true" ]; then
if [ "$NO_CACHE" = "true" ]; then
BUILD_ARGS="--no-cache"
fi
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
docker run --rm \
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "${ENV_TMPFILE}:/workspace/nexus/.env:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
if [ -n '${{ inputs.service }}' ]; then
echo '🚀 Deploying service: ${{ inputs.service }}'
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
docker compose up -d --wait --force-recreate ${{ inputs.service }}
if [ -n '${SERVICE_ARG}' ]; then
echo '🚀 Deploying service: ${SERVICE_ARG}'
docker compose build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose up -d --wait --force-recreate ${SERVICE_ARG}
else
echo '🚀 Deploying all services'
docker compose build ${BUILD_ARGS}
@@ -160,10 +251,22 @@ jobs:
fi
"
# ── Step 6: Health Check (backoff) ────────
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
# Why: cold-start containers need variable warmup time;
# fixed 5s intervals either wait too long or give up too early.
echo "✅ Docker compose up completed"
# ═══════════════════════════════════════════════════
# Step 7: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 8: Health Check (exponential backoff)
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check..."
@@ -186,11 +289,10 @@ jobs:
echo "❌ Health check failed after $MAX attempts"
exit 1
# ── Step 7: Smoke test (multi-endpoint) ───
# Tests multiple endpoints to catch partial failures.
# Why: a single /dashboard check can miss backend-only outages;
# /health tests the API + database + runtime status.
- name: Verify (smoke test)
# ═══════════════════════════════════════════════════
# Step 9: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test..."
PASS=0
@@ -199,7 +301,8 @@ jobs:
check() {
local path="$1" label="$2" expected="${3:-200}"
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
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 " ✅"
@@ -212,6 +315,7 @@ jobs:
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
@@ -219,25 +323,53 @@ jobs:
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Deployment verified"
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ── Step 8: Rollback hint ────────────────
# On any failure, prints the previous deploy tag for quick manual rollback.
# Why: reduces MTTR (mean time to recovery) by providing the exact
# git tag to roll back to without needing to look it up manually.
- name: Rollback hint
# ═══════════════════════════════════════════════════
# Step 10: Deployment Summary
# ═══════════════════════════════════════════════════
- name: Deployment Summary
if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}"
echo " Bump type: ${VERSION_BUMP}"
echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 11: Failure → Reviewer Handoff
#
# On failure: DevOps (Architekt) analyses the log,
# notifies Reviewer (Code-Fixer) with the exact error.
# This output provides a ready-to-copy message.
# ═══════════════════════════════════════════════════
- name: 🔴 Failure — Reviewer Handoff
if: failure()
run: |
echo ""
echo "🔙 ─── Rollback Instructions ─── 🔙"
echo ""
echo " # 1. Checkout previous version:"
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
echo ""
echo " # 2. Redeploy:"
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
echo " docker compose up -d --force-recreate"
echo ""
echo " # 3. Or trigger rollback via Gitea:"
echo " Trigger 'Deploy to Production' workflow with the previous tag"
echo ""
echo "─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Version: v${{ steps.version.outputs.version }}"
echo " Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo " → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo " "
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+277
View File
@@ -0,0 +1,277 @@
name: Rollback to Previous Version
run-name: 🔙 Rollback by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
#
# This workflow reverts the deploy path to the code at a
# given git tag/ref, then rebuilds and redeploys the stack.
#
# Strategy: git checkout <tag> → docker compose up -d --build
# This is a "full restart rollback" — safest for containerized
# apps where DB schema changes may need the matching API binary.
#
# DB migrations: the API runs MigrateAsync on startup. If the
# rollback-tag's migration history is a prefix of the current DB,
# EF Core handles this gracefully (no-op for already-applied
# migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
target_tag:
description: 'Git tag to roll back to (e.g. v0.2.49)'
required: true
type: string
confirm:
description: 'Type "ROLLBACK" to confirm'
required: true
type: string
jobs:
rollback:
name: Rollback Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
# ═══════════════════════════════════════════════════
# Step 0: Safety gate — require explicit confirmation
# ═══════════════════════════════════════════════════
- name: Safety Gate
run: |
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
echo " You entered: '${{ inputs.confirm }}'"
exit 1
fi
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
# ═══════════════════════════════════════════════════
# Step 1: Checkout target tag
# ═══════════════════════════════════════════════════
- name: Checkout target tag
uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.target_tag }}
fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════
# Step 2: Verify tag exists
# ═══════════════════════════════════════════════════
- name: Verify tag
run: |
set -euo pipefail
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$ACTUAL_TAG" ]; then
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
echo " Available tags:"
git tag -l 'v*' | sort -V | tail -20
exit 1
fi
echo "✅ Checked out: $ACTUAL_TAG"
echo " Commit: $(git rev-parse --short HEAD)"
echo " Message: $(git log -1 --oneline)"
# Read version from VERSION file at this tag
if [ -f VERSION ]; then
VERSION=$(cat VERSION | tr -d '[:space:]')
echo " VERSION: $VERSION"
fi
# ═══════════════════════════════════════════════════
# Step 3: Prepare .env from secrets (safe temp file)
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets → temp file)
run: |
set -euo pipefail
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 4: Sync rollback code to host
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 5: Rebuild & Redeploy
# ═══════════════════════════════════════════════════
- name: Rebuild & Redeploy
run: |
set -euo pipefail
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "${ENV_TMPFILE}:/workspace/nexus/.env:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose build --no-cache
docker compose up -d --wait --force-recreate
"
echo "✅ Rollback redeploy completed"
# ═══════════════════════════════════════════════════
# Step 6: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 7: Health Check
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check after rollback..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 8: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test after rollback..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
# ═══════════════════════════════════════════════════
# Step 9: Rollback Summary
# ═══════════════════════════════════════════════════
- name: Rollback Summary
if: always()
run: |
echo ""
echo "═══════════════════════════════════════"
echo " 🔙 Rollback Summary"
echo "═══════════════════════════════════════"
echo " Rolled to: ${{ inputs.target_tag }}"
echo " Triggered: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 10: Failure → Reviewer Handoff
# ═══════════════════════════════════════════════════
- name: 🔴 Rollback Failed — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Target: ${{ inputs.target_tag }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+3
View File
@@ -31,3 +31,6 @@ docker-compose.override.yml
*.bak
# pnpm (lockfile IS committed for reproducible CI builds)
# Claude local config (per-developer, not repo-shared)
.claude/
+61 -2
View File
@@ -3,7 +3,11 @@
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
adapter-backed agent runtime, not a dependency of the frontend or domain model.
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy.
> CI runs automatically on every push. CD can run **automatically after successful CI**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
> stay read-only. Rollback and database backup are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation
@@ -287,4 +291,59 @@ The configured model-routing policy is:
The Settings module reports runtime and provider state without exposing
credentials.
# Trigger CI
## CI/CD
### CI — Automatic
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
- **Backend**: .NET restore → build → test
- **Frontend**: pnpm install → type-check → test → build
- **Security**: Scan for hardcoded secrets in source code
CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3)
Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
- Uses safe defaults: `patch` bump, all services, main ref
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
#### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions
2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`)
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
4. Health check + smoke test verify the rollback
#### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
#### Failure Handling
When deploy or rollback fails:
- **DevOps (Architekt)** analyses the error
- **Reviewer (Code-Fixer)** fixes the problem
- **DevOps** re-deploys to verify the fix
The workflow outputs a formatted handoff message with the job URL.
Full CD documentation: [phases/deployment.md](phases/deployment.md)
+1 -1
View File
@@ -1 +1 @@
0.2.50
0.2.52
+14 -65
View File
@@ -1,8 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Helpers;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -15,6 +13,7 @@ public class AgentsController(
IAgentService agentService,
IAgentRuntime runtime,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
@@ -22,8 +21,7 @@ public class AgentsController(
{
var agents = await agentService.GetAgentsAsync(ct);
return Results.Ok(agents.Select(a => new AgentListResponse(
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
)));
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
}
[HttpGet("{id}")]
@@ -34,8 +32,7 @@ public class AgentsController(
return Results.Ok(new AgentDetailResponse(
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
agent.SubAgents, agent.IdentityName
));
agent.SubAgents, agent.IdentityName));
}
[HttpGet("{id}/activity")]
@@ -58,9 +55,7 @@ public class AgentsController(
try
{
var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
}
catch (Exception exception)
@@ -73,79 +68,33 @@ public class AgentsController(
}
}
// ========== Agent Config Editor ==========
// ── Config Editor ──
[HttpGet("{id}/config")]
public IResult GetConfig(string id)
{
var workspacePath = $"/mnt/workspace-{id}";
if (!Directory.Exists(workspacePath))
return Results.Ok(Array.Empty<object>());
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
var files = Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => allowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new
{
fileName = f.Name,
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
return Results.Ok(files);
}
=> Results.Ok(agentConfigService.GetConfigFiles(id));
[HttpGet("{id}/config/{fileName}")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
var workspacePath = $"/mnt/workspace-{id}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return file is null
? Results.NotFound()
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
}
[HttpPut("{id}/config/{fileName}")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
var workspacePath = $"/mnt/workspace-{id}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return Results.NotFound();
var tempPath = safePath + ".tmp";
try
{
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
System.IO.File.Move(tempPath, safePath, overwrite: true);
}
catch
{
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
throw;
}
var fi = new FileInfo(safePath);
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
}
}
+4 -67
View File
@@ -1,80 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/calendar")]
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
public class CalendarController(ICalendarService calendarService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
{
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return Results.Ok(data ?? new List<CronJobEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
}
var fallbackJobs = new List<object>
{
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
};
return Results.Ok(fallbackJobs);
}
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
[HttpGet("upcoming")]
public async Task<IResult> GetUpcoming(CancellationToken ct)
{
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return Results.Ok(data ?? new List<UpcomingCronEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
}
var now = DateTimeOffset.UtcNow;
var fallback = new List<object>
{
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
};
return Results.Ok(fallback);
}
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
}
+55 -417
View File
@@ -1,403 +1,113 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
public class DashboardController(
OpenClawGatewayClient gateway,
ITaskRepository taskRepo,
IActivityRepository activityRepo,
ILogger<DashboardController> logger)
: ControllerBase
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
{
/// <summary>
/// Gateway health + session_status + subagents count.
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
/// </summary>
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
=> await dashboardService.GetStatusAsync();
/// <summary>
/// Returns all agents with their current status.
/// Combines sessions_list + sub_agents_list.
/// </summary>
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return new List<DashboardAgentInfo>();
}
}
=> await dashboardService.GetAgentsAsync();
/// <summary>
/// Returns the latest assistant messages aggregated from ALL agent sessions.
/// Events are sorted by timestamp descending (newest first).
/// Supports optional agent filter via ?agent= query parameter.
/// Falls back to Iris-only feed if multi-agent feed fails.
/// </summary>
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations(
[FromQuery] int limit = 20,
[FromQuery] string? agent = null)
{
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
=> await dashboardService.GetOperationsAsync(limit, agent);
// Optional agent filter
if (!string.IsNullOrWhiteSpace(agent))
{
entries = entries
.Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase))
.ToList();
}
return entries;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return new List<FeedEntry>();
}
}
/// <summary>
/// Send a chat message to the Iris session.
/// </summary>
[HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required");
try
{
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
? "iris"
: request.AgentId.Trim();
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
}
/// <summary>
/// Returns chat messages (user + assistant only, not tool messages).
/// </summary>
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
{
try
{
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
return messages
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard messages fetch failed");
return new List<MessageEntry>();
}
}
/// <summary>
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
/// </summary>
[HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
{
try
{
// Fetch cron jobs and open tasks concurrently
var cronTask = gateway.GetQueueAsync();
var tasksTask = taskRepo.GetAllAsync(ct);
=> await dashboardService.GetQueueAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var cronJobs = cronTask.Result;
var openTasks = tasksTask.Result
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.ToList();
var merged = new List<QueueItem>();
// Map cron jobs (already in QueueItem format from gateway)
merged.AddRange(cronJobs);
// Map open tasks to QueueItems
foreach (var t in openTasks)
{
var priority = NormalizePriority(t.Priority);
merged.Add(new QueueItem(
"task-" + t.Id.ToString(),
t.Title,
t.State,
priority,
"task",
"--"
));
}
// Sort: high priority first, then medium, then low
var priorityOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
["high"] = 0,
["medium"] = 1,
["low"] = 2
};
return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return new List<QueueItem>();
}
}
private static string NormalizePriority(string priority)
{
return priority.ToLowerInvariant() switch
{
"high" or "critical" or "urgent" => "high",
"low" or "minor" => "low",
_ => "medium"
};
}
/// <summary>
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
/// </summary>
[HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
try
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
return result.Outcome switch
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
{
var ok = await gateway.DeleteCronJobAsync(id);
if (!ok)
return StatusCode(502, new { error = "Gateway could not delete cron job" });
return NoContent();
}
else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
{
// Extract the actual GUID from the prefixed id ("task-{guid}")
if (!id.StartsWith("task-"))
return BadRequest(new { error = "Invalid task id format" });
var guidStr = id["task-".Length..];
if (!Guid.TryParse(guidStr, out var guid))
return BadRequest(new { error = "Invalid task id" });
var task = await taskRepo.GetByIdAsync(guid, ct);
if (task is null)
return NotFound(new { error = "Task not found" });
// Set task status to Done instead of deleting
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" completed via queue"
}, ct);
return NoContent();
QueueDeleteOutcome.Deleted => NoContent(),
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => StatusCode(500, new { error = "Internal error" })
};
}
// Default: try cron
var deleted = await gateway.DeleteCronJobAsync(id);
if (!deleted)
return NotFound(new { error = "Queue item not found" });
return NoContent();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
return StatusCode(500, new { error = "Internal error" });
}
}
/// <summary>
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
/// Cycles: high → medium → low → high.
/// </summary>
[HttpPut("queue/{id}/priority")]
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
{
try
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
return result.Outcome switch
{
if (!id.StartsWith("task-"))
return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
var guidStr = id["task-".Length..];
if (!Guid.TryParse(guidStr, out var guid))
return BadRequest(new { error = "Invalid task id" });
var task = await taskRepo.GetByIdAsync(guid, ct);
if (task is null)
return NotFound(new { error = "Task not found" });
// Cycle priority: high → medium → low → high
task.Priority = task.Priority.ToLowerInvariant() switch
{
"high" => "Medium",
"medium" => "Low",
"low" => "High",
_ => "Medium"
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => Ok(new { status = "ok", priority = result.NewPriority })
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" priority → {task.Priority}"
}, ct);
return Ok(new { status = "ok", priority = task.Priority });
}
catch (Exception ex)
{
logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
return StatusCode(500, new { error = "Internal error" });
}
}
/// <summary>
/// Returns the current model and provider for a specific agent session.
/// Calls session_status with the agent's session key.
/// </summary>
[HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
{
try
{
var info = await gateway.GetAgentModelAsync(id);
if (info is null)
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
return Ok(info);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
return StatusCode(500, new { error = "Internal error" });
}
var info = await dashboardService.GetAgentModelAsync(id);
return info is null
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
: Ok(info);
}
/// <summary>
/// Sets the model for a specific agent session.
/// Calls session_status with model parameter.
/// </summary>
[HttpPut("agents/{id}/model")]
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Model))
return BadRequest(new { error = "Model is required" });
try
{
var ok = await gateway.SetAgentModelAsync(id, request.Model);
if (!ok)
return StatusCode(502, new { error = "Gateway did not accept the change" });
return Ok(new { status = "ok", model = request.Model });
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
return StatusCode(500, new { error = "Internal error" });
}
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
}
/// <summary>
/// Returns the most recent activity entries (assistant messages) for a specific agent.
/// </summary>
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
{
try
{
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
return new List<AgentActivityEntry>();
}
}
=> await dashboardService.GetAgentActivityAsync(id, limit);
/// <summary>
/// Returns the list of available models that can be assigned to agents.
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
/// </summary>
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
{
var models = gateway.GetAvailableModels();
return Ok(models);
}
=> Ok(dashboardService.GetAvailableModels());
// ========== Task Endpoints ==========
// ── Task Endpoints ──
/// <summary>
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
/// </summary>
[HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
try
{
var tasks = await taskRepo.GetAllAsync(ct);
return tasks
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.Select(MapToDto)
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard tasks fetch failed");
return new List<DashboardTaskDto>();
}
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
}
/// <summary>
/// Creates a new task and logs an activity event.
/// </summary>
[HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
@@ -405,121 +115,49 @@ public class DashboardController(
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
var task = new WorkTask
{
Title = request.Title.Trim(),
Detail = request.Detail?.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
AssignedTo = request.AssignedTo?.Trim(),
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" created ({task.Source})"
}, ct);
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
/// <summary>
/// Updates an existing task (title, detail, source, priority, assignedTo).
/// </summary>
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
if (!string.IsNullOrWhiteSpace(request.Title))
task.Title = request.Title.Trim();
if (request.Detail is not null)
task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim();
if (!string.IsNullOrWhiteSpace(request.Source))
task.Source = request.Source.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
task.Priority = request.Priority.Trim();
if (request.AssignedTo is not null)
task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim();
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
var result = await taskService.UpdateDashboardTaskAsync(
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return result.Outcome switch
{
Type = "task",
Message = $"Task \"{task.Title}\" updated"
}, ct);
return Ok(MapToDto(task));
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>
/// Deletes a task (only if status is 'Done' or 'Backlog').
/// </summary>
[HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." });
await activityRepo.AddAsync(new ActivityEvent
var result = await taskService.DeleteAsync(id, ct);
return result.Outcome switch
{
Type = "task",
Message = $"Task \"{task.Title}\" deleted"
}, ct);
await taskRepo.DeleteAsync(task, ct);
return NoContent();
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
};
}
/// <summary>
/// Changes the status of a task.
/// </summary>
[HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
if (!TaskStateHelper.IsValidState(request.Status))
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" });
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null)
return NotFound(new { error = "Task not found." });
var canonicalState = TaskStateHelper.AllStates.First(s =>
s.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
task.State = canonicalState;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch
{
Type = "task",
Message = $"Task \"{task.Title}\" → {canonicalState}"
}, ct);
return Ok(MapToDto(task));
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
// ========== Helpers ==========
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id,
t.Title,
t.Detail,
t.Source,
t.State,
t.Priority,
t.AssignedTo,
t.CreatedAt,
t.UpdatedAt
);
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
}
+5 -51
View File
@@ -1,47 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/docs")]
public class DocsController : ControllerBase
public class DocsController(IDocService docService) : ControllerBase
{
[HttpGet]
public IResult GetAll()
{
var workspaceRoot = "/mnt/workspace-iris";
var results = new List<object>();
void ScanDir(string dir, string category)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
continue;
var fi = new FileInfo(file);
results.Add(new
{
name = fi.Name,
path = file.Replace(workspaceRoot, "").TrimStart('/'),
category,
type = ext.Replace(".", ""),
size = fi.Length,
modifiedAt = fi.LastWriteTimeUtc
});
}
}
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
ScanDir("/mnt/workspace-iris/skills", "skills");
ScanDir("/mnt/workspace-iris", "workspace");
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
}
=> Results.Ok(docService.GetAll());
[HttpGet("{**path}")]
public async Task<IResult> GetFile(string path)
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required.");
string? resolvedPath = null;
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
var file = await docService.GetFileAsync(path);
return file is null ? Results.NotFound() : Results.Ok(file);
}
}
+5 -85
View File
@@ -1,100 +1,20 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers;
using System.Text.RegularExpressions;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/incidents")]
public class IncidentsController : ControllerBase
public class IncidentsController(IIncidentService incidentService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll()
{
var basePath = "/mnt/workspace-iris/memory/incidents";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var incidents = new List<object>();
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await System.IO.File.ReadAllTextAsync(file);
var title = name;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
var severity = "unknown";
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
if (severityMatch.Success)
severity = severityMatch.Groups[1].Value.Trim();
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0
? content[..excerptEnd].Trim()
: content[..Math.Min(300, content.Length)].Trim();
if (excerpt.Length > 200)
excerpt = excerpt[..200] + "\u2026";
incidents.Add(new
{
name = Path.GetFileName(file),
title,
date,
severity,
excerpt,
size = fi.Length
});
}
return Results.Ok(incidents);
}
=> Results.Ok(await incidentService.GetAllAsync());
[HttpGet("{name}")]
public async Task<IResult> GetOne(string name)
{
var basePath = "/mnt/workspace-iris/memory/incidents";
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
return Results.BadRequest("Invalid filename.");
if (!System.IO.File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(basePath, name + ".md");
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
}
var content = await System.IO.File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = fileName;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
return Results.Ok(new
{
name = fileName,
title,
date,
content,
size = fi.Length
});
var incident = await incidentService.GetByNameAsync(name);
return incident is null ? Results.NotFound() : Results.Ok(incident);
}
}
+7 -86
View File
@@ -1,40 +1,15 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/memory")]
public class MemoryController : ControllerBase
public class MemoryController(IMemoryService memoryService) : ControllerBase
{
[HttpGet]
public IResult GetAll()
{
var basePath = "/mnt/workspace-iris/memory";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var files = Directory.GetFiles(basePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new
{
name = f.Name,
path = f.FullName.Replace(basePath, "").TrimStart('/'),
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
var fi = new FileInfo(longTermPath);
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
}
return Results.Ok(files);
}
public async Task<IResult> GetAll()
=> Results.Ok(await memoryService.GetAllAsync());
[HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q)
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters.");
var basePath = "/mnt/workspace-iris/memory";
var results = new List<object>();
const int maxFiles = 50;
const int maxFileSize = 1_000_000;
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
foreach (var file in files)
{
var fi = new FileInfo(file);
if (fi.Length > maxFileSize) continue;
string content;
using (var reader = new StreamReader(file))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
}
}
}
await SearchDir(basePath);
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
string content;
using (var reader = new StreamReader(longTermPath))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
}
}
return Results.Ok(results);
return Results.Ok(await memoryService.SearchAsync(q));
}
[HttpGet("{name}")]
public async Task<IResult> GetFile(string name)
{
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
return Results.BadRequest("Invalid filename.");
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
filePath = longTermPath;
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(filePath!);
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
var file = await memoryService.GetFileAsync(name);
return file is null ? Results.NotFound() : Results.Ok(file);
}
}
+2 -60
View File
@@ -1,71 +1,13 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/operations")]
public class OperationsController(
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ControllerBase
public class OperationsController(IOperationsService operationsService) : ControllerBase
{
[HttpGet("snapshot")]
public async Task<IResult> GetSnapshot(CancellationToken ct)
{
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
var projectsTask = projectRepo.GetAllAsync(ct);
var tasksTask = taskRepo.GetAllAsync(ct);
var activityTask = activityRepo.GetRecentAsync(20, ct);
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
var tasks = tasksTask.Result;
var projects = projectsTask.Result;
var agents = agentsTask.Result;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = runtimeTask.Result;
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
var projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
};
return Results.Ok(new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth,
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
});
}
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
}
+19 -46
View File
@@ -1,17 +1,23 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/projects")]
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
public class ProjectsController(IProjectService projectService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectRepo.GetAllAsync(ct));
=> Results.Ok(await projectService.GetAllAsync(ct));
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectService.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var project = new Project
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
var project = await projectService.CreateAsync(request, ct);
return Results.Created($"/api/v1/projects/{project.Id}", project);
}
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectRepo.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return Results.NotFound();
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return Results.Ok(project);
var project = await projectService.UpdateAsync(id, request, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return Results.NotFound();
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
if (hasTasks)
var result = await projectService.DeleteAsync(id, ct);
return result.Outcome switch
{
project.Status = OperationalStatus.Offline;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
return Results.Ok(project);
}
await projectRepo.DeleteAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
return Results.NoContent();
ProjectDeleteOutcome.NotFound => Results.NotFound(),
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
_ => Results.NoContent()
};
}
}
+47 -70
View File
@@ -1,17 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/tasks")]
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
public class TasksController(ITaskService taskService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await taskRepo.GetAllAsync(ct));
=> Results.Ok(await taskService.GetAllAsync(ct));
[HttpPost]
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
@@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
if (string.IsNullOrWhiteSpace(request.Title))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = new WorkTask
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
var task = await taskService.CreateAsync(request, ct);
return Results.Created($"/api/v1/tasks/{task.Id}", task);
}
[HttpGet("pending-approval")]
public async Task<IResult> GetPendingApproval(CancellationToken ct)
{
var pending = await taskRepo.GetPendingApprovalAsync(ct);
var pending = await taskService.GetPendingApprovalAsync(ct);
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
}
[HttpPost("{id:guid}/approve")]
public async Task<IResult> Approve(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return Results.Problem(
var result = await taskService.ApproveAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
TaskOperationOutcome.InvalidState => Results.Problem(
title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden);
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
return Results.Ok(task);
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
};
}
[HttpPost("{id:guid}/reject")]
public async Task<IResult> Reject(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return Results.Problem(
var result = await taskService.RejectAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
TaskOperationOutcome.InvalidState => Results.Problem(
title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden);
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
return Results.Ok(task);
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
};
}
[HttpPatch("{id:guid}/state")]
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
{
var allowedStates = TaskStateHelper.AllStates;
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
if (!TaskStateHelper.IsValidState(request.State))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
return Results.Ok(task);
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
var result = await taskService.UpdateStateAsync(id, request.State, ct);
return result.Outcome switch
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return Results.NoContent();
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
};
}
[HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
var result = await taskService.UpdateAsync(id, request, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
};
}
if (!string.IsNullOrWhiteSpace(request.Title))
task.Title = request.Title.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
task.Priority = request.Priority.Trim();
if (request.ProjectId.HasValue)
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
return Results.Ok(task);
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
{
var result = await taskService.DeleteAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
TaskOperationOutcome.InvalidState => Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.NoContent()
};
}
}
+2 -29
View File
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/v1/team")]
public class TeamController(IAgentService agentService) : ControllerBase
public class TeamController(ITeamService teamService) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetTeam(CancellationToken ct)
{
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<object>();
foreach (var agent in agents)
{
string identity = "";
string workspace = agent.Workspace ?? "";
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
{
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (System.IO.File.Exists(identityFile))
{
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
identity = string.Join("\n", lines);
}
}
team.Add(new
{
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity
});
}
return Results.Ok(team);
}
=> Results.Ok(await teamService.GetTeamAsync(ct));
}
+6 -1
View File
@@ -11,7 +11,12 @@ public sealed record DashboardAgentInfo(
string[] Tags,
int Progress = 0,
int Workload = 0,
string? Goal = null
string? Goal = null,
string RoleBadge = "badge-slate",
string StatusLabel = "Bereit",
string? Elapsed = null,
string? Think = null,
string? Next = null
);
public sealed record MessageEntry(
+11 -1
View File
@@ -112,7 +112,7 @@ builder.Services.AddHttpClient("gateway", client =>
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
@@ -123,6 +123,16 @@ builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAgentService, AgentService>();
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<ITaskService, TaskService>();
builder.Services.AddScoped<IOperationsService, OperationsService>();
builder.Services.AddScoped<ITeamService, TeamService>();
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
builder.Services.AddSingleton<IMemoryService, MemoryService>();
builder.Services.AddSingleton<IIncidentService, IncidentService>();
builder.Services.AddSingleton<IDocService, DocService>();
builder.Services.AddScoped<ICalendarService, CalendarService>();
// --- Repositories ---
builder.Services.AddScoped<IUserRepository, UserRepository>();
+2 -3
View File
@@ -10,12 +10,11 @@ public interface IUserRepository
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
// Refresh token operations
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
+30 -3
View File
@@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
{
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await db.SaveChangesAsync(ct);
}
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
{
var activeTokens = await db.RefreshTokens
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
.ToListAsync(ct);
if (activeTokens.Count == 0) return;
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await db.SaveChangesAsync(ct);
}
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
.ToListAsync(ct);
if (oldTokens.Count > 0)
{
db.RefreshTokens.RemoveRange(oldTokens);
await db.SaveChangesAsync(ct);
}
}
public Task SaveChangesAsync(CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
}
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class AgentConfigService : IAgentConfigService
{
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
{
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return Array.Empty<AgentConfigFileInfo>();
return Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => AllowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
.ToList();
}
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
return null;
var content = await File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null;
var tempPath = safePath + ".tmp";
try
{
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
catch
{
if (File.Exists(tempPath)) File.Delete(tempPath);
throw;
}
var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
}
}
+3 -27
View File
@@ -71,7 +71,7 @@ public sealed class AuthService : IAuthService
if (token.RevokedAt is not null)
{
await RevokeFamilyAsync(token.FamilyId, ct);
await _users.RevokeFamilyAsync(token.FamilyId, ct);
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
return null;
}
@@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await _users.SaveChangesAsync(ct);
await _users.RevokeTokenAsync(tokenHash, ct);
}
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> Task.Run(async () =>
{
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
// For read-only access, we call it but the result shouldn't be mutated
return await _users.GetByIdAsync(userId, ct);
}, ct);
=> _users.GetByIdAsync(userId, ct).AsTask();
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
{
@@ -228,19 +217,6 @@ public sealed class AuthService : IAuthService
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
{
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await _users.SaveChangesAsync(ct);
}
private static string GenerateRefreshToken()
{
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
+86
View File
@@ -0,0 +1,86 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public sealed class CalendarService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<CalendarService> logger) : ICalendarService
{
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return data ?? new List<CronJobEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
}
return BuildFallbackCronJobs();
}
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return data ?? new List<UpcomingCronEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
}
return BuildFallbackUpcomingJobs();
}
private HttpClient CreateGatewayClient()
{
var client = httpClientFactory.CreateClient("gateway");
var token = configuration["Integrations:OpenClaw:Token"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
];
}
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
];
}
}
+209
View File
@@ -0,0 +1,209 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class DashboardService(
IOpenClawGatewayClient gateway,
ITaskService taskService,
ILogger<DashboardService> logger) : IDashboardService
{
public async Task<DashboardStatus> GetStatusAsync()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return [];
}
}
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
{
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
if (!string.IsNullOrWhiteSpace(agentFilter))
{
entries = entries
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
return entries;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return [];
}
}
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
{
try
{
return await gateway.SendChatMessageAsync(agentId, message);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
}
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
{
try
{
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
return messages
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard messages fetch failed");
return [];
}
}
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
{
try
{
var cronTask = gateway.GetQueueAsync();
var tasksTask = taskService.GetOpenAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var merged = new List<QueueItem>(cronTask.Result);
foreach (var t in tasksTask.Result)
{
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
}
return merged
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return [];
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
{
var ok = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
}
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
{
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
var result = await taskService.CompleteViaQueueAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
};
}
var deleted = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
}
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
{
if (!id.StartsWith("task-"))
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
var result = await taskService.CyclePriorityAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
};
}
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
{
try
{
return await gateway.GetAgentModelAsync(agentId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
return null;
}
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
try
{
return await gateway.SetAgentModelAsync(agentId, model);
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
return false;
}
}
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
{
try
{
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
return [];
}
}
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
{
"high" or "critical" or "urgent" => "high",
"low" or "minor" => "low",
_ => "medium"
};
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
{
["high"] = 0, ["medium"] = 1, ["low"] = 2
};
}
+75
View File
@@ -0,0 +1,75 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class DocService : IDocService
{
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
private static readonly string[] SearchRoots =
[
"/mnt/workspace-iris",
"/home/node/.openclaw/workspace/nexus"
];
private static readonly (string Dir, string Category)[] ScanDirectories =
[
("/mnt/workspace-iris/nexus-phases", "phases"),
("/mnt/workspace-iris/skills", "skills"),
("/mnt/workspace-iris", "workspace"),
("/home/node/.openclaw/workspace/nexus", "nexus"),
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
];
public IReadOnlyList<DocFileInfo> GetAll()
{
var results = new List<DocFileInfo>();
foreach (var (dir, category) in ScanDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext)) continue;
var fi = new FileInfo(file);
results.Add(new DocFileInfo(
fi.Name,
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
category,
ext.Replace(".", ""),
fi.Length,
fi.LastWriteTimeUtc));
}
}
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
}
public async Task<DocFileContent?> GetFileAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string? resolvedPath = null;
foreach (var root in SearchRoots)
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return null;
var content = await File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
var relativePath = resolvedPath
.Replace("/mnt/workspace-iris/", "")
.Replace("/home/node/.openclaw/workspace/nexus/", "");
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
public interface IAgentConfigService
{
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+9
View File
@@ -0,0 +1,9 @@
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public interface ICalendarService
{
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
}
+25
View File
@@ -0,0 +1,25 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
public interface IDashboardService
{
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
List<ModelOption> GetAvailableModels();
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record DocFileInfo(
string Name,
string Path,
string Category,
string Type,
long Size,
DateTime ModifiedAt);
public sealed record DocFileContent(
string Name,
string Path,
string Content,
long Size,
DateTime ModifiedAt);
public interface IDocService
{
IReadOnlyList<DocFileInfo> GetAll();
Task<DocFileContent?> GetFileAsync(string path);
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record IncidentSummary(
string Name,
string Title,
string? Date,
string Severity,
string Excerpt,
long Size);
public sealed record IncidentDetail(
string Name,
string Title,
string? Date,
string Content,
long Size);
public interface IIncidentService
{
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
Task<IncidentDetail?> GetByNameAsync(string name);
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
public interface IMemoryService
{
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
Task<MemoryFileContent?> GetFileAsync(string name);
}
@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawGatewayClient
{
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
List<ModelOption> GetAvailableModels();
}
+6
View File
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IOperationsService
{
Task<object> GetSnapshotAsync(CancellationToken ct = default);
}
+17
View File
@@ -0,0 +1,17 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
public interface IProjectService
{
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
}
+29
View File
@@ -0,0 +1,29 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
public interface ITaskService
{
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
}
+19
View File
@@ -0,0 +1,19 @@
using Nexus.Api.Data;
namespace Nexus.Api.Services;
public sealed record TeamMember(
string Id,
string Name,
string Role,
string Model,
OperationalStatus Status,
DateTimeOffset? LastSeen,
string? Workspace,
string? Description,
string Identity);
public interface ITeamService
{
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
}
+89
View File
@@ -0,0 +1,89 @@
using Nexus.Api.Helpers;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public sealed partial class IncidentService : IIncidentService
{
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
{
if (!Directory.Exists(BasePath))
return Array.Empty<IncidentSummary>();
var incidents = new List<IncidentSummary>();
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await File.ReadAllTextAsync(file);
var title = ExtractTitle(name, content);
var date = ExtractDate(name);
var severity = ExtractSeverity(content);
var excerpt = ExtractExcerpt(content);
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
}
return incidents;
}
public async Task<IncidentDetail?> GetByNameAsync(string name)
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
return null;
if (!File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(BasePath, name + ".md");
if (!File.Exists(filePath!))
return null;
}
var content = await File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
var date = ExtractDate(fileName);
return new IncidentDetail(fileName, title, date, content, fi.Length);
}
private static string ExtractTitle(string name, string content)
{
var match = TitleRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : name;
}
private static string? ExtractDate(string fileName)
{
var match = DateRegex().Match(fileName);
return match.Success ? match.Groups[1].Value : null;
}
private static string ExtractSeverity(string content)
{
var match = SeverityRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
}
private static string ExtractExcerpt(string content)
{
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
}
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
private static partial Regex TitleRegex();
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
private static partial Regex DateRegex();
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
private static partial Regex SeverityRegex();
}
+100
View File
@@ -0,0 +1,100 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class MemoryService : IMemoryService
{
private const string BasePath = "/mnt/workspace-iris/memory";
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
private const int MaxFileSize = 1_000_000;
private const int MaxFiles = 50;
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
{
var files = new List<MemoryFileInfo>();
if (File.Exists(LongTermPath))
{
var fi = new FileInfo(LongTermPath);
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
}
if (Directory.Exists(BasePath))
{
var memFiles = Directory.GetFiles(BasePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new MemoryFileInfo(
f.Name,
f.FullName.Replace(BasePath, "").TrimStart('/'),
f.Length,
f.LastWriteTimeUtc));
files.AddRange(memFiles);
}
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
}
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
{
var results = new List<MemorySearchResult>();
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
{
var fi = new FileInfo(file);
if (fi.Length > MaxFileSize) continue;
var content = await File.ReadAllTextAsync(file);
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Add(new MemorySearchResult(
Path.GetFileName(file),
file.Replace(BasePath, "").TrimStart('/'),
excerpt,
fi.Length));
}
}
await SearchDir(BasePath);
if (File.Exists(LongTermPath))
{
var content = await File.ReadAllTextAsync(LongTermPath);
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
}
}
return results;
}
public async Task<MemoryFileContent?> GetFileAsync(string name)
{
string? filePath;
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
{
filePath = LongTermPath;
}
else
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
return null;
}
if (!File.Exists(filePath!))
return null;
var content = await File.ReadAllTextAsync(filePath!);
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
}
}
+54 -5
View File
@@ -6,7 +6,7 @@ using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -202,7 +202,12 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Tags: tags,
Progress: progress,
Workload: workload,
Goal: goal
Goal: goal,
RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status),
Elapsed: FormatElapsed(status),
Think: null,
Next: DeriveNext(isActive, currentTask)
));
}
return agents;
@@ -415,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
if (toolResult is null)
return result;
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
var json = toolResult.ToJsonString();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
@@ -840,8 +845,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Look for "## Oberstes Prinzip" as second choice
inRoleSection = false;
reader = new StringReader(soul);
while ((line = reader.ReadLine()) is not null)
using var reader2 = new StringReader(soul);
while ((line = reader2.ReadLine()) is not null)
{
var trimmed = line.Trim();
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
@@ -1060,6 +1065,50 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
return slash > 0 ? modelId[..slash] : "unknown";
}
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "badge-purple",
"programmer" or "developer" => "badge-blue",
"reviewer" => "badge-amber",
"architekt" => "badge-cyan",
"executor" => "badge-rose",
"researcher" => "badge-green",
_ => "badge-slate"
};
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
{
if (!isActive) return "Bereit";
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
return statusText switch
{
"thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert",
_ => "Arbeitet"
};
}
private static string? FormatElapsed(JsonNode? status)
{
var lastActivity = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>();
if (lastActivity is null) return null;
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
var diff = DateTimeOffset.UtcNow - ts;
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
return $"{(int)diff.TotalDays}d";
}
private static string DeriveNext(bool isActive, string? currentTask)
{
if (!isActive) return "Standby";
if (!string.IsNullOrWhiteSpace(currentTask) && currentTask != "Working...")
return currentTask.Length > 60 ? currentTask[..60] + "…" : currentTask;
return "Aufgabe ausführen";
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Chief of Staff",
+62
View File
@@ -0,0 +1,62 @@
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class OperationsService(
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : IOperationsService
{
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
{
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
var projectsTask = projectRepo.GetAllAsync(ct);
var tasksTask = taskRepo.GetAllAsync(ct);
var activityTask = activityRepo.GetRecentAsync(20, ct);
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
var tasks = tasksTask.Result;
var projects = projectsTask.Result;
var agents = agentsTask.Result;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = runtimeTask.Result;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
return new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
},
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
};
}
}
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class ProjectService(
IProjectRepository projectRepo,
IActivityRepository activityRepo) : IProjectService
{
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
=> await projectRepo.GetAllAsync(ct);
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await projectRepo.GetByIdAsync(id, ct);
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
var project = new Project
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return null;
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return project;
}
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
if (await projectRepo.HasTasksAsync(id, ct))
{
project.Status = OperationalStatus.Offline;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
await projectRepo.DeleteAsync(project, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
}
}
+191
View File
@@ -0,0 +1,191 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ITaskService
{
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
return task;
}
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(request.Title))
task.Title = request.Title.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
task.Priority = request.Priority.Trim();
if (request.ProjectId.HasValue)
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = assignedTo?.Trim()
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
{
if (!TaskStateHelper.IsValidState(status))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.Priority = task.Priority.ToLowerInvariant() switch
{
"high" => "Medium",
"medium" => "Low",
"low" => "High",
_ => "Medium"
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Nexus.Api.Services;
public sealed class TeamService(IAgentService agentService) : ITeamService
{
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
{
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<TeamMember>(agents.Count);
foreach (var agent in agents)
{
var identity = await ReadIdentityAsync(agent.Workspace, ct);
team.Add(new TeamMember(
agent.Id, agent.Name, agent.Role, agent.Model,
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity));
}
return team;
}
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
return string.Empty;
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (!File.Exists(identityFile))
return string.Empty;
var content = await File.ReadAllTextAsync(identityFile, ct);
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
}
}
+4 -2
View File
@@ -53,7 +53,7 @@ services:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -87,7 +87,9 @@ services:
window: 120s
ports:
- "127.0.0.1:18880:80"
depends_on: [api]
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s
File diff suppressed because it is too large Load Diff
+205 -386
View File
@@ -1,18 +1,5 @@
<script setup lang="ts">
/**
* IrisChat — Rechte Seitenleiste (Rail) im V2 Dashboard
*
* Container: 368px breit, border-left 1px var(--line), flex column
*
* Props:
* messages ChatMessage[]
* isThinking zeigt "thinking…" Indicator an
*
* Emits:
* send(text) Nachricht absenden
*/
import { ref, computed, nextTick, watch } from 'vue'
import { ref, nextTick, watch } from 'vue'
import { icons } from '../../../composables/icons'
import type { ChatMessage } from './types'
@@ -26,10 +13,8 @@ const emit = defineEmits<{
send: [text: string]
}>()
/* ── Input ────────────────────────────────────────── */
const inputText = ref('')
const msgContainer = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
function handleSend() {
const text = inputText.value.trim()
@@ -45,439 +30,273 @@ function onKeydown(e: KeyboardEvent) {
}
}
/* ── Reversed messages (newest first in DOM for column-reverse) ── */
const reversedMessages = computed(() => [...props.messages].reverse())
/* ── Auto-scroll: column-reverse means scrollTop=0 = bottom (newest) ── */
watch(
() => props.messages.length,
() => {
nextTick(() => {
if (msgContainer.value) {
msgContainer.value.scrollTop = 0
}
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
})
}
)
</script>
<template>
<div class="irischat">
<section class="iris-panel">
<!-- Header -->
<div class="chat-header">
<div class="chat-header-left">
<span class="header-icon" v-html="icons.bot || ''"></span>
<div class="header-text">
<span class="header-title">Live-Orchestrierung</span>
<span class="header-subtitle">Iris Chat</span>
<div class="iris-head">
<div class="iris-av" v-html="icons.bot || ''"></div>
<div>
<div class="iris-name">Iris</div>
<div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
</div>
</div>
<button class="ask-btn" type="button" @click="inputRef?.focus()">
<span class="ask-icon" v-html="icons.spark || ''"></span>
Ask Iris
</button>
<button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
</div>
<!-- Messages (flex column-reverse neueste unten) -->
<div ref="msgContainer" class="messages">
<!-- Error Banner -->
<div v-if="error" class="chat-error">
<span class="error-icon"></span>
<span>Chat unavailable: {{ error }}</span>
</div>
<!-- Chat Scroll -->
<div ref="scrollEl" class="chat-scroll">
<div v-if="error" class="chat-msg-info error"> {{ error }}</div>
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
<!-- Thinking Indicator -->
<div v-if="isThinking" class="thinking-indicator">
<span class="thinking-dots">
<span class="dot-1"></span>
<span class="dot-2"></span>
<span class="dot-3"></span>
</span>
<span class="thinking-text">thinking</span>
</div>
<!-- Empty State -->
<div v-if="!messages.length && !isThinking" class="chat-empty">
<span class="empty-text">No messages yet. Ask Iris something.</span>
</div>
<!-- Messages (reverse order newest first in DOM, column-reverse flips) -->
<template v-for="(msg, i) in reversedMessages" :key="i">
<!-- Iris Bubble -->
<div v-if="msg.sender === 'iris'" class="bubble iris-bubble">
<div class="bubble-text">{{ msg.text }}</div>
<!-- Tool-Call-Indikator -->
<div v-if="msg.tool" class="tool-indicator">
<span class="tool-icon" v-html="icons.search || ''"></span>
<span class="tool-label">{{ msg.tool }}</span>
</div>
<div class="bubble-meta">{{ msg.ts }}</div>
</div>
<!-- User Bubble -->
<div v-else class="bubble user-bubble">
<div class="bubble-text">{{ msg.text }}</div>
<div class="bubble-meta">{{ msg.ts }}</div>
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
<template v-if="msg.sender === 'iris'">
<div class="bubble iris">{{ msg.text }}</div>
<div v-if="msg.tool" class="tool">
<span v-html="icons.doc || ''"></span>{{ msg.tool }}
</div>
</template>
<div v-else class="bubble me">{{ msg.text }}</div>
</div>
<!-- Input Area -->
<div class="chat-input-area">
<div class="input-wrap">
<div v-if="isThinking" class="chat-row">
<div class="bubble iris"><span class="caret"></span></div>
</div>
</div>
<!-- Input -->
<div class="chat-in">
<input
ref="inputRef"
v-model="inputText"
class="chat-input"
type="text"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button
class="send-btn"
type="button"
:disabled="!inputText.trim()"
@click="handleSend"
:aria-label="'Send message'"
>
<span v-html="icons.send || ''"></span>
</button>
</div>
</div>
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
</div>
</section>
</template>
<style scoped>
.irischat {
width: 368px;
flex: 0 0 368px;
align-self: stretch;
.iris-panel {
width: var(--rail-w, 360px);
flex: 0 0 var(--rail-w, 360px);
display: flex;
flex-direction: column;
border-left: 1px solid var(--line);
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
min-height: 0;
background: linear-gradient(180deg, rgba(20,17,48,.6), rgba(12,10,30,.6));
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
overflow: hidden;
}
/* ── Header ───────────────────────────────────────── */
.chat-header {
/* ── Header ─────────────────────────────────── */
.iris-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.chat-header-left {
display: flex;
align-items: center;
gap: 10px;
.iris-av {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad);
display: grid;
place-items: center;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.header-icon :deep(svg) {
width: 20px;
height: 20px;
color: var(--a-mid);
.iris-av :deep(svg) {
width: 18px;
height: 18px;
color: #fff;
}
.header-text {
display: flex;
flex-direction: column;
}
.header-title {
.iris-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
line-height: 1.3;
line-height: 1.2;
}
.header-subtitle {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
color: var(--tx-3);
line-height: 1.3;
}
.ask-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 29px;
padding: 0 14px;
border-radius: 8px;
border: none;
background: var(--grad);
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
white-space: nowrap;
}
.ask-btn:hover {
filter: brightness(1.1);
}
.ask-icon :deep(svg) {
width: 14px;
height: 14px;
}
/* ── Messages ─────────────────────────────────────── */
.messages {
flex: 1;
display: flex;
flex-direction: column-reverse;
overflow-y: auto;
padding: 12px;
gap: 10px;
min-height: 0;
}
.messages::-webkit-scrollbar {
width: 6px;
}
.messages::-webkit-scrollbar-thumb {
background: rgba(124, 108, 255, 0.22);
border-radius: 6px;
border: 1px solid transparent;
background-clip: padding-box;
}
.messages::-webkit-scrollbar-thumb:hover {
background: rgba(124, 108, 255, 0.4);
background-clip: padding-box;
}
.messages::-webkit-scrollbar-track {
background: transparent;
}
/* ── Bubbles ──────────────────────────────────────── */
.bubble {
padding: 10px 13px;
max-width: 86%;
animation: bubble-in 0.2s ease-out;
}
@keyframes bubble-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.iris-bubble {
align-self: flex-start;
background: rgba(124, 108, 255, 0.14);
border-left: 2px solid var(--a-mid);
border-radius: 0 10px 10px 10px;
}
.user-bubble {
align-self: flex-end;
background: rgba(255, 255, 255, 0.06);
border-right: 2px solid var(--tx-3);
border-radius: 10px 0 10px 10px;
}
.bubble-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
line-height: 1.6;
color: var(--tx);
white-space: pre-wrap;
word-wrap: break-word;
}
.bubble-meta {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3);
margin-top: 4px;
font-variant-numeric: tabular-nums;
}
/* ── Tool-Call-Indikator ──────────────────────────── */
.tool-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding: 3px 9px;
border-radius: 6px;
background: rgba(52, 214, 245, 0.10);
border: 1px solid rgba(52, 214, 245, 0.18);
}
.tool-icon :deep(svg) {
width: 11px;
height: 11px;
color: var(--st-think);
flex: 0 0 auto;
}
.tool-label {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--st-think);
}
/* ── Error Banner ─────────────────────────────────── */
.chat-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 13px;
background: rgba(251, 113, 133, 0.12);
border: 1px solid rgba(251, 113, 133, 0.25);
border-radius: 10px;
font-family: 'Manrope', sans-serif;
.iris-sub {
font-size: 11px;
color: #fda4b0;
}
.error-icon {
flex: 0 0 auto;
font-size: 14px;
}
/* ── Empty State ──────────────────────────────────── */
.chat-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.chat-empty .empty-text {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
margin-top: 1px;
}
/* ── Thinking Indicator ────────────────────────────── */
.thinking-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
.online {
color: var(--st-work);
}
.thinking-dots {
display: flex;
gap: 2px;
font-size: 6px;
color: var(--a-mid);
}
.thinking-dots span {
animation: think-pop 1.2s ease-in-out infinite;
}
.thinking-dots .dot-2 {
animation-delay: 0.2s;
}
.thinking-dots .dot-3 {
animation-delay: 0.4s;
}
@keyframes think-pop {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.7);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.thinking-text {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
font-style: italic;
}
/* ── Input Area ───────────────────────────────────── */
.chat-input-area {
flex: 0 0 auto;
padding: 10px 12px 12px;
border-top: 1px solid var(--line);
}
.input-wrap {
display: flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 8px 0 13px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--line);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input-wrap:focus-within {
border-color: var(--line-3);
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
}
.chat-input {
flex: 1;
.expand-btn {
margin-left: auto;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
border: none;
outline: none;
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx);
line-height: 1.4;
}
.chat-input::placeholder {
color: var(--tx-3);
}
.send-btn {
flex: 0 0 32px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background: var(--grad);
color: #fff;
color: var(--tx-2);
cursor: pointer;
transition: filter 0.15s, opacity 0.15s;
display: grid;
place-items: center;
transition: background .15s, color .15s;
flex: 0 0 auto;
}
.send-btn:disabled {
opacity: 0.35;
cursor: default;
.expand-btn:hover {
background: rgba(124,108,255,.10);
color: var(--tx);
}
.send-btn:not(:disabled):hover {
filter: brightness(1.1);
}
.send-btn :deep(svg) {
.expand-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Messages ────────────────────────────────── */
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.chat-scroll::-webkit-scrollbar { width: 6px; }
.chat-scroll::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 6px;
}
.chat-scroll::-webkit-scrollbar-track { background: transparent; }
.chat-msg-info {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
text-align: center;
padding: 24px 0;
}
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
.chat-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.bubble {
max-width: 84%;
padding: 10px 13px;
border-radius: 14px;
font-family: 'Manrope', sans-serif;
font-size: 13px;
line-height: 1.5;
}
.bubble.iris {
background: rgba(124,108,255,.12);
border: 1px solid var(--line-2);
border-bottom-left-radius: 5px;
color: var(--tx);
}
.bubble.me {
background: var(--grad);
color: #fff;
border-bottom-right-radius: 5px;
margin-left: auto;
box-shadow: var(--glow-purple);
}
.tool {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--st-think);
padding-left: 4px;
}
.tool :deep(svg) {
width: 12px;
height: 12px;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
/* ── Input ───────────────────────────────────── */
.chat-in {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
gap: 9px;
align-items: center;
flex: 0 0 auto;
}
.chat-in input {
flex: 1;
height: 40px;
border-radius: 11px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
color: var(--tx);
padding: 0 14px;
font-family: 'Manrope', sans-serif;
font-size: 13px;
outline: none;
transition: border-color .15s;
}
.chat-in input::placeholder { color: var(--tx-3); }
.chat-in input:focus { border-color: var(--line-3); }
.send {
width: 40px;
height: 40px;
border-radius: 11px;
border: none;
background: var(--grad);
display: grid;
place-items: center;
cursor: pointer;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
transition: filter .15s;
}
.send:hover { filter: brightness(1.1); }
.send :deep(svg) {
width: 17px;
height: 17px;
color: #fff;
}
</style>
@@ -1,11 +1,4 @@
<script setup lang="ts">
/**
* TaskStrip — Untere Leiste im V2 Dashboard Stage
*
* Props:
* tasks TaskItem[]
*/
import type { TaskItem } from './types'
defineProps<{
@@ -13,184 +6,130 @@ defineProps<{
loading?: boolean
error?: string | null
}>()
function prioLabel(p: TaskItem['priority']): string {
return p === 'high' ? 'P0' : p === 'medium' ? 'P1' : 'P2'
}
function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
}
function dotClass(s: TaskItem['status']): string {
return s === 'active' ? 'work' : s === 'blocked' ? 'block' : 'queue'
}
function statusLabel(s: TaskItem['status']): string {
return s === 'active' ? 'Läuft' : s === 'blocked' ? 'Blocker' : 'Queue'
}
</script>
<template>
<div class="taskstrip v2-scroll">
<!-- Loading skeleton -->
<div class="tstrip">
<template v-if="loading">
<div v-for="n in 3" :key="'sk-' + n" class="taskcard skeleton" />
<div v-for="n in 4" :key="n" class="tcard skeleton"></div>
</template>
<!-- Error -->
<div v-else-if="error" class="task-error">
<span class="error-icon"></span> {{ error }}
</div>
<div v-else-if="error" class="tstrip-msg"> {{ error }}</div>
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
<!-- Empty -->
<div v-else-if="!tasks.length" class="task-empty">
No active tasks
</div>
<!-- Tasks -->
<template v-else>
<div
v-for="task in tasks"
v-for="task in tasks.slice(0, 4)"
:key="task.id"
class="taskcard"
:class="`task-${task.status}`"
class="tcard"
:class="{ block: task.status === 'blocked' }"
>
<!-- Priority Badge -->
<span class="prio-badge" :class="`prio-${task.priority}`">
{{ task.priority === 'high' ? 'P0' : task.priority === 'medium' ? 'P1' : 'P2' }}
<div class="tcard-row">
<span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
{{ prioLabel(task.priority) }}
</span>
<!-- Title -->
<div class="task-title">{{ task.title }}</div>
<!-- Agent -->
<div class="task-agent">{{ task.agent }}</div>
<!-- Progress Bar -->
<div class="task-progress">
<div class="bar-track">
<div
class="bar-fill"
:style="{ width: task.progress + '%' }"
></div>
</div>
<span class="dot" :class="dotClass(task.status)"></span>
<span class="stl">{{ statusLabel(task.status) }}</span>
</div>
<div class="tt">{{ task.title }}</div>
<div class="ow">{{ task.agent }}</div>
</div>
</template>
</div>
</template>
<style scoped>
.taskstrip {
.tstrip {
display: flex;
flex-direction: row;
gap: 10px;
padding: 0 16px 14px;
overflow-x: auto;
min-height: 0;
overflow: hidden;
flex: 0 0 auto;
}
/* ── Task Card ────────────────────────────────────── */
.taskcard {
min-width: 196px;
max-width: 220px;
flex: 0 0 auto;
.tcard {
flex: 1;
min-width: 0;
padding: 11px 13px;
border-radius: 12px;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 12px 13px;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
transition: border-color 0.15s, background 0.15s;
}
/* ── Status Variants ──────────────────────────────── */
.task-active {
border-left: 2px solid var(--st-work);
background: rgba(61, 220, 151, 0.04);
.tcard.block {
border-color: rgba(251,113,133,.35);
background: rgba(251,113,133,.07);
}
.task-pending {
border-left: 2px solid var(--st-think);
background: rgba(52, 214, 245, 0.04);
.tcard-row {
display: flex;
align-items: center;
gap: 7px;
}
.task-blocked {
border-left: 2px solid var(--st-block);
background: rgba(255, 106, 106, 0.04);
}
/* ── Priority Badge ───────────────────────────────── */
.prio-badge {
display: inline-block;
align-self: flex-start;
.pr {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 20px;
line-height: 1.5;
padding: 1px 6px;
border-radius: 5px;
flex: 0 0 auto;
}
.prio-high {
background: rgba(255, 106, 106, 0.18);
color: var(--st-block);
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.prio-medium {
background: rgba(124, 108, 255, 0.14);
color: var(--a-mid);
}
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
.dot.queue { background: var(--st-queue); }
.dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
.dot.idle { background: var(--st-idle); }
.prio-low {
background: rgba(255, 255, 255, 0.06);
.stl {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
/* ── Title ─────────────────────────────────────────── */
.task-title {
font-family: 'Manrope', sans-serif;
.tt {
font-size: 12px;
font-weight: 600;
margin-top: 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Agent ─────────────────────────────────────────── */
.task-agent {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
.ow {
font-size: 10.5px;
color: var(--tx-3);
font-variant-numeric: tabular-nums;
margin-top: 5px;
}
/* ── Progress Bar ──────────────────────────────────── */
.task-progress {
margin-top: 2px;
}
.bar-track {
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.4s ease;
}
/* Status-specific bar colors */
.task-active .bar-fill {
background: var(--grad);
}
.task-pending .bar-fill {
background: var(--grad);
opacity: 0.45;
}
.task-blocked .bar-fill {
background: var(--st-block);
opacity: 0.55;
}
/* ── Skeleton ─────────────────────────────────── */
.taskcard.skeleton {
height: 98px;
.skeleton {
height: 78px;
background: var(--glass);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@@ -200,26 +139,10 @@ defineProps<{
50% { opacity: 0.8; }
}
/* ── Error ────────────────────────────────────── */
.task-error {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: #fda4b0;
padding: 12px;
white-space: nowrap;
}
.error-icon { flex: 0 0 auto; font-size: 14px; }
/* ── Empty ────────────────────────────────────── */
.task-empty {
.tstrip-msg {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
font-style: italic;
padding: 12px;
white-space: nowrap;
}
+12 -1
View File
@@ -26,13 +26,24 @@ export interface ThinkingItem {
ts: string
}
/** Dashboard view-model for an agent detail modal (distinct from types/agent.ts AgentDetail) */
/** Dashboard view-model for an agent detail modal */
export interface AgentDetailData {
id: string
name: string
role: string
roleBadge: string
model: string
status: 'work' | 'think' | 'idle'
statusLabel: string
task: string | null
goal: string | null
progress: number
elapsed: string
next: string
tokens: string
cost: string
think: string | null
md?: string
tokensToday: number
costToday: number
workload: number
+1 -1
View File
@@ -20,7 +20,7 @@ defineProps<{
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'Verbunden' : 'Preview' }}
{{ connected ? 'OpenClaw verbunden' : 'Preview' }}
</span>
<!-- Ask Iris Button -->
+6 -3
View File
@@ -5,9 +5,12 @@
* Sidebar (248px) + Main (flex:1, flex-column)
*/
import { RouterView } from 'vue-router'
import { useAgentStore } from '../stores/agents'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue'
const agentStore = useAgentStore()
</script>
<template>
@@ -15,7 +18,7 @@ import Topbar from '../components/layout/Topbar.vue'
<GalaxyBackground />
<Sidebar />
<main class="nexus-main">
<Topbar />
<Topbar :connected="agentStore.isConnected" />
<div class="nexus-content">
<RouterView />
</div>
@@ -43,7 +46,7 @@ import Topbar from '../components/layout/Topbar.vue'
.nexus-content {
flex: 1;
overflow-y: auto;
padding: 18px 20px;
overflow: hidden;
min-height: 0;
}
</style>
+26 -26
View File
@@ -27,6 +27,11 @@ interface DashboardAgentInfo {
progress?: number
workload?: number
goal?: string | null
roleBadge?: string
statusLabel?: string
elapsed?: string | null
think?: string | null
next?: string | null
}
interface ModelOption {
@@ -35,25 +40,6 @@ interface ModelOption {
provider: string
}
/* ── Agent Catalog (static enrichment) ────────────── */
// Type-safe catalog for static AgentNodeData fields not provided by API
interface AgentCatalogEntry {
elapsed: string;
think: string | null;
next: string;
}
const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
iris: { elapsed: '--', think: null, next: 'Standby' },
programmer: { elapsed: '--', think: null, next: 'Standby' },
developer: { elapsed: '--', think: null, next: 'Standby' },
architekt: { elapsed: '--', think: null, next: 'Standby' },
reviewer: { elapsed: '--', think: null, next: 'Standby' },
executor: { elapsed: '--', think: null, next: 'Standby' },
researcher: { elapsed: '--', think: null, next: 'Standby' },
}
/* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
@@ -78,24 +64,24 @@ function avatarFor(id: string, name: string): string {
/* ── Enrich API Agent → AgentNodeData ─────────────── */
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']!
const status = mapStatus(api.isActive, api.currentTask)
return {
id: api.id,
name: api.name,
role: api.role,
roleBadge: api.roleBadge ?? 'badge-slate',
model: api.model,
avatar: avatarFor(api.id, api.name),
status,
statusLabel: STATUS_LABELS[status],
statusLabel: api.statusLabel ?? STATUS_LABELS[status],
task: api.currentTask,
goal: api.goal ?? null,
progress: api.progress ?? 0,
elapsed: cat.elapsed ?? '--',
next: cat.next ?? 'Standby',
elapsed: api.elapsed ?? '--',
next: api.next ?? 'Standby',
tokens: '0',
cost: '0.00',
think: cat.think ?? null,
think: api.think ?? null,
}
}
@@ -142,8 +128,19 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
id: data.id,
name: data.name,
role: data.role,
roleBadge: data.roleBadge || 'badge-slate',
model: displayModel,
status: data.status === 'block' ? 'idle' : data.status,
statusLabel: data.statusLabel,
task: data.task,
goal: data.goal,
progress,
elapsed: data.elapsed || '—',
next: data.next || '—',
tokens: data.tokens || '0',
cost: data.cost || '0.00',
think: data.think,
md: data.md,
tokensToday,
costToday: costNum,
workload: progress,
@@ -163,6 +160,7 @@ export const useAgentStore = defineStore('agents', {
error: null as string | null,
selectedAgentId: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false,
}),
getters: {
@@ -211,10 +209,12 @@ export const useAgentStore = defineStore('agents', {
async fetchAgents() {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return
if (!res.ok) { this.isConnected = false; return }
const data: DashboardAgentInfo[] = await res.json()
this.agents = data.map(enrichAgent)
this.isConnected = true
} catch (err) {
this.isConnected = false
console.warn('[AgentStore] fetchAgents failed', err)
}
},
@@ -262,7 +262,7 @@ export const useAgentStore = defineStore('agents', {
this.refreshInterval = setInterval(() => {
this.fetchAgents()
this.fetchModels()
}, 30000)
}, 15000)
},
stopPolling() {
+4 -2
View File
@@ -162,7 +162,9 @@ onUnmounted(() => {
flex: 1;
display: flex;
flex-direction: row;
gap: 0;
gap: 18px;
padding: 18px 20px;
overflow: hidden;
min-height: 0;
}
@@ -171,8 +173,8 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 14px;
padding: 0 18px 0 0;
min-height: 0;
min-width: 0;
overflow: hidden;
}
</style>
+4 -5
View File
@@ -28,10 +28,9 @@ echo "[4/4] Verifikation..."
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden"
echo ""
echo "=== Fertig ==="
echo "Nexus Web: http://nexus.noveria.net:18880"
echo "Login: vmbao62@hotmail.de"
echo "Passwort: wird beim ersten Start im Container-Log ausgegeben"
echo "=== Deployment abgeschlossen ==="
echo "Dashboard: https://nexus.noveria.net/dashboard"
echo "Health-API: https://nexus.noveria.net/health"
echo ""
echo "Logs: docker compose logs api | grep 'Initial owner'"
echo "Login-Informationen: docker compose logs api | grep 'Initial owner'"
echo "Status: docker compose ps"
+5 -2
View File
@@ -34,14 +34,17 @@ systemctl daemon-reload
systemctl enable --now ollama
systemctl restart ollama
for attempt in {1..30}; do
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 30 ]]; then
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
systemctl status ollama --no-pager
exit 1
fi
attempt=$((attempt + 1))
sleep 2
done
+131 -9
View File
@@ -1,18 +1,132 @@
# Deployment
> Letzte Aktualisierung: 2026-06-09
> Status: ✅ Deployment abgeschlossen
> Letzte Aktualisierung: 2026-06-13
> Status: ✅ CD v3 (Auto + Manual)
> Live-URL: https://nexus.noveria.net
## Ziel
## CD-Philosophie (v3)
Nach Phase 1 soll das Mission-Control-Board deployt und die Infrastruktur so gesetzt sein, dass Bao direkt draufkommen kann.
- **CI läuft automatisch** bei jedem Push → darf nie brechen
- **CD auto + manuell**: Automaticher Deploy nach CI-Success auf main (patch default), manueller Deploy mit voller Kontrolle via `workflow_dispatch`
- **Loop-Schutz**: Version-Bump-Commits enthalten `[skip ci]` — kein Re-Trigger der CI, kein Infinite-Loop
- **Main-Deploys** duerfen VERSION bumpen und einen Git-Tag setzen
- **Nicht-Main-Deploys** (anderer `git_ref`) deployen read-only und mutieren Git nicht
- **Rollback** als eigener Workflow, manuell triggerbar
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)
## Workflows
### Deploy (`.gitea/workflows/deploy.yaml`)
**Trigger**:
- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`)
→ Default-Parameter: patch bump, all services, main ref
- **Manuell**: Via Gitea Actions → `workflow_dispatch`
**Loop-Schutz**:
- Version-Bump-Commits enthalten `[skip ci]` → Gitea startet keine neue CI
- Auto-Deploy prüft zusätzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]`
- Beide Mechanismen zusammen verhindern Endlosschleife: CI → Deploy → Bump → CI …
**Inputs** (nur bei `workflow_dispatch`):
| Input | Typ | Default | Beschreibung |
|---|---|---|---|
| `version_bump` | choice (patch/minor/major) | patch | Version-Bump-Typ |
| `service` | string | (all) | Einzelner Service oder alle |
| `no_cache` | boolean | false | Docker-Build-Cache deaktivieren |
| `git_ref` | string | main | Branch/Tag/Commit zum Deployen |
**Ablauf**:
1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet
2. Checkout des gewählten Git-Refs
3. Wenn `git_ref = main`: Version-Bump + Git-Tag + Push
4. Wenn `git_ref != main`: VERSION nur lesen, kein Push, kein Tag
5. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
6. Code-Sync zum Host-Deploy-Pfad
7. `docker compose build && up -d --wait --force-recreate`
8. `.env`-Tempfile wird mit `shred` gelöscht
9. Health-Check (exponentieller Backoff, 6 Versuche)
10. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
11. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL
### Backup (`.gitea/workflows/backup.yaml`)
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch` (optional: Nightly-Schedule via Cron)
**Inputs**:
| Input | Typ | Default | Beschreibung |
|---|---|---|---|
| `keep_on_host` | boolean | false | Backup auch auf Host-Pfad kopieren |
| `host_backup_path` | string | `/opt/openclaw/backups` | Host-Zielpfad |
**Ablauf**:
1. Backup-ID generieren (Timestamp-basiert)
2. `docker exec nexus-postgres-1 pg_dumpall -U nexus` → gzip
3. Upload als Gitea-Artifact (90 Tage Retention, bereits komprimiert)
4. Optional: Kopie auf Host-Pfad via Docker-Volume-Mount
5. Integritäts-Check: gzip-Test + SQL-Header-Validierung
6. Backup-Summary mit Restore-Befehl
**Restore (manuell auf dem Host)**:
```bash
# Aus Gitea-Artifact herunterladen oder von Host-Pfad:
zcat nexus-backup-YYYY-MM-DDTHHMMSSZ.sql.gz | docker exec -i nexus-postgres-1 psql -U nexus -d postgres
# Danach Stack neu starten:
cd /opt/openclaw/data/openclaw/workspace/nexus
docker compose up -d --wait
```
**Nightly-Schedule aktivieren**:
In `backup.yaml` die Zeilen auskommentieren:
```yaml
schedule:
- cron: '0 3 * * *' # Jede Nacht um 03:00 UTC
```
### Rollback (`.gitea/workflows/rollback.yaml`)
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`
**Inputs**:
| Input | Typ | Beschreibung |
|---|---|---|
| `target_tag` | string | Git-Tag zum Zurückrollen (z.B. `v0.2.49`) |
| `confirm` | string | Muss exakt `ROLLBACK` sein (Safety-Gate) |
**Ablauf**:
1. Safety-Gate: Bestätigungstext muss `ROLLBACK` sein
2. Checkout des Target-Tags
3. Tag-Validierung (existiert? welcher Commit?)
4. Safe Secret Handling (gleiches Tempfile-Pattern)
5. Code-Sync des alten Stands zum Host
6. `docker compose build --no-cache && up -d --wait --force-recreate`
7. Health-Check + Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
8. Bei Fehler: Reviewer-Handoff mit manueller Rollback-Anleitung
**DB-Migration bei Rollback**: Die API führt `MigrateAsync` beim Start aus. Wenn die Migrationen des Rollback-Tags ein Prefix der aktuellen DB sind (Normalfall), läuft EF Core sie als No-Op. Wenn ein Rollback-Tag vor einer destruktiven Migration liegt, ist manuelles DB-Intervention nötig — ein Edge Case, der DevOps signalisiert wird.
## Secrets und Konfiguration
- [x] `.env.template` mit allen erforderlichen Variablen erstellt
- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt
- [x] Migration des Produktionsstacks getestet
### Secrets in Gitea
Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfiguriert:
| Secret | Verwendung |
|---|---|
| `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort |
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
| `ENV_OWNER_PASSWORD` | Owner-Account-Passwort |
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
### Safe Secret Handling (v3)
**Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem.
**Jetzt (sicher)**:
1. Secrets werden als Step-Environment aus Gitea Secrets bezogen und erst dann in `/tmp/nexus-deploy-env` (mode 600) geschrieben
2. Die Temp-Datei wird via `docker run -v` als read-only ins Compose-Environment gemountet
3. Nach Deploy/Rollback wird die Datei mit `shred -u` gelöscht
4. Das `.env` erscheint **nie** im Workspace oder auf dem Host-Deploy-Pfad
## Build-Anleitung (lokal oder in CI)
@@ -54,6 +168,13 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- [x] Nginx mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
- [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09)
- [x] CI/CD entkoppelt — Deploy darf automatisch (v3) oder manuell (2026-06-13)
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13)
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
## Verifizierung (2026-06-09)
@@ -66,6 +187,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
## Offene Arbeit
- [ ] Backup-Strategie fuer Produktionsdaten definieren
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
- [ ] `.gitignore` final pruefen
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern