Compare commits

...

10 Commits

Author SHA1 Message Date
devops f271602f31 chore: bump version to 0.2.55 [skip ci] 2026-06-14 07:29:01 +00:00
reviewer 63319e1046 fix: stream deploy env into docker cli
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:27:56 +02:00
devops b730fa1518 chore: bump version to 0.2.54 [skip ci] 2026-06-14 07:21:34 +00:00
reviewer fadb5d75c4 Fix AgentService tests fixture path
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:20:28 +02:00
reviewer 45a39d319f Fix operations CI and snapshots
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:14:24 +02:00
reviewer 5ea7aa9611 fix(ops): mount temp env directory for compose
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 08:48:23 +02:00
devops a6fabb90b0 chore: bump version to 0.2.53 [skip ci] 2026-06-14 06:46:55 +00:00
reviewer db62354c97 fix(ops): pass temp env via compose --env-file
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 08:44:42 +02:00
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
58 changed files with 3269 additions and 2340 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
+21 -8
View File
@@ -27,14 +27,13 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Restore - name: Restore
run: dotnet restore backend/Nexus.Api.csproj run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
- name: Build - name: Build
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
- name: Test - name: Test
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
continue-on-error: true
# ─── Frontend ────────────────────────────────── # ─── Frontend ──────────────────────────────────
frontend: frontend:
@@ -54,16 +53,18 @@ jobs:
corepack enable corepack enable
corepack prepare pnpm@latest --activate 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 - name: Install dependencies
run: pnpm install --no-frozen-lockfile --prefer-offline run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend
- name: Type check - name: Type check
run: pnpm exec vue-tsc --noEmit run: pnpm exec vue-tsc --noEmit
working-directory: frontend working-directory: frontend
- name: Test
run: pnpm test
working-directory: frontend
- name: Build - name: Build
run: pnpm build run: pnpm build
working-directory: frontend working-directory: frontend
@@ -79,8 +80,20 @@ jobs:
- name: Check for .env leaks - name: Check for .env leaks
run: | run: |
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then echo "🔍 Scanning for potential secrets in source code..."
echo "⚠️ Warning: Potential secrets in source code (review manually)" 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 else
echo "✅ No obvious secrets found" echo "✅ No obvious secrets found"
fi fi
+246 -112
View File
@@ -1,169 +1,274 @@
name: Deploy to Production 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 # Owner: DevOps (Architekt)
# a manual deploy is still running. The latest deploy wins. # CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version-Bump / CI Loop Prevention:
# The version-bump commit includes "[skip ci]" in its message,
# which Gitea Actions respects. The auto-trigger additionally
# checks for "[skip ci]" as a second safety layer. Together
# they guarantee that a version-bump commit does NOT trigger
# another CI → Deploy → Bump → CI cycle.
# ───────────────────────────────────────────────────────
concurrency: concurrency:
group: deploy-production group: deploy-production
cancel-in-progress: false cancel-in-progress: false
# ───────────────────────────────────────────────────
# 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: on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run: workflow_run:
workflows: ["CI - Build & Test"] workflows: ["CI - Build & Test"]
types: [completed] types: [completed]
branches: [main] branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump_version: version_bump:
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)' description: 'Version bump type'
required: false required: true
default: 'patch' default: 'patch'
type: string type: choice
options: options:
- 'patch' - patch
- 'minor' - minor
- 'major' - major
service: service:
description: 'Service to deploy (empty = all)' description: 'Service to deploy (empty = all)'
required: false required: false
default: '' default: ''
type: string type: string
no_cache: no_cache:
description: 'Disable build cache' description: 'Disable Docker build cache'
required: false required: false
default: false default: false
type: boolean type: boolean
git_ref:
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
required: false
default: 'main'
type: string
jobs: jobs:
deploy: deploy:
name: Deploy Nexus name: Deploy Nexus
runs-on: ubuntu-latest runs-on: 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: steps:
# ── Step 1: Checkout ───────────────────── # ═══════════════════════════════════════════════════
- name: Checkout latest code # Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
# ── Step 2: Version bump (race-free) ───── # ═══════════════════════════════════════════════════
# Derives current version from git tags (not VERSION file) to # Step 2: Set up Git identity
# avoid race conditions where tag exists but VERSION is stale. # ═══════════════════════════════════════════════════
# Uses --force on tag+push to handle retries after failed runs. - name: Configure Git
- name: Version Bump 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: | run: |
set -euo pipefail set -euo pipefail
# Source of truth: latest git tag # Determine bump type (auto-deploy → patch; manual → user choice)
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
CURRENT_VERSION="${TAG#v}"
echo "📦 Current version (from git tags): $CURRENT_VERSION"
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) # Read current version
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) if [ ! -f VERSION ]; then
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) echo "❌ VERSION file not found"
exit 1
fi
case "${{ inputs.bump_version }}" in CURRENT=$(cat VERSION | tr -d '[:space:]')
major) if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; echo "❌ Invalid semver in VERSION: '$CURRENT'"
minor) exit 1
MINOR=$((MINOR + 1)); PATCH=0 ;; fi
patch|*)
PATCH=$((PATCH + 1)) ;; 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 esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" # Determine git ref — auto-deploy always uses main
echo "🏷️ New version: $NEW_VERSION" DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
echo "$NEW_VERSION" > VERSION if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
echo "$NEW_VERSION" > VERSION
git add VERSION
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin HEAD:main --tags
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
echo "mutated_main=true" >> "$GITEA_OUTPUT"
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
else
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
fi
git config user.email "devops@noveria.net" # ═══════════════════════════════════════════════════
git config user.name "DevOps" # Step 4: Build .env from secrets (SAFE)
git add VERSION #
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]" # Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# --force avoids "tag already exists" when re-running after a failed attempt # the host. The temp file is deleted immediately after
git tag -f "v${NEW_VERSION}" # compose operations complete.
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags # ═══════════════════════════════════════════════════
echo "✅ Version bumped to v${NEW_VERSION}" - name: Prepare .env (secrets → temp file)
# ── Step 3: Sync code + .env to host ──────
# Creates .env from Gitea secrets in the workspace, then syncs
# everything (except .git) to the host deploy path via DIND.
- name: Sync code + .env to host
run: | run: |
# Create .env from Gitea secrets in the workspace set -euo pipefail
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline # 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_DB=nexus
POSTGRES_USER=nexus POSTGRES_USER=nexus
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${{ secrets.ENV_JWT_KEY }} JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }} OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME= OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD= 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 \ docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \ -v "${{ gitea.workspace }}:/src:ro" \
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \ -v "${DEPLOY_PATH}:/dest" \
alpine:latest \ alpine:latest \
sh -c " sh -c "
cd /src && \ cd /src && \
find . -mindepth 1 -maxdepth 1 \ find . -mindepth 1 -maxdepth 1 \
! -name .git \ ! -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 ───────────────── echo "✅ Code synced to ${DEPLOY_PATH}"
- name: Set up Docker Buildx
run: docker buildx create --use 2>/dev/null || true
# ── 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 - name: Build & Deploy
run: | 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="" BUILD_ARGS=""
if [ "${{ inputs.no_cache }}" = "true" ]; then if [ "$NO_CACHE" = "true" ]; then
BUILD_ARGS="--no-cache" BUILD_ARGS="--no-cache"
fi fi
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
docker run --rm \ docker run --rm \
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \ -w /workspace/nexus \
-i \
docker:cli \ docker:cli \
sh -c " sh -c "
set -e set -e
if [ -n '${{ inputs.service }}' ]; then trap 'rm -f /tmp/nexus-deploy-env' EXIT
echo '🚀 Deploying service: ${{ inputs.service }}' cat > /tmp/nexus-deploy-env
docker compose build ${BUILD_ARGS} ${{ inputs.service }} if [ -n '${SERVICE_ARG}' ]; then
docker compose up -d --wait --force-recreate ${{ inputs.service }} echo '🚀 Deploying service: ${SERVICE_ARG}'
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
else else
echo '🚀 Deploying all services' echo '🚀 Deploying all services'
docker compose build ${BUILD_ARGS} docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
docker compose up -d --wait --force-recreate docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
fi fi
" " < "${ENV_TMPFILE}"
# ── Step 6: Health Check (backoff) ──────── echo "✅ Docker compose up completed"
# 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. # 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 - name: Health Check
run: | run: |
echo "🏥 Health check..." echo "🏥 Health check..."
@@ -186,11 +291,10 @@ jobs:
echo "❌ Health check failed after $MAX attempts" echo "❌ Health check failed after $MAX attempts"
exit 1 exit 1
# ── Step 7: Smoke test (multi-endpoint) ─── # ═══════════════════════════════════════════════════
# Tests multiple endpoints to catch partial failures. # Step 9: Smoke Test
# Why: a single /dashboard check can miss backend-only outages; # ═══════════════════════════════════════════════════
# /health tests the API + database + runtime status. - name: Smoke Test
- name: Verify (smoke test)
run: | run: |
echo "🔍 Smoke test..." echo "🔍 Smoke test..."
PASS=0 PASS=0
@@ -199,7 +303,8 @@ jobs:
check() { check() {
local path="$1" label="$2" expected="${3:-200}" 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}" printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then if [ "$code" = "$expected" ]; then
echo " ✅" echo " ✅"
@@ -210,8 +315,9 @@ jobs:
fi fi
} }
check "/dashboard" "Dashboard" 200 check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200 check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
@@ -219,25 +325,53 @@ jobs:
echo "❌ Smoke test failed!" echo "❌ Smoke test failed!"
exit 1 exit 1
fi 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. # Step 10: Deployment Summary
# 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: Deployment Summary
- name: Rollback hint 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() if: failure()
run: | run: |
echo "" echo ""
echo "🔙 ─── Rollback Instructions ─── 🔙" echo "─────────────────────────────────────────────────────────────┐"
echo "" echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo " # 1. Checkout previous version:" echo "├─────────────────────────────────────────────────────────────┤"
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')" echo "│ │"
echo "" echo "│ Version: v${{ steps.version.outputs.version }}"
echo " # 2. Redeploy:" echo " Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo " cd /opt/openclaw/data/openclaw/workspace/nexus" echo "│ │"
echo " docker compose up -d --force-recreate" echo " → DevOps (Architekt) analysiert den Fehler │"
echo "" echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo " # 3. Or trigger rollback via Gitea:" echo "│ → DevOps verifiziert mit neuem Deploy │"
echo " Trigger 'Deploy to Production' workflow with the previous tag" echo " "
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 "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
echo "✅ Rollback redeploy completed"
# ═══════════════════════════════════════════════════
# Step 6: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 7: Health Check
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check after rollback..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 8: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test after rollback..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
# ═══════════════════════════════════════════════════
# Step 9: Rollback Summary
# ═══════════════════════════════════════════════════
- name: Rollback Summary
if: always()
run: |
echo ""
echo "═══════════════════════════════════════"
echo " 🔙 Rollback Summary"
echo "═══════════════════════════════════════"
echo " Rolled to: ${{ inputs.target_tag }}"
echo " Triggered: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 10: Failure → Reviewer Handoff
# ═══════════════════════════════════════════════════
- name: 🔴 Rollback Failed — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Target: ${{ inputs.target_tag }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /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 *.bak
# pnpm (lockfile IS committed for reproducible CI builds) # 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 Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
adapter-backed agent runtime, not a dependency of the frontend or domain model. adapter-backed agent runtime, not a dependency of the frontend or domain model.
> CI/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 ## Current foundation
@@ -287,4 +291,59 @@ The configured model-routing policy is:
The Settings module reports runtime and provider state without exposing The Settings module reports runtime and provider state without exposing
credentials. 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.51 0.2.55
+52 -18
View File
@@ -11,12 +11,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentsAsync_ReturnsCorrectCount() public async Task GetAgentsAsync_ReturnsCorrectCount()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -27,12 +23,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Iris_ReturnsOrchestrator() public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -44,18 +36,60 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Unknown_ReturnsNull() public async Task GetAgentAsync_Unknown_ReturnsNull()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None); var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
Assert.Null(agent); Assert.Null(agent);
} }
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath
})
.Build();
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{
"id": "iris",
"name": "iris"
},
{
"id": "programmer",
"name": "programmer"
},
{
"id": "reviewer",
"name": "reviewer"
},
{
"id": "architekt",
"name": "architekt"
}
]
}
}
""");
return path;
}
} }
public sealed class FakeRuntime : IAgentRuntime public sealed class FakeRuntime : IAgentRuntime
+143
View File
@@ -0,0 +1,143 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class OperationsSnapshotTests
{
[Fact]
public void GetSnapshot_RequiresAuthorization()
{
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
}
[Fact]
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
{
var guard = new RepositoryConcurrencyGuard();
var runtime = new SnapshotRuntimeStub();
var agentService = new SnapshotAgentServiceStub();
var projectRepo = new GuardedProjectRepository(guard);
var taskRepo = new GuardedTaskRepository(guard);
var activityRepo = new GuardedActivityRepository(guard);
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
await service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, guard.MaxConcurrentCalls);
}
}
internal sealed class RepositoryConcurrencyGuard
{
private readonly Lock sync = new();
private int currentCalls;
public int MaxConcurrentCalls { get; private set; }
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
{
lock (sync)
{
currentCalls++;
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
}
try
{
await Task.Delay(25, ct);
return value;
}
finally
{
lock (sync)
{
currentCalls--;
}
}
}
}
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
{
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<Project>
{
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
}, ct);
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
{
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<WorkTask>
{
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
}, ct);
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>
{
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct);
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotRuntimeStub : IAgentRuntime
{
public string Name => "stub";
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotAgentServiceStub : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
[
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
+14 -65
View File
@@ -1,8 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Helpers;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
@@ -15,6 +13,7 @@ public class AgentsController(
IAgentService agentService, IAgentService agentService,
IAgentRuntime runtime, IAgentRuntime runtime,
IActivityRepository activityRepo, IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
ILogger<AgentsController> logger) : ControllerBase ILogger<AgentsController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -22,8 +21,7 @@ public class AgentsController(
{ {
var agents = await agentService.GetAgentsAsync(ct); var agents = await agentService.GetAgentsAsync(ct);
return Results.Ok(agents.Select(a => new AgentListResponse( 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}")] [HttpGet("{id}")]
@@ -34,8 +32,7 @@ public class AgentsController(
return Results.Ok(new AgentDetailResponse( return Results.Ok(new AgentDetailResponse(
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
agent.SubAgents, agent.IdentityName agent.SubAgents, agent.IdentityName));
));
} }
[HttpGet("{id}/activity")] [HttpGet("{id}/activity")]
@@ -58,9 +55,7 @@ public class AgentsController(
try try
{ {
var result = await runtime.ChatAsync(message, conversationId, id, ct); var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
await activityRepo.AddAsync(new 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)); return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
} }
catch (Exception exception) catch (Exception exception)
@@ -73,79 +68,33 @@ public class AgentsController(
} }
} }
// ========== Agent Config Editor ========== // ── Config Editor ──
[HttpGet("{id}/config")] [HttpGet("{id}/config")]
public IResult GetConfig(string id) public IResult GetConfig(string id)
{ => Results.Ok(agentConfigService.GetConfigFiles(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);
}
[HttpGet("{id}/config/{fileName}")] [HttpGet("{id}/config/{fileName}")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct) public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); return file is null
? Results.NotFound()
var workspacePath = $"/mnt/workspace-{id}"; : Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
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 });
} }
[HttpPut("{id}/config/{fileName}")] [HttpPut("{id}/config/{fileName}")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{ {
if (!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) if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." }); return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024) if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
var workspacePath = $"/mnt/workspace-{id}"; var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath)) return result is null
return Results.NotFound(); ? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
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 });
} }
} }
+4 -67
View File
@@ -1,80 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/calendar")] [Route("api/v1/calendar")]
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase public class CalendarController(ICalendarService calendarService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
{ => Results.Ok(await calendarService.GetCronJobsAsync(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);
}
[HttpGet("upcoming")] [HttpGet("upcoming")]
public async Task<IResult> GetUpcoming(CancellationToken ct) public async Task<IResult> GetUpcoming(CancellationToken ct)
{ => Results.Ok(await calendarService.GetUpcomingCronJobsAsync(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);
}
} }
+56 -418
View File
@@ -1,403 +1,113 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.Models; using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/dashboard")] [Route("api/dashboard")]
public class DashboardController( public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
OpenClawGatewayClient gateway,
ITaskRepository taskRepo,
IActivityRepository activityRepo,
ILogger<DashboardController> logger)
: ControllerBase
{ {
/// <summary>
/// Gateway health + session_status + subagents count.
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
/// </summary>
[HttpGet("status")] [HttpGet("status")]
public async Task<DashboardStatus> GetStatus() public async Task<DashboardStatus> GetStatus()
{ => await dashboardService.GetStatusAsync();
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
/// <summary>
/// Returns all agents with their current status.
/// Combines sessions_list + sub_agents_list.
/// </summary>
[HttpGet("agents")] [HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents() public async Task<List<DashboardAgentInfo>> GetAgents()
{ => await dashboardService.GetAgentsAsync();
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return new List<DashboardAgentInfo>();
}
}
/// <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")] [HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations( public async Task<List<FeedEntry>> GetOperations(
[FromQuery] int limit = 20, [FromQuery] int limit = 20,
[FromQuery] string? agent = null) [FromQuery] string? agent = null)
{ => await dashboardService.GetOperationsAsync(limit, agent);
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
// 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")] [HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request) public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Message)) if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required"); return new ChatResponse(false, null, "Message is required");
try var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
{ return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
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");
}
} }
/// <summary>
/// Returns chat messages (user + assistant only, not tool messages).
/// </summary>
[HttpGet("chat/messages")] [HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages( public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey, [FromQuery] string? sessionKey,
[FromQuery] int limit = 50, [FromQuery] int limit = 50,
[FromQuery] int offset = 0) [FromQuery] int offset = 0)
{ => await dashboardService.GetMessagesAsync(sessionKey, limit, 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 new List<MessageEntry>();
}
}
/// <summary>
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
/// </summary>
[HttpGet("queue")] [HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue(CancellationToken ct) public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
{ => await dashboardService.GetQueueAsync(ct);
try
{
// Fetch cron jobs and open tasks concurrently
var cronTask = gateway.GetQueueAsync();
var tasksTask = taskRepo.GetAllAsync(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}")] [HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{ {
try var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
return result.Outcome switch
{ {
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) QueueDeleteOutcome.Deleted => NoContent(),
{ QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
var ok = await gateway.DeleteCronJobAsync(id); QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
if (!ok) QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
return StatusCode(502, new { error = "Gateway could not delete cron job" }); QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
return NoContent(); _ => StatusCode(500, new { error = "Internal error" })
} };
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();
}
// 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")] [HttpPut("queue/{id}/priority")]
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct) 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-")) QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
return 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" }),
var guidStr = id["task-".Length..]; _ => Ok(new { status = "ok", priority = result.NewPriority })
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"
};
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")] [HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id) public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
{ {
try var info = await dashboardService.GetAgentModelAsync(id);
{ return info is null
var info = await gateway.GetAgentModelAsync(id); ? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
if (info is null) : Ok(info);
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" });
}
} }
/// <summary>
/// Sets the model for a specific agent session.
/// Calls session_status with model parameter.
/// </summary>
[HttpPut("agents/{id}/model")] [HttpPut("agents/{id}/model")]
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request) public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Model)) if (string.IsNullOrWhiteSpace(request.Model))
return BadRequest(new { error = "Model is required" }); return BadRequest(new { error = "Model is required" });
try 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" });
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" });
}
} }
/// <summary>
/// Returns the most recent activity entries (assistant messages) for a specific agent.
/// </summary>
[HttpGet("agents/{id}/activity")] [HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5) public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
{ => await dashboardService.GetAgentActivityAsync(id, limit);
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>();
}
}
/// <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")] [HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels() public ActionResult<List<ModelOption>> GetAvailableModels()
{ => Ok(dashboardService.GetAvailableModels());
var models = gateway.GetAvailableModels();
return Ok(models);
}
// ========== Task Endpoints ========== // ── Task Endpoints ──
/// <summary>
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
/// </summary>
[HttpGet("tasks")] [HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct) public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{ {
try var tasks = await taskService.GetOpenAsync(ct);
{ return tasks.Select(MapToDto).ToList();
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>();
}
} }
/// <summary>
/// Creates a new task and logs an activity event.
/// </summary>
[HttpPost("tasks")] [HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> CreateTask( public async Task<ActionResult<DashboardTaskDto>> CreateTask(
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct) [FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
@@ -405,121 +115,49 @@ public class DashboardController(
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." }); return BadRequest(new { error = "Title is required." });
var task = new WorkTask var task = await taskService.CreateDashboardTaskAsync(
{ request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
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);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
} }
/// <summary>
/// Updates an existing task (title, detail, source, priority, assignedTo).
/// </summary>
[HttpPut("tasks/{id:guid}")] [HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask( public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateDashboardTaskAsync(
if (task is null) id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return NotFound(new { error = "Task not found." }); return result.Outcome switch
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
{ {
Type = "task", TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
Message = $"Task \"{task.Title}\" updated" _ => Ok(MapToDto(result.Task!))
}, ct); };
return Ok(MapToDto(task));
} }
/// <summary>
/// Deletes a task (only if status is 'Done' or 'Backlog').
/// </summary>
[HttpDelete("tasks/{id:guid}")] [HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct) public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.DeleteAsync(id, ct);
if (task is null) return result.Outcome switch
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
{ {
Type = "task", TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
Message = $"Task \"{task.Title}\" deleted" TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
}, ct); _ => NoContent()
await taskRepo.DeleteAsync(task, ct); };
return NoContent();
} }
/// <summary>
/// Changes the status of a task.
/// </summary>
[HttpPatch("tasks/{id:guid}/status")] [HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus( public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{ {
if (!TaskStateHelper.IsValidState(request.Status)) var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }); return result.Outcome switch
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
{ {
Type = "task", TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
Message = $"Task \"{task.Title}\" → {canonicalState}" TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
}, ct); _ => Ok(MapToDto(result.Task!))
};
return Ok(MapToDto(task));
} }
// ========== Helpers ==========
private static DashboardTaskDto MapToDto(WorkTask t) => new( private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
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 Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/docs")] [Route("api/v1/docs")]
public class DocsController : ControllerBase public class DocsController(IDocService docService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public IResult GetAll()
{ => Results.Ok(docService.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));
}
[HttpGet("{**path}")] [HttpGet("{**path}")]
public async Task<IResult> GetFile(string path) public async Task<IResult> GetFile(string path)
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required."); return Results.BadRequest("Path required.");
string? resolvedPath = null; var file = await docService.GetFileAsync(path);
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) return file is null ? Results.NotFound() : Results.Ok(file);
{
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 });
} }
} }
+5 -85
View File
@@ -1,100 +1,20 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
using System.Text.RegularExpressions;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/incidents")] [Route("api/v1/incidents")]
public class IncidentsController : ControllerBase public class IncidentsController(IIncidentService incidentService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await incidentService.GetAllAsync());
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);
}
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetOne(string name) public async Task<IResult> GetOne(string name)
{ {
var basePath = "/mnt/workspace-iris/memory/incidents"; var incident = await incidentService.GetByNameAsync(name);
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath)) return incident is null ? Results.NotFound() : Results.Ok(incident);
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
});
} }
} }
+7 -86
View File
@@ -1,40 +1,15 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/memory")] [Route("api/v1/memory")]
public class MemoryController : ControllerBase public class MemoryController(IMemoryService memoryService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await memoryService.GetAllAsync());
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);
}
[HttpGet("search")] [HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q) public async Task<IResult> Search([FromQuery] string q)
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
if (string.IsNullOrWhiteSpace(q) || q.Length < 2) if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters."); return Results.BadRequest("Query must be at least 2 characters.");
var basePath = "/mnt/workspace-iris/memory"; return Results.Ok(await memoryService.SearchAsync(q));
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);
} }
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetFile(string name) public async Task<IResult> GetFile(string name)
{ {
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) var file = await memoryService.GetFileAsync(name);
return Results.BadRequest("Invalid filename."); return file is null ? Results.NotFound() : Results.Ok(file);
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!) });
} }
} }
+4 -60
View File
@@ -1,71 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/operations")] [Route("api/v1/operations")]
public class OperationsController( public class OperationsController(IOperationsService operationsService) : ControllerBase
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ControllerBase
{ {
[HttpGet("snapshot")] [HttpGet("snapshot")]
[Authorize]
public async Task<IResult> GetSnapshot(CancellationToken ct) public async Task<IResult> GetSnapshot(CancellationToken ct)
{ => Results.Ok(await operationsService.GetSnapshotAsync(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 })
});
}
} }
+19 -46
View File
@@ -1,17 +1,23 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/projects")] [Route("api/v1/projects")]
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase public class ProjectsController(IProjectService projectService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) 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] [HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct) 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)) if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var project = new Project var project = await projectService.CreateAsync(request, ct);
{
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 Results.Created($"/api/v1/projects/{project.Id}", project); 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}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var project = await projectService.UpdateAsync(id, request, ct);
if (project is null) return Results.NotFound(); return project is null ? Results.NotFound() : Results.Ok(project);
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);
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct) public async Task<IResult> Delete(Guid id, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var result = await projectService.DeleteAsync(id, ct);
if (project is null) return Results.NotFound(); return result.Outcome switch
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
if (hasTasks)
{ {
project.Status = OperationalStatus.Offline; ProjectDeleteOutcome.NotFound => Results.NotFound(),
await projectRepo.UpdateAsync(project, ct); ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct); _ => Results.NoContent()
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();
} }
} }
+48 -71
View File
@@ -1,17 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/tasks")] [Route("api/v1/tasks")]
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase public class TasksController(ITaskService taskService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await taskRepo.GetAllAsync(ct)); => Results.Ok(await taskService.GetAllAsync(ct));
[HttpPost] [HttpPost]
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct) 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)) if (string.IsNullOrWhiteSpace(request.Title))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = new WorkTask var task = await taskService.CreateAsync(request, ct);
{
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 Results.Created($"/api/v1/tasks/{task.Id}", task); return Results.Created($"/api/v1/tasks/{task.Id}", task);
} }
[HttpGet("pending-approval")] [HttpGet("pending-approval")]
public async Task<IResult> GetPendingApproval(CancellationToken ct) 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 })); return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
} }
[HttpPost("{id:guid}/approve")] [HttpPost("{id:guid}/approve")]
public async Task<IResult> Approve(Guid id, CancellationToken ct) public async Task<IResult> Approve(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.ApproveAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Approval denied", title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.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 Results.Ok(task);
} }
[HttpPost("{id:guid}/reject")] [HttpPost("{id:guid}/reject")]
public async Task<IResult> Reject(Guid id, CancellationToken ct) public async Task<IResult> Reject(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.RejectAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Rejection denied", title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.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 Results.Ok(task);
} }
[HttpPatch("{id:guid}/state")] [HttpPatch("{id:guid}/state")]
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct) public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
{ {
var allowedStates = TaskStateHelper.AllStates; if (!TaskStateHelper.IsValidState(request.State))
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateStateAsync(id, request.State, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); {
await taskRepo.UpdateAsync(task, ct); TaskOperationOutcome.NotFound => Results.NotFound(),
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); _ => Results.Ok(result.Task)
return Results.Ok(task); };
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return Results.NoContent();
} }
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateAsync(id, request, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
};
}
if (!string.IsNullOrWhiteSpace(request.Title)) [HttpDelete("{id:guid}")]
task.Title = request.Title.Trim(); public async Task<IResult> Delete(Guid id, CancellationToken ct)
if (!string.IsNullOrWhiteSpace(request.Priority)) {
task.Priority = request.Priority.Trim(); var result = await taskService.DeleteAsync(id, ct);
if (request.ProjectId.HasValue) return result.Outcome switch
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; {
TaskOperationOutcome.NotFound => Results.NotFound(),
await taskRepo.UpdateAsync(task, ct); TaskOperationOutcome.InvalidState => Results.Problem(
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct); title: "Task deletion denied",
return Results.Ok(task); 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] [ApiController]
[Route("api/v1/team")] [Route("api/v1/team")]
public class TeamController(IAgentService agentService) : ControllerBase public class TeamController(ITeamService teamService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetTeam(CancellationToken ct) public async Task<IResult> GetTeam(CancellationToken ct)
{ => Results.Ok(await teamService.GetTeamAsync(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);
}
} }
+6 -1
View File
@@ -11,7 +11,12 @@ public sealed record DashboardAgentInfo(
string[] Tags, string[] Tags,
int Progress = 0, int Progress = 0,
int Workload = 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( public sealed record MessageEntry(
+11 -1
View File
@@ -112,7 +112,7 @@ builder.Services.AddHttpClient("gateway", client =>
client.Timeout = TimeSpan.FromSeconds(120); client.Timeout = TimeSpan.FromSeconds(120);
}); });
builder.Services.AddHttpClient<OpenClawGatewayClient>(client => builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{ {
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789"); ?? "http://127.0.0.1:18789");
@@ -123,6 +123,16 @@ builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
builder.Services.AddTransient<ModelRoutingService>(); builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAgentService, AgentService>(); 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 --- // --- Repositories ---
builder.Services.AddScoped<IUserRepository, UserRepository>(); 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<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default);
// Refresh token operations
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task UpdateRefreshTokenAsync(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 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) public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
=> db.SaveChangesAsync(ct); => 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) public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
{ {
var cutoff = DateTimeOffset.UtcNow.AddDays(-30); var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
.ToListAsync(ct); .ToListAsync(ct);
if (oldTokens.Count > 0) if (oldTokens.Count > 0)
{
db.RefreshTokens.RemoveRange(oldTokens); 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) 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); _logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
return null; return null;
} }
@@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(refreshToken)) return; if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken); var tokenHash = HashToken(refreshToken);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); await _users.RevokeTokenAsync(tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await _users.SaveChangesAsync(ct);
} }
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default) public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> Task.Run(async () => => _users.GetByIdAsync(userId, ct).AsTask();
{
// 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);
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) 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); 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() private static string GenerateRefreshToken()
{ {
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); 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; 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() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -202,7 +202,12 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Tags: tags, Tags: tags,
Progress: progress, Progress: progress,
Workload: workload, Workload: workload,
Goal: goal Goal: goal,
RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status),
Elapsed: FormatElapsed(status),
Think: null,
Next: DeriveNext(isActive, currentTask)
)); ));
} }
return agents; return agents;
@@ -415,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
if (toolResult is null) if (toolResult is null)
return result; 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); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; var root = doc.RootElement;
@@ -840,8 +845,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Look for "## Oberstes Prinzip" as second choice // 3. Look for "## Oberstes Prinzip" as second choice
inRoleSection = false; inRoleSection = false;
reader = new StringReader(soul); using var reader2 = new StringReader(soul);
while ((line = reader.ReadLine()) is not null) while ((line = reader2.ReadLine()) is not null)
{ {
var trimmed = line.Trim(); var trimmed = line.Trim();
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0) 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"; 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 private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{ {
"iris" => "Chief of Staff", "iris" => "Chief of Staff",
+59
View File
@@ -0,0 +1,59 @@
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);
// Repository calls share the scoped EF Core DbContext and must stay serialized.
var projects = await projectRepo.GetAllAsync(ct);
var tasks = await taskRepo.GetAllAsync(ct);
var activity = await activityRepo.GetRecentAsync(20, ct);
var agents = await agentsTask;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = await runtimeTask;
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 = activity.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));
}
}
+3 -1
View File
@@ -87,7 +87,9 @@ services:
window: 120s window: 120s
ports: ports:
- "127.0.0.1:18880:80" - "127.0.0.1:18880:80"
depends_on: [api] depends_on:
api:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s interval: 30s
File diff suppressed because it is too large Load Diff
+212 -393
View File
@@ -1,18 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
/** import { ref, nextTick, watch } from 'vue'
* 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 { icons } from '../../../composables/icons' import { icons } from '../../../composables/icons'
import type { ChatMessage } from './types' import type { ChatMessage } from './types'
@@ -26,10 +13,8 @@ const emit = defineEmits<{
send: [text: string] send: [text: string]
}>() }>()
/* ── Input ────────────────────────────────────────── */
const inputText = ref('') const inputText = ref('')
const msgContainer = ref<HTMLElement | null>(null) const scrollEl = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
function handleSend() { function handleSend() {
const text = inputText.value.trim() 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( watch(
() => props.messages.length, () => props.messages.length,
() => { () => {
nextTick(() => { nextTick(() => {
if (msgContainer.value) { if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
msgContainer.value.scrollTop = 0
}
}) })
} }
) )
</script> </script>
<template> <template>
<div class="irischat"> <section class="iris-panel">
<!-- Header --> <!-- Header -->
<div class="chat-header"> <div class="iris-head">
<div class="chat-header-left"> <div class="iris-av" v-html="icons.bot || ''"></div>
<span class="header-icon" v-html="icons.bot || ''"></span> <div>
<div class="header-text"> <div class="iris-name">Iris</div>
<span class="header-title">Live-Orchestrierung</span> <div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
<span class="header-subtitle">Iris Chat</span>
</div>
</div> </div>
<button class="ask-btn" type="button" @click="inputRef?.focus()"> <button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
<span class="ask-icon" v-html="icons.spark || ''"></span>
Ask Iris
</button>
</div> </div>
<!-- Messages (flex column-reverse neueste unten) --> <!-- Chat Scroll -->
<div ref="msgContainer" class="messages"> <div ref="scrollEl" class="chat-scroll">
<!-- Error Banner --> <div v-if="error" class="chat-msg-info error"> {{ error }}</div>
<div v-if="error" class="chat-error"> <div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
<span class="error-icon"></span>
<span>Chat unavailable: {{ error }}</span>
</div>
<!-- Thinking Indicator --> <div v-for="(msg, i) in messages" :key="i" class="chat-row">
<div v-if="isThinking" class="thinking-indicator"> <template v-if="msg.sender === 'iris'">
<span class="thinking-dots"> <div class="bubble iris">{{ msg.text }}</div>
<span class="dot-1"></span> <div v-if="msg.tool" class="tool">
<span class="dot-2"></span> <span v-html="icons.doc || ''"></span>{{ msg.tool }}
<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>
<div class="bubble-meta">{{ msg.ts }}</div> </template>
</div> <div v-else class="bubble me">{{ msg.text }}</div>
</div>
<!-- User Bubble --> <div v-if="isThinking" class="chat-row">
<div v-else class="bubble user-bubble"> <div class="bubble iris"><span class="caret"></span></div>
<div class="bubble-text">{{ msg.text }}</div>
<div class="bubble-meta">{{ msg.ts }}</div>
</div>
</template>
</div>
<!-- Input Area -->
<div class="chat-input-area">
<div class="input-wrap">
<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>
</div> </div>
</div>
<!-- Input -->
<div class="chat-in">
<input
v-model="inputText"
type="text"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
</div>
</section>
</template> </template>
<style scoped> <style scoped>
.irischat { .iris-panel {
width: 368px; width: var(--rail-w, 360px);
flex: 0 0 368px; flex: 0 0 var(--rail-w, 360px);
align-self: stretch;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-left: 1px solid var(--line); min-height: 0;
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92)); 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; overflow: hidden;
} }
/* ── Header ───────────────────────────────────────── */ /* ── Header ─────────────────────────────────── */
.chat-header { .iris-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 10px;
padding: 14px 16px; padding: 14px 16px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
flex: 0 0 auto; flex: 0 0 auto;
} }
.chat-header-left { .iris-av {
display: flex; width: 34px;
align-items: center; height: 34px;
gap: 10px; border-radius: 10px;
background: var(--grad);
display: grid;
place-items: center;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
} }
.header-icon :deep(svg) { .iris-av :deep(svg) {
width: 20px; width: 18px;
height: 20px; height: 18px;
color: var(--a-mid); color: #fff;
} }
.header-text { .iris-name {
display: flex;
flex-direction: column;
}
.header-title {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-weight: 600; font-weight: 600;
font-size: 14.5px; font-size: 14.5px;
color: var(--tx); color: var(--tx);
line-height: 1.3; line-height: 1.2;
} }
.header-subtitle { .iris-sub {
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;
font-size: 11px; 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); color: var(--tx-3);
font-style: italic; margin-top: 1px;
} }
/* ── Thinking Indicator ────────────────────────────── */ .online {
.thinking-indicator { color: var(--st-work);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
} }
.thinking-dots { .expand-btn {
display: flex; margin-left: auto;
gap: 2px; width: 34px;
font-size: 6px; height: 34px;
color: var(--a-mid); border-radius: 9px;
} border: none;
.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;
background: transparent; background: transparent;
border: none; color: var(--tx-2);
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;
cursor: pointer; 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 { .expand-btn:hover {
opacity: 0.35; background: rgba(124,108,255,.10);
cursor: default; color: var(--tx);
} }
.send-btn:not(:disabled):hover { .expand-btn :deep(svg) {
filter: brightness(1.1);
}
.send-btn :deep(svg) {
width: 16px; width: 16px;
height: 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> </style>
@@ -1,11 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* TaskStrip — Untere Leiste im V2 Dashboard Stage
*
* Props:
* tasks TaskItem[]
*/
import type { TaskItem } from './types' import type { TaskItem } from './types'
defineProps<{ defineProps<{
@@ -13,213 +6,143 @@ defineProps<{
loading?: boolean loading?: boolean
error?: string | null 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> </script>
<template> <template>
<div class="taskstrip v2-scroll"> <div class="tstrip">
<!-- Loading skeleton -->
<template v-if="loading"> <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> </template>
<!-- Error --> <div v-else-if="error" class="tstrip-msg"> {{ error }}</div>
<div v-else-if="error" class="task-error"> <div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
<span class="error-icon"></span> {{ error }}
</div>
<!-- Empty --> <template v-else>
<div v-else-if="!tasks.length" class="task-empty"> <div
No active tasks v-for="task in tasks.slice(0, 4)"
</div> :key="task.id"
class="tcard"
<!-- Tasks --> :class="{ block: task.status === 'blocked' }"
<div >
v-for="task in tasks" <div class="tcard-row">
:key="task.id" <span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
class="taskcard" {{ prioLabel(task.priority) }}
:class="`task-${task.status}`" </span>
> <span class="dot" :class="dotClass(task.status)"></span>
<!-- Priority Badge --> <span class="stl">{{ statusLabel(task.status) }}</span>
<span class="prio-badge" :class="`prio-${task.priority}`">
{{ task.priority === 'high' ? 'P0' : task.priority === 'medium' ? 'P1' : 'P2' }}
</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> </div>
<div class="tt">{{ task.title }}</div>
<div class="ow">{{ task.agent }}</div>
</div> </div>
</div> </template>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.taskstrip { .tstrip {
display: flex; display: flex;
flex-direction: row;
gap: 10px; gap: 10px;
padding: 0 16px 14px; overflow: hidden;
overflow-x: auto;
min-height: 0;
flex: 0 0 auto; flex: 0 0 auto;
} }
/* ── Task Card ────────────────────────────────────── */ .tcard {
.taskcard { flex: 1;
min-width: 196px; min-width: 0;
max-width: 220px; padding: 11px 13px;
flex: 0 0 auto; border-radius: 12px;
background: var(--glass); background: var(--glass);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--r);
padding: 12px 13px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px;
position: relative;
transition: border-color 0.15s, background 0.15s;
} }
/* ── Status Variants ──────────────────────────────── */ .tcard.block {
.task-active { border-color: rgba(251,113,133,.35);
border-left: 2px solid var(--st-work); background: rgba(251,113,133,.07);
background: rgba(61, 220, 151, 0.04);
} }
.task-pending { .tcard-row {
border-left: 2px solid var(--st-think); display: flex;
background: rgba(52, 214, 245, 0.04); align-items: center;
gap: 7px;
} }
.task-blocked { .pr {
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;
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 9px; font-size: 10px;
font-weight: 600; font-weight: 600;
padding: 1px 7px; padding: 1px 6px;
border-radius: 20px; border-radius: 5px;
line-height: 1.5; flex: 0 0 auto;
} }
.prio-high { .dot {
background: rgba(255, 106, 106, 0.18); width: 8px;
color: var(--st-block); height: 8px;
border-radius: 50%;
flex: 0 0 auto;
} }
.prio-medium { .dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
background: rgba(124, 108, 255, 0.14); .dot.queue { background: var(--st-queue); }
color: var(--a-mid); .dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
} .dot.idle { background: var(--st-idle); }
.prio-low { .stl {
background: rgba(255, 255, 255, 0.06); margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3); color: var(--tx-3);
} }
/* ── Title ─────────────────────────────────────────── */ .tt {
.task-title {
font-family: 'Manrope', sans-serif;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
margin-top: 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx); color: var(--tx);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
} }
/* ── Agent ─────────────────────────────────────────── */ .ow {
.task-agent { font-size: 10.5px;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--tx-3); color: var(--tx-3);
font-variant-numeric: tabular-nums; margin-top: 5px;
} }
/* ── Progress Bar ──────────────────────────────────── */ .skeleton {
.task-progress { height: 78px;
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;
background: var(--glass); background: var(--glass);
animation: skeleton-pulse 1.5s ease-in-out infinite; animation: skeleton-pulse 1.5s ease-in-out infinite;
} }
@keyframes skeleton-pulse { @keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; } 0%, 100% { opacity: 0.5; }
50% { opacity: 0.8; } 50% { opacity: 0.8; }
} }
/* ── Error ────────────────────────────────────── */ .tstrip-msg {
.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 {
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
font-size: 11px; font-size: 11px;
color: var(--tx-3); color: var(--tx-3);
font-style: italic;
padding: 12px; padding: 12px;
white-space: nowrap; white-space: nowrap;
} }
+12 -1
View File
@@ -26,13 +26,24 @@ export interface ThinkingItem {
ts: string 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 { export interface AgentDetailData {
id: string id: string
name: string name: string
role: string role: string
roleBadge: string
model: string model: string
status: 'work' | 'think' | 'idle' 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 tokensToday: number
costToday: number costToday: number
workload: number workload: number
+1 -1
View File
@@ -20,7 +20,7 @@ defineProps<{
<!-- Status Pill --> <!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']"> <span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span> <span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'Verbunden' : 'Preview' }} {{ connected ? 'OpenClaw verbunden' : 'Preview' }}
</span> </span>
<!-- Ask Iris Button --> <!-- Ask Iris Button -->
+6 -3
View File
@@ -5,9 +5,12 @@
* Sidebar (248px) + Main (flex:1, flex-column) * Sidebar (248px) + Main (flex:1, flex-column)
*/ */
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useAgentStore } from '../stores/agents'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue' import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue' import Topbar from '../components/layout/Topbar.vue'
const agentStore = useAgentStore()
</script> </script>
<template> <template>
@@ -15,7 +18,7 @@ import Topbar from '../components/layout/Topbar.vue'
<GalaxyBackground /> <GalaxyBackground />
<Sidebar /> <Sidebar />
<main class="nexus-main"> <main class="nexus-main">
<Topbar /> <Topbar :connected="agentStore.isConnected" />
<div class="nexus-content"> <div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
@@ -43,7 +46,7 @@ import Topbar from '../components/layout/Topbar.vue'
.nexus-content { .nexus-content {
flex: 1; flex: 1;
overflow-y: auto; overflow: hidden;
padding: 18px 20px; min-height: 0;
} }
</style> </style>
+26 -26
View File
@@ -27,6 +27,11 @@ interface DashboardAgentInfo {
progress?: number progress?: number
workload?: number workload?: number
goal?: string | null goal?: string | null
roleBadge?: string
statusLabel?: string
elapsed?: string | null
think?: string | null
next?: string | null
} }
interface ModelOption { interface ModelOption {
@@ -35,25 +40,6 @@ interface ModelOption {
provider: string 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 ───────────────────────────────── */ /* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] { function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
@@ -78,24 +64,24 @@ function avatarFor(id: string, name: string): string {
/* ── Enrich API Agent → AgentNodeData ─────────────── */ /* ── Enrich API Agent → AgentNodeData ─────────────── */
function enrichAgent(api: DashboardAgentInfo): AgentNodeData { function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']!
const status = mapStatus(api.isActive, api.currentTask) const status = mapStatus(api.isActive, api.currentTask)
return { return {
id: api.id, id: api.id,
name: api.name, name: api.name,
role: api.role, role: api.role,
roleBadge: api.roleBadge ?? 'badge-slate',
model: api.model, model: api.model,
avatar: avatarFor(api.id, api.name), avatar: avatarFor(api.id, api.name),
status, status,
statusLabel: STATUS_LABELS[status], statusLabel: api.statusLabel ?? STATUS_LABELS[status],
task: api.currentTask, task: api.currentTask,
goal: api.goal ?? null, goal: api.goal ?? null,
progress: api.progress ?? 0, progress: api.progress ?? 0,
elapsed: cat.elapsed ?? '--', elapsed: api.elapsed ?? '--',
next: cat.next ?? 'Standby', next: api.next ?? 'Standby',
tokens: '0', tokens: '0',
cost: '0.00', 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, id: data.id,
name: data.name, name: data.name,
role: data.role, role: data.role,
roleBadge: data.roleBadge || 'badge-slate',
model: displayModel, model: displayModel,
status: data.status === 'block' ? 'idle' : data.status, 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, tokensToday,
costToday: costNum, costToday: costNum,
workload: progress, workload: progress,
@@ -163,6 +160,7 @@ export const useAgentStore = defineStore('agents', {
error: null as string | null, error: null as string | null,
selectedAgentId: null as string | null, selectedAgentId: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null, refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false,
}), }),
getters: { getters: {
@@ -211,10 +209,12 @@ export const useAgentStore = defineStore('agents', {
async fetchAgents() { async fetchAgents() {
try { try {
const res = await apiFetch('/api/dashboard/agents') const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return if (!res.ok) { this.isConnected = false; return }
const data: DashboardAgentInfo[] = await res.json() const data: DashboardAgentInfo[] = await res.json()
this.agents = data.map(enrichAgent) this.agents = data.map(enrichAgent)
this.isConnected = true
} catch (err) { } catch (err) {
this.isConnected = false
console.warn('[AgentStore] fetchAgents failed', err) console.warn('[AgentStore] fetchAgents failed', err)
} }
}, },
@@ -262,7 +262,7 @@ export const useAgentStore = defineStore('agents', {
this.refreshInterval = setInterval(() => { this.refreshInterval = setInterval(() => {
this.fetchAgents() this.fetchAgents()
this.fetchModels() this.fetchModels()
}, 30000) }, 15000)
}, },
stopPolling() { stopPolling() {
+4 -2
View File
@@ -162,7 +162,9 @@ onUnmounted(() => {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 0; gap: 18px;
padding: 18px 20px;
overflow: hidden;
min-height: 0; min-height: 0;
} }
@@ -171,8 +173,8 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
padding: 0 18px 0 0;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
overflow: hidden;
} }
</style> </style>
+5 -6
View File
@@ -28,10 +28,9 @@ echo "[4/4] Verifikation..."
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden" curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden"
echo "" echo ""
echo "=== Fertig ===" echo "=== Deployment abgeschlossen ==="
echo "Nexus Web: http://nexus.noveria.net:18880" echo "Dashboard: https://nexus.noveria.net/dashboard"
echo "Login: vmbao62@hotmail.de" echo "Health-API: https://nexus.noveria.net/health"
echo "Passwort: wird beim ersten Start im Container-Log ausgegeben"
echo "" 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" echo "Status: docker compose ps"
+5 -2
View File
@@ -34,14 +34,17 @@ systemctl daemon-reload
systemctl enable --now ollama systemctl enable --now ollama
systemctl restart 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 if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
break break
fi fi
if [[ "${attempt}" -eq 30 ]]; then if [[ "${attempt}" -eq "${max_attempts}" ]]; then
systemctl status ollama --no-pager systemctl status ollama --no-pager
exit 1 exit 1
fi fi
attempt=$((attempt + 1))
sleep 2 sleep 2
done done
+131 -9
View File
@@ -1,18 +1,132 @@
# Deployment # Deployment
> Letzte Aktualisierung: 2026-06-09 > Letzte Aktualisierung: 2026-06-13
> Status: ✅ Deployment abgeschlossen > Status: ✅ CD v3 (Auto + Manual)
> Live-URL: https://nexus.noveria.net > 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 ## Secrets und Konfiguration
- [x] `.env.template` mit allen erforderlichen Variablen erstellt ### Secrets in Gitea
- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt
- [x] Migration des Produktionsstacks getestet 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) ## 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] 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] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09) - [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) ## Verifizierung (2026-06-09)
@@ -66,6 +187,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
## Offene Arbeit ## Offene Arbeit
- [ ] Backup-Strategie fuer Produktionsdaten definieren - [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
- [ ] Docker-Logs und Container-Health-Monitoring einrichten - [ ] 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