diff --git a/.gitea/workflows/backup.yaml b/.gitea/workflows/backup.yaml new file mode 100644 index 0000000..e4be73d --- /dev/null +++ b/.gitea/workflows/backup.yaml @@ -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 diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 6642d1f..6839971 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -34,7 +34,6 @@ jobs: - name: Test run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal - continue-on-error: true # ─── Frontend ────────────────────────────────── frontend: @@ -54,16 +53,18 @@ jobs: corepack enable corepack prepare pnpm@latest --activate - # --prefer-offline: use cached packages if available in the runner image - # Lockfile IS committed β€” regenerated on changes via pnpm install. - name: Install dependencies - run: pnpm install --no-frozen-lockfile --prefer-offline + run: pnpm install --frozen-lockfile working-directory: frontend - name: Type check run: pnpm exec vue-tsc --noEmit working-directory: frontend + - name: Test + run: pnpm test + working-directory: frontend + - name: Build run: pnpm build working-directory: frontend @@ -79,8 +80,20 @@ jobs: - name: Check for .env leaks run: | - if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then - echo "⚠️ Warning: Potential secrets in source code (review manually)" + echo "πŸ” Scanning for potential secrets in source code..." + HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true) + if [ -n "$HITS" ]; then + echo "❌ SECRET LEAK DETECTED β€” the following lines look like hardcoded credentials:" + echo "$HITS" + echo "" + echo "Remove these values and use environment variables or a secrets manager instead." + exit 1 + fi + # Secondary pass: catch bare assign patterns that are suspicious regardless of length + LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true) + if [ -n "$LOOSE" ]; then + echo "⚠️ WARNING β€” potential secrets found (short values may be false positives, review manually):" + echo "$LOOSE" else echo "βœ… No obvious secrets found" fi diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 413fc37..9edd83f 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -1,158 +1,249 @@ name: Deploy to Production -run-name: πŸš€ Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }} +run-name: πŸš€ Deploy by @${{ gitea.actor }} -# ── Concurrency: one deploy at a time, cancel queued ones ── -# Why: prevents race conditions when CI triggers deploy while -# a manual deploy is still running. The latest deploy wins. +# ─────────────────────────────────────────────────────── +# Owner: DevOps (Architekt) +# CD v3 β€” 2026-06-13 +# +# Triggers: +# 1. AUTOMATIC after successful CI on main (workflow_run) +# β†’ Uses safe defaults: patch bump, all services, main ref. +# β†’ Commits marked with [skip ci] are filtered at job level +# (prevents version-bump loops). +# 2. MANUAL via workflow_dispatch with full parameter control. +# +# Concurrency: one deploy at a time. +# Queued deploys wait β€” no race conditions with parallel builds. +# +# Version-Bump / CI Loop Prevention: +# The version-bump commit includes "[skip ci]" in its message, +# which Gitea Actions respects. The auto-trigger additionally +# checks for "[skip ci]" as a second safety layer. Together +# they guarantee that a version-bump commit does NOT trigger +# another CI β†’ Deploy β†’ Bump β†’ CI cycle. +# ─────────────────────────────────────────────────────── concurrency: group: deploy-production cancel-in-progress: false -# ─────────────────────────────────────────────────── -# Trigger: automatic after CI success, or manual dispatch. -# Runner: uses ubuntu-latest label (consistently present on -# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…). -# Standard labels avoid custom-label matching edge cases. -# ─────────────────────────────────────────────────── on: + # ── Auto-Trigger: after successful CI on main ── workflow_run: workflows: ["CI - Build & Test"] types: [completed] branches: [main] + + # ── Manual Trigger (full control) ── workflow_dispatch: inputs: - bump_version: - description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)' - required: false + version_bump: + description: 'Version bump type' + required: true default: 'patch' - type: string + type: choice options: - - 'patch' - - 'minor' - - 'major' + - patch + - minor + - major service: description: 'Service to deploy (empty = all)' required: false default: '' type: string no_cache: - description: 'Disable build cache' + description: 'Disable Docker build cache' required: false default: false type: boolean + git_ref: + description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)' + required: false + default: 'main' + type: string jobs: deploy: name: Deploy Nexus runs-on: ubuntu-latest - if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }} + if: | + (github.event_name == 'workflow_dispatch') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + !contains(github.event.workflow_run.head_commit.message, '[skip ci]')) + + # ── Env for the deploy target path ── + env: + DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus + ENV_TMPFILE: /tmp/nexus-deploy-env + ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} + ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} + ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }} + ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }} + steps: - # ── Step 1: Checkout ───────────────────── - - name: Checkout latest code + # ═══════════════════════════════════════════════════ + # Step 1: Checkout + # ═══════════════════════════════════════════════════ + - name: Checkout uses: actions/checkout@v4 with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }} fetch-depth: 0 fetch-tags: true - # ── Step 2: Version bump (race-free) ───── - # Derives current version from git tags (not VERSION file) to - # avoid race conditions where tag exists but VERSION is stale. - # Uses --force on tag+push to handle retries after failed runs. - - name: Version Bump + # ═══════════════════════════════════════════════════ + # Step 2: Set up Git identity + # ═══════════════════════════════════════════════════ + - name: Configure Git + run: | + git config user.email "devops@noveria.net" + git config user.name "DevOps" + + # ═══════════════════════════════════════════════════ + # Step 3: Resolve deploy version + # + # Deploying main: DevOps may bump VERSION and create a tag. + # Deploying any other ref: deploy exactly that ref, but DO NOT + # mutate main or create a version-bump commit on another branch. + # + # For auto-deploys (workflow_run): always "patch" bump on main. + # ═══════════════════════════════════════════════════ + - name: Resolve Version + id: version run: | set -euo pipefail - # Source of truth: latest git tag - TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - CURRENT_VERSION="${TAG#v}" - echo "πŸ“¦ Current version (from git tags): $CURRENT_VERSION" + # Determine bump type (auto-deploy β†’ patch; manual β†’ user choice) + BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}" - MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) - MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) - PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) + # Read current version + if [ ! -f VERSION ]; then + echo "❌ VERSION file not found" + exit 1 + fi - case "${{ inputs.bump_version }}" in - major) - MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) - MINOR=$((MINOR + 1)); PATCH=0 ;; - patch|*) - PATCH=$((PATCH + 1)) ;; + CURRENT=$(cat VERSION | tr -d '[:space:]') + if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "❌ Invalid semver in VERSION: '$CURRENT'" + exit 1 + fi + + MAJOR=$(echo "$CURRENT" | cut -d. -f1) + MINOR=$(echo "$CURRENT" | cut -d. -f2) + PATCH=$(echo "$CURRENT" | cut -d. -f3) + + case "$BUMP_TYPE" in + major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;; + minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;; + patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;; + *) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;; esac - NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" - echo "🏷️ New version: $NEW_VERSION" - echo "$NEW_VERSION" > VERSION + # Determine git ref β€” auto-deploy always uses main + DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" + if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then + NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}" + echo "$NEW_VERSION" > VERSION + git 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" - git add VERSION - git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]" - - # --force avoids "tag already exists" when re-running after a failed attempt - git tag -f "v${NEW_VERSION}" - git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags - echo "βœ… Version bumped to v${NEW_VERSION}" - - # ── Step 3: Sync code + .env to host ────── - # Creates .env from Gitea secrets in the workspace, then syncs - # everything (except .git) to the host deploy path via DIND. - - name: Sync code + .env to host + # ═══════════════════════════════════════════════════ + # Step 4: Build .env from secrets (SAFE) + # + # Secrets are written to /tmp/nexus-deploy-env β€” NEVER + # to a file inside the workspace that gets rsync'd to + # the host. The temp file is deleted immediately after + # compose operations complete. + # ═══════════════════════════════════════════════════ + - name: Prepare .env (secrets β†’ temp file) run: | - # Create .env from Gitea secrets in the workspace - cat > "${{ gitea.workspace }}/.env" << 'ENVEOF' + set -euo pipefail + + cat > "${ENV_TMPFILE}" </dev/null || true + echo "βœ… Code synced to ${DEPLOY_PATH}" - # ── Step 5: Build & Deploy ──────────────── + # ═══════════════════════════════════════════════════ + # Step 6: Build & Deploy + # + # The temp .env file is bind-mounted read-only into the + # docker:cli container so compose can resolve variables. + # It is NEVER written into the workspace directory. + # ═══════════════════════════════════════════════════ - name: Build & Deploy run: | + set -euo pipefail + + # Auto-deploy: always use cache. Manual: respect no_cache input. + NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}" BUILD_ARGS="" - if [ "${{ inputs.no_cache }}" = "true" ]; then + if [ "$NO_CACHE" = "true" ]; then BUILD_ARGS="--no-cache" fi + SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}" + docker run --rm \ - -v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \ + -v "${DEPLOY_PATH}:/workspace/nexus" \ + -v "${ENV_TMPFILE}:/workspace/nexus/.env:ro" \ -v /var/run/docker.sock:/var/run/docker.sock \ -w /workspace/nexus \ docker:cli \ sh -c " set -e - if [ -n '${{ inputs.service }}' ]; then - echo 'πŸš€ Deploying service: ${{ inputs.service }}' - docker compose build ${BUILD_ARGS} ${{ inputs.service }} - docker compose up -d --wait --force-recreate ${{ inputs.service }} + if [ -n '${SERVICE_ARG}' ]; then + echo 'πŸš€ Deploying service: ${SERVICE_ARG}' + docker compose build ${BUILD_ARGS} ${SERVICE_ARG} + docker compose up -d --wait --force-recreate ${SERVICE_ARG} else echo 'πŸš€ Deploying all services' docker compose build ${BUILD_ARGS} @@ -160,10 +251,22 @@ jobs: fi " - # ── Step 6: Health Check (backoff) ──────── - # Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total). - # Why: cold-start containers need variable warmup time; - # fixed 5s intervals either wait too long or give up too early. + echo "βœ… Docker compose up completed" + + # ═══════════════════════════════════════════════════ + # Step 7: Clean up temp .env + # ═══════════════════════════════════════════════════ + - name: Clean up temp .env + if: always() + run: | + if [ -f "${ENV_TMPFILE}" ]; then + shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}" + echo "🧹 Temp .env removed" + fi + + # ═══════════════════════════════════════════════════ + # Step 8: Health Check (exponential backoff) + # ═══════════════════════════════════════════════════ - name: Health Check run: | echo "πŸ₯ Health check..." @@ -186,11 +289,10 @@ jobs: echo "❌ Health check failed after $MAX attempts" exit 1 - # ── Step 7: Smoke test (multi-endpoint) ─── - # Tests multiple endpoints to catch partial failures. - # Why: a single /dashboard check can miss backend-only outages; - # /health tests the API + database + runtime status. - - name: Verify (smoke test) + # ═══════════════════════════════════════════════════ + # Step 9: Smoke Test + # ═══════════════════════════════════════════════════ + - name: Smoke Test run: | echo "πŸ” Smoke test..." PASS=0 @@ -199,7 +301,8 @@ jobs: check() { local path="$1" label="$2" expected="${3:-200}" - local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}") + local code + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}") printf " %-25s HTTP %s" "${label}:" "${code}" if [ "$code" = "$expected" ]; then echo " βœ…" @@ -210,8 +313,9 @@ jobs: fi } - check "/dashboard" "Dashboard" 200 - check "/health" "Health API" 200 + check "/dashboard" "Dashboard" 200 + check "/health" "Health API" 200 + check "/api/v1/operations/snapshot" "Operations API (auth)" 401 echo "" echo "Results: $PASS passed, $FAIL failed" @@ -219,25 +323,53 @@ jobs: echo "❌ Smoke test failed!" exit 1 fi - echo "βœ… Deployment verified" + echo "βœ… Smoke test passed β€” v${{ steps.version.outputs.version }} is live" - # ── Step 8: Rollback hint ──────────────── - # On any failure, prints the previous deploy tag for quick manual rollback. - # Why: reduces MTTR (mean time to recovery) by providing the exact - # git tag to roll back to without needing to look it up manually. - - name: Rollback hint + # ═══════════════════════════════════════════════════ + # Step 10: Deployment Summary + # ═══════════════════════════════════════════════════ + - name: Deployment Summary + if: always() + run: | + TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}" + VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}" + echo "" + echo "═══════════════════════════════════════" + echo " πŸ“¦ Deploy Summary" + echo "═══════════════════════════════════════" + echo " Version: v${{ steps.version.outputs.version }}" + echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" + echo " Main bump: ${{ steps.version.outputs.mutated_main }}" + echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}" + echo " Trigger: ${TRIGGER}" + echo " Bump type: ${VERSION_BUMP}" + echo " Actor: @${{ gitea.actor }}" + echo " Status: ${{ job.status }}" + echo "═══════════════════════════════════════" + + # ═══════════════════════════════════════════════════ + # Step 11: Failure β†’ Reviewer Handoff + # + # On failure: DevOps (Architekt) analyses the log, + # notifies Reviewer (Code-Fixer) with the exact error. + # This output provides a ready-to-copy message. + # ═══════════════════════════════════════════════════ + - name: πŸ”΄ Failure β€” Reviewer Handoff if: failure() run: | echo "" - echo "πŸ”™ ─── Rollback Instructions ─── πŸ”™" - echo "" - echo " # 1. Checkout previous version:" - echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')" - echo "" - echo " # 2. Redeploy:" - echo " cd /opt/openclaw/data/openclaw/workspace/nexus" - echo " docker compose up -d --force-recreate" - echo "" - echo " # 3. Or trigger rollback via Gitea:" - echo " Trigger 'Deploy to Production' workflow with the previous tag" - echo "" + echo "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”" + echo "β”‚ πŸ”΄ DEPLOY FAILED β€” Reviewer muss fixen β”‚" + echo "β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€" + echo "β”‚ β”‚" + echo "β”‚ Version: v${{ steps.version.outputs.version }}" + echo "β”‚ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" + echo "β”‚ β”‚" + echo "β”‚ β†’ DevOps (Architekt) analysiert den Fehler β”‚" + echo "β”‚ β†’ Reviewer (Code-Fixer) behebt das Problem β”‚" + echo "β”‚ β†’ DevOps verifiziert mit neuem Deploy β”‚" + echo "β”‚ β”‚" + echo "β”‚ Rollback: Trigger 'Rollback to Previous Version' β”‚" + echo "β”‚ workflow manuell in Gitea Actions. β”‚" + echo "β”‚ β”‚" + echo "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" diff --git a/.gitea/workflows/rollback.yaml b/.gitea/workflows/rollback.yaml new file mode 100644 index 0000000..82c587d --- /dev/null +++ b/.gitea/workflows/rollback.yaml @@ -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 β†’ 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}" </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 "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" diff --git a/.gitignore b/.gitignore index c7e58c9..6eed030 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ docker-compose.override.yml *.bak # pnpm (lockfile IS committed for reproducible CI builds) + +# Claude local config (per-developer, not repo-shared) +.claude/ diff --git a/README.md b/README.md index 467e1f7..cb77f20 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,11 @@ Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an adapter-backed agent runtime, not a dependency of the frontend or domain model. -> CI/CD auto-deploy enabled β€” every push to main triggers build β†’ test β†’ deploy. +> CI runs automatically on every push. CD can run **automatically after successful CI** +> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with +> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys +> stay read-only. Rollback and database backup are separate manual workflows. +> See [phases/deployment.md](phases/deployment.md) for full CD documentation. ## Current foundation @@ -287,4 +291,59 @@ The configured model-routing policy is: The Settings module reports runtime and provider state without exposing credentials. -# Trigger CI + +## CI/CD + +### CI β€” Automatic + +Every push to `main` triggers `.gitea/workflows/ci.yaml`: +- **Backend**: .NET restore β†’ build β†’ test +- **Frontend**: pnpm install β†’ type-check β†’ test β†’ build +- **Security**: Scan for hardcoded secrets in source code + +CI must never break. If it does, Reviewer fixes. + +### CD β€” Auto + Manual (CD v3) + +Deployment can happen automatically or manually: + +#### Auto-Deploy (after successful CI on main) + +- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main` +- Uses safe defaults: `patch` bump, all services, main ref +- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits) +- The version-bump commit itself uses `[skip ci]` β†’ no infinite CIβ†’Deployβ†’Bumpβ†’CI loops + +#### Manual Deploy (`workflow_dispatch`) + +1. DevOps triggers `Deploy to Production` in Gitea Actions +2. Chooses version bump type: patch (default) / minor / major +3. Optionally scopes to a single service or specific git ref +4. Workflow bumps VERSION, creates git tag, builds and deploys +5. Health check + smoke test verify the deployment + +#### Rollback (`workflow_dispatch`) + +1. DevOps triggers `Rollback to Previous Version` in Gitea Actions +2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK` +3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys +4. Health check + smoke test verify the rollback + +#### Database Backup (`workflow_dispatch`) + +1. DevOps triggers `Database Backup` in Gitea Actions +2. Optionally also copies backup to a host path (`/opt/openclaw/backups`) +3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact +4. Artifacts are retained for 90 days (configurable) +5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`) + +#### Failure Handling + +When deploy or rollback fails: +- **DevOps (Architekt)** analyses the error +- **Reviewer (Code-Fixer)** fixes the problem +- **DevOps** re-deploys to verify the fix + +The workflow outputs a formatted handoff message with the job URL. + +Full CD documentation: [phases/deployment.md](phases/deployment.md) diff --git a/backend/Controllers/AgentsController.cs b/backend/Controllers/AgentsController.cs index a21be04..0c3b605 100644 --- a/backend/Controllers/AgentsController.cs +++ b/backend/Controllers/AgentsController.cs @@ -1,8 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; -using Nexus.Api.Data; using Nexus.Api.DTOs; -using Nexus.Api.Helpers; using Nexus.Api.Integrations; using Nexus.Api.Repositories; using Nexus.Api.Services; @@ -15,6 +13,7 @@ public class AgentsController( IAgentService agentService, IAgentRuntime runtime, IActivityRepository activityRepo, + IAgentConfigService agentConfigService, ILogger logger) : ControllerBase { [HttpGet] @@ -22,8 +21,7 @@ public class AgentsController( { var agents = await agentService.GetAgentsAsync(ct); return Results.Ok(agents.Select(a => new AgentListResponse( - a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description - ))); + a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description))); } [HttpGet("{id}")] @@ -34,8 +32,7 @@ public class AgentsController( return Results.Ok(new AgentDetailResponse( agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, - agent.SubAgents, agent.IdentityName - )); + agent.SubAgents, agent.IdentityName)); } [HttpGet("{id}/activity")] @@ -58,9 +55,7 @@ public class AgentsController( try { var result = await runtime.ChatAsync(message, conversationId, id, ct); - - await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct); - + await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct); return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); } catch (Exception exception) @@ -73,79 +68,33 @@ public class AgentsController( } } - // ========== Agent Config Editor ========== + // ── Config Editor ── [HttpGet("{id}/config")] public IResult GetConfig(string id) - { - var workspacePath = $"/mnt/workspace-{id}"; - if (!Directory.Exists(workspacePath)) - return Results.Ok(Array.Empty()); - - var allowedFiles = new HashSet(StringComparer.OrdinalIgnoreCase) - { - "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" - }; - - var files = Directory.GetFiles(workspacePath, "*.md") - .Select(f => new FileInfo(f)) - .Where(f => allowedFiles.Contains(f.Name)) - .OrderBy(f => f.Name) - .Select(f => new - { - fileName = f.Name, - size = f.Length, - modifiedAt = f.LastWriteTimeUtc - }) - .ToList(); - - return Results.Ok(files); - } + => Results.Ok(agentConfigService.GetConfigFiles(id)); [HttpGet("{id}/config/{fileName}")] public async Task GetConfigFile(string id, string fileName, CancellationToken ct) { - if (!PathSecurityHelper.IsValidConfigFileName(fileName)) - return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); - - var workspacePath = $"/mnt/workspace-{id}"; - if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath)) - return Results.NotFound(); - - var content = await System.IO.File.ReadAllTextAsync(safePath!, ct); - var fi = new FileInfo(safePath!); - return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct); + return file is null + ? Results.NotFound() + : Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt }); } [HttpPut("{id}/config/{fileName}")] public async Task SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) { - if (!PathSecurityHelper.IsValidConfigFileName(fileName)) - return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); - if (request.Content is null) return Results.BadRequest(new { error = "Content is required." }); if (request.Content.Length > 500 * 1024) return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); - var workspacePath = $"/mnt/workspace-{id}"; - if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath)) - return Results.NotFound(); - - var tempPath = safePath + ".tmp"; - try - { - await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct); - System.IO.File.Move(tempPath, safePath, overwrite: true); - } - catch - { - if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); - throw; - } - - var fi = new FileInfo(safePath); - return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct); + return result is null + ? Results.BadRequest(new { error = "Invalid filename or path." }) + : Results.Ok(new { result.FileName, result.Size, result.ModifiedAt }); } } diff --git a/backend/Controllers/CalendarController.cs b/backend/Controllers/CalendarController.cs index a77efb1..8e0c2c6 100644 --- a/backend/Controllers/CalendarController.cs +++ b/backend/Controllers/CalendarController.cs @@ -1,80 +1,17 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.DTOs; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/calendar")] -public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger logger) : ControllerBase +public class CalendarController(ICalendarService calendarService) : ControllerBase { [HttpGet] public async Task GetAll(CancellationToken ct) - { - var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; - - try - { - var httpClient = httpClientFactory.CreateClient("gateway"); - if (!string.IsNullOrWhiteSpace(gatewayToken)) - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); - - var response = await httpClient.GetAsync("/api/cron", ct); - if (response.IsSuccessStatusCode) - { - var data = await response.Content.ReadFromJsonAsync>(ct); - return Results.Ok(data ?? new List()); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data."); - } - - var fallbackJobs = new List - { - new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" }, - new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" }, - new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" }, - new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" }, - new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" }, - }; - return Results.Ok(fallbackJobs); - } + => Results.Ok(await calendarService.GetCronJobsAsync(ct)); [HttpGet("upcoming")] public async Task GetUpcoming(CancellationToken ct) - { - var gatewayToken = config["Integrations:OpenClaw:Token"] ?? ""; - - try - { - var httpClient = httpClientFactory.CreateClient("gateway"); - if (!string.IsNullOrWhiteSpace(gatewayToken)) - httpClient.DefaultRequestHeaders.Authorization = - new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken); - - var response = await httpClient.GetAsync("/api/cron/upcoming", ct); - if (response.IsSuccessStatusCode) - { - var data = await response.Content.ReadFromJsonAsync>(ct); - return Results.Ok(data ?? new List()); - } - } - catch (Exception ex) - { - logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data."); - } - - var now = DateTimeOffset.UtcNow; - var fallback = new List - { - new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" }, - new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" }, - new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" }, - new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" }, - new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" }, - }; - return Results.Ok(fallback); - } + => Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct)); } diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs index 418fefe..c277e75 100644 --- a/backend/Controllers/DashboardController.cs +++ b/backend/Controllers/DashboardController.cs @@ -1,403 +1,113 @@ using Microsoft.AspNetCore.Mvc; using Nexus.Api.Data; using Nexus.Api.Models; -using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/dashboard")] -public class DashboardController( - OpenClawGatewayClient gateway, - ITaskRepository taskRepo, - IActivityRepository activityRepo, - ILogger logger) - : ControllerBase +public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase { - /// - /// Gateway health + session_status + subagents count. - /// Returns HTTP 200 even when gateway is down (gatewayOk: false). - /// [HttpGet("status")] public async Task GetStatus() - { - try - { - return await gateway.GetStatusAsync(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard status check failed"); - return new DashboardStatus(false, "Offline", 0, 0); - } - } + => await dashboardService.GetStatusAsync(); - /// - /// Returns all agents with their current status. - /// Combines sessions_list + sub_agents_list. - /// [HttpGet("agents")] public async Task> GetAgents() - { - try - { - return await gateway.GetAgentsAsync(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard agents fetch failed"); - return new List(); - } - } + => await dashboardService.GetAgentsAsync(); - /// - /// 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. - /// [HttpGet("operations")] public async Task> GetOperations( [FromQuery] int limit = 20, [FromQuery] string? agent = null) - { - try - { - var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100)); + => await dashboardService.GetOperationsAsync(limit, agent); - // Optional agent filter - if (!string.IsNullOrWhiteSpace(agent)) - { - entries = entries - .Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase) - || string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - - return entries; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard operations fetch failed"); - return new List(); - } - } - - /// - /// Send a chat message to the Iris session. - /// [HttpPost("chat/send")] public async Task SendChat([FromBody] ChatRequest request) { if (string.IsNullOrWhiteSpace(request.Message)) return new ChatResponse(false, null, "Message is required"); - try - { - var agentId = string.IsNullOrWhiteSpace(request.AgentId) - ? "iris" - : request.AgentId.Trim(); - - return await gateway.SendChatMessageAsync(agentId, request.Message.Trim()); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard chat send failed"); - return new ChatResponse(false, null, "Gateway nicht erreichbar"); - } + var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim(); + return await dashboardService.SendChatAsync(agentId, request.Message.Trim()); } - /// - /// Returns chat messages (user + assistant only, not tool messages). - /// [HttpGet("chat/messages")] public async Task> GetMessages( [FromQuery] string? sessionKey, [FromQuery] int limit = 50, [FromQuery] int offset = 0) - { - try - { - var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim(); - var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset)); + => await dashboardService.GetMessagesAsync(sessionKey, limit, offset); - return messages - .Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) - || string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)) - .ToList(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard messages fetch failed"); - return new List(); - } - } - - /// - /// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority). - /// [HttpGet("queue")] public async Task> GetQueue(CancellationToken ct) - { - try - { - // Fetch cron jobs and open tasks concurrently - var cronTask = gateway.GetQueueAsync(); - var tasksTask = taskRepo.GetAllAsync(ct); + => await dashboardService.GetQueueAsync(ct); - await Task.WhenAll(cronTask, tasksTask); - - var cronJobs = cronTask.Result; - var openTasks = tasksTask.Result - .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) - .ToList(); - - var merged = new List(); - - // 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(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(); - } - } - - private static string NormalizePriority(string priority) - { - return priority.ToLowerInvariant() switch - { - "high" or "critical" or "urgent" => "high", - "low" or "minor" => "low", - _ => "medium" - }; - } - - /// - /// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done. - /// [HttpDelete("queue/{id}")] public async Task DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) { - try + var result = await dashboardService.DeleteQueueItemAsync(id, source, ct); + return result.Outcome switch { - if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) - { - var ok = await gateway.DeleteCronJobAsync(id); - if (!ok) - return StatusCode(502, new { error = "Gateway could not delete cron job" }); - return NoContent(); - } - else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase)) - { - // Extract the actual GUID from the prefixed id ("task-{guid}") - if (!id.StartsWith("task-")) - return BadRequest(new { error = "Invalid task id format" }); - - var guidStr = id["task-".Length..]; - if (!Guid.TryParse(guidStr, out var guid)) - return BadRequest(new { error = "Invalid task id" }); - - var task = await taskRepo.GetByIdAsync(guid, ct); - if (task is null) - return NotFound(new { error = "Task not found" }); - - // Set task status to Done instead of deleting - task.State = "Done"; - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent - { - Type = "task", - Message = $"Task \"{task.Title}\" completed via queue" - }, ct); - - return NoContent(); - } - - // 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" }); - } + QueueDeleteOutcome.Deleted => NoContent(), + QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }), + QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }), + QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }), + QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }), + _ => StatusCode(500, new { error = "Internal error" }) + }; } - /// - /// Changes the priority of a queue item (only for tasks; cron jobs are ignored). - /// Cycles: high β†’ medium β†’ low β†’ high. - /// [HttpPut("queue/{id}/priority")] public async Task ChangeQueuePriority(string id, CancellationToken ct) { - try + var result = await dashboardService.CycleQueuePriorityAsync(id, ct); + return result.Outcome switch { - if (!id.StartsWith("task-")) - return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }); - - var guidStr = id["task-".Length..]; - if (!Guid.TryParse(guidStr, out var guid)) - return BadRequest(new { error = "Invalid task id" }); - - var task = await taskRepo.GetByIdAsync(guid, ct); - if (task is null) - return NotFound(new { error = "Task not found" }); - - // Cycle priority: high β†’ medium β†’ low β†’ high - task.Priority = task.Priority.ToLowerInvariant() switch - { - "high" => "Medium", - "medium" => "Low", - "low" => "High", - _ => "Medium" - }; - - 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" }); - } + QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }), + QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }), + QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }), + _ => Ok(new { status = "ok", priority = result.NewPriority }) + }; } - /// - /// Returns the current model and provider for a specific agent session. - /// Calls session_status with the agent's session key. - /// [HttpGet("agents/{id}/model")] public async Task> GetAgentModel(string id) { - try - { - var info = await gateway.GetAgentModelAsync(id); - if (info is null) - return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" }); - return Ok(info); - } - catch (Exception ex) - { - logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id); - return StatusCode(500, new { error = "Internal error" }); - } + var info = await dashboardService.GetAgentModelAsync(id); + return info is null + ? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" }) + : Ok(info); } - /// - /// Sets the model for a specific agent session. - /// Calls session_status with model parameter. - /// [HttpPut("agents/{id}/model")] public async Task SetAgentModel(string id, [FromBody] SetModelRequest request) { if (string.IsNullOrWhiteSpace(request.Model)) return BadRequest(new { error = "Model is required" }); - try - { - var ok = await gateway.SetAgentModelAsync(id, request.Model); - if (!ok) - return StatusCode(502, new { error = "Gateway did not accept the change" }); - return Ok(new { status = "ok", model = request.Model }); - } - catch (Exception ex) - { - logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id); - return StatusCode(500, new { error = "Internal error" }); - } + var ok = await dashboardService.SetAgentModelAsync(id, request.Model); + return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" }); } - /// - /// Returns the most recent activity entries (assistant messages) for a specific agent. - /// [HttpGet("agents/{id}/activity")] public async Task> GetAgentActivity(string id, [FromQuery] int limit = 5) - { - try - { - return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20)); - } - catch (Exception ex) - { - logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id); - return new List(); - } - } + => await dashboardService.GetAgentActivityAsync(id, limit); - /// - /// Returns the list of available models that can be assigned to agents. - /// Reads from OpenClaw config dynamically, falls back to hardcoded list. - /// [HttpGet("models")] public ActionResult> GetAvailableModels() - { - var models = gateway.GetAvailableModels(); - return Ok(models); - } + => Ok(dashboardService.GetAvailableModels()); - // ========== Task Endpoints ========== + // ── Task Endpoints ── - /// - /// Returns all non-done tasks (status != 'Done'), ordered by creation date descending. - /// [HttpGet("tasks")] public async Task> GetTasks(CancellationToken ct) { - try - { - var tasks = await taskRepo.GetAllAsync(ct); - return tasks - .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(t => t.CreatedAt) - .Select(MapToDto) - .ToList(); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Dashboard tasks fetch failed"); - return new List(); - } + var tasks = await taskService.GetOpenAsync(ct); + return tasks.Select(MapToDto).ToList(); } - /// - /// Creates a new task and logs an activity event. - /// [HttpPost("tasks")] public async Task> CreateTask( [FromBody] CreateDashboardTaskRequest request, CancellationToken ct) @@ -405,121 +115,49 @@ public class DashboardController( if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required." }); - var task = new WorkTask - { - Title = request.Title.Trim(), - Detail = request.Detail?.Trim(), - Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(), - Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), - AssignedTo = request.AssignedTo?.Trim(), - }; - - await taskRepo.AddAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent - { - Type = "task", - Message = $"Task \"{task.Title}\" created ({task.Source})" - }, ct); - + var task = await taskService.CreateDashboardTaskAsync( + request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); } - /// - /// Updates an existing task (title, detail, source, priority, assignedTo). - /// [HttpPut("tasks/{id:guid}")] public async Task> UpdateTask( Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) - return NotFound(new { error = "Task not found." }); - - if (!string.IsNullOrWhiteSpace(request.Title)) - task.Title = request.Title.Trim(); - if (request.Detail is not null) - task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim(); - if (!string.IsNullOrWhiteSpace(request.Source)) - task.Source = request.Source.Trim(); - if (!string.IsNullOrWhiteSpace(request.Priority)) - task.Priority = request.Priority.Trim(); - if (request.AssignedTo is not null) - task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim(); - - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent + var result = await taskService.UpdateDashboardTaskAsync( + id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct); + return result.Outcome switch { - Type = "task", - Message = $"Task \"{task.Title}\" updated" - }, ct); - - return Ok(MapToDto(task)); + TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), + _ => Ok(MapToDto(result.Task!)) + }; } - /// - /// Deletes a task (only if status is 'Done' or 'Backlog'). - /// [HttpDelete("tasks/{id:guid}")] public async Task DeleteTask(Guid id, CancellationToken ct) { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) - return NotFound(new { error = "Task not found." }); - - if (!TaskStateHelper.IsDoneOrBacklog(task.State)) - return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }); - - await activityRepo.AddAsync(new ActivityEvent + var result = await taskService.DeleteAsync(id, ct); + return result.Outcome switch { - Type = "task", - Message = $"Task \"{task.Title}\" deleted" - }, ct); - await taskRepo.DeleteAsync(task, ct); - - return NoContent(); + TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), + TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }), + _ => NoContent() + }; } - /// - /// Changes the status of a task. - /// [HttpPatch("tasks/{id:guid}/status")] public async Task> UpdateTaskStatus( Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) { - if (!TaskStateHelper.IsValidState(request.Status)) - return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }); - - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) - return NotFound(new { error = "Task not found." }); - - var canonicalState = TaskStateHelper.AllStates.First(s => - s.Equals(request.Status, StringComparison.OrdinalIgnoreCase)); - task.State = canonicalState; - - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent + var result = await taskService.UpdateStatusAsync(id, request.Status, ct); + return result.Outcome switch { - Type = "task", - Message = $"Task \"{task.Title}\" β†’ {canonicalState}" - }, ct); - - return Ok(MapToDto(task)); + TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }), + TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), + _ => Ok(MapToDto(result.Task!)) + }; } - // ========== Helpers ========== - private static DashboardTaskDto MapToDto(WorkTask t) => new( - t.Id, - t.Title, - t.Detail, - t.Source, - t.State, - t.Priority, - t.AssignedTo, - t.CreatedAt, - t.UpdatedAt - ); - - + t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt); } diff --git a/backend/Controllers/DocsController.cs b/backend/Controllers/DocsController.cs index 4572ec5..f531ece 100644 --- a/backend/Controllers/DocsController.cs +++ b/backend/Controllers/DocsController.cs @@ -1,47 +1,15 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.Helpers; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/docs")] -public class DocsController : ControllerBase +public class DocsController(IDocService docService) : ControllerBase { [HttpGet] public IResult GetAll() - { - var workspaceRoot = "/mnt/workspace-iris"; - var results = new List(); - - void ScanDir(string dir, string category) - { - if (!Directory.Exists(dir)) return; - foreach (var file in Directory.GetFiles(dir, "*.*")) - { - var ext = Path.GetExtension(file).ToLowerInvariant(); - if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css")) - continue; - var fi = new FileInfo(file); - results.Add(new - { - name = fi.Name, - path = file.Replace(workspaceRoot, "").TrimStart('/'), - category, - type = ext.Replace(".", ""), - size = fi.Length, - modifiedAt = fi.LastWriteTimeUtc - }); - } - } - - ScanDir("/mnt/workspace-iris/nexus-phases", "phases"); - ScanDir("/mnt/workspace-iris/skills", "skills"); - ScanDir("/mnt/workspace-iris", "workspace"); - ScanDir("/home/node/.openclaw/workspace/nexus", "nexus"); - ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases"); - - return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100)); - } + => Results.Ok(docService.GetAll()); [HttpGet("{**path}")] public async Task GetFile(string path) @@ -49,21 +17,7 @@ public class DocsController : ControllerBase if (string.IsNullOrWhiteSpace(path)) return Results.BadRequest("Path required."); - string? resolvedPath = null; - foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) - { - if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate)) - { - resolvedPath = candidate; - break; - } - } - - if (resolvedPath is null) - return Results.NotFound(); - - var content = await System.IO.File.ReadAllTextAsync(resolvedPath); - var fi = new FileInfo(resolvedPath); - return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); + var file = await docService.GetFileAsync(path); + return file is null ? Results.NotFound() : Results.Ok(file); } } diff --git a/backend/Controllers/IncidentsController.cs b/backend/Controllers/IncidentsController.cs index 91722ed..b4a4750 100644 --- a/backend/Controllers/IncidentsController.cs +++ b/backend/Controllers/IncidentsController.cs @@ -1,100 +1,20 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.Helpers; -using System.Text.RegularExpressions; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/incidents")] -public class IncidentsController : ControllerBase +public class IncidentsController(IIncidentService incidentService) : ControllerBase { [HttpGet] public async Task GetAll() - { - var basePath = "/mnt/workspace-iris/memory/incidents"; - if (!Directory.Exists(basePath)) - return Results.Ok(Array.Empty()); - - var incidents = new List(); - foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50)) - { - var fi = new FileInfo(file); - if (fi.Length > 1_000_000) continue; - var name = Path.GetFileNameWithoutExtension(file); - var content = await System.IO.File.ReadAllTextAsync(file); - - var title = name; - var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline); - if (titleMatch.Success) - title = titleMatch.Groups[1].Value.Trim(); - - var date = (string?)null; - var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})"); - if (dateMatch.Success) - date = dateMatch.Groups[1].Value; - - var severity = "unknown"; - var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline); - if (severityMatch.Success) - severity = severityMatch.Groups[1].Value.Trim(); - - var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal); - var excerpt = excerptEnd > 0 - ? content[..excerptEnd].Trim() - : content[..Math.Min(300, content.Length)].Trim(); - if (excerpt.Length > 200) - excerpt = excerpt[..200] + "\u2026"; - - incidents.Add(new - { - name = Path.GetFileName(file), - title, - date, - severity, - excerpt, - size = fi.Length - }); - } - - return Results.Ok(incidents); - } + => Results.Ok(await incidentService.GetAllAsync()); [HttpGet("{name}")] public async Task GetOne(string name) { - var basePath = "/mnt/workspace-iris/memory/incidents"; - if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath)) - return Results.BadRequest("Invalid filename."); - - if (!System.IO.File.Exists(filePath!)) - { - if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) - filePath = Path.Combine(basePath, name + ".md"); - if (!System.IO.File.Exists(filePath!)) - return Results.NotFound(); - } - - var content = await System.IO.File.ReadAllTextAsync(filePath!); - var fi = new FileInfo(filePath!); - var fileName = Path.GetFileName(filePath!); - - var title = fileName; - var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline); - if (titleMatch.Success) - title = titleMatch.Groups[1].Value.Trim(); - - var date = (string?)null; - var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})"); - if (dateMatch.Success) - date = dateMatch.Groups[1].Value; - - return Results.Ok(new - { - name = fileName, - title, - date, - content, - size = fi.Length - }); + var incident = await incidentService.GetByNameAsync(name); + return incident is null ? Results.NotFound() : Results.Ok(incident); } } diff --git a/backend/Controllers/MemoryController.cs b/backend/Controllers/MemoryController.cs index 0cc4150..91f9124 100644 --- a/backend/Controllers/MemoryController.cs +++ b/backend/Controllers/MemoryController.cs @@ -1,40 +1,15 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.Helpers; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/memory")] -public class MemoryController : ControllerBase +public class MemoryController(IMemoryService memoryService) : ControllerBase { [HttpGet] - public IResult GetAll() - { - var basePath = "/mnt/workspace-iris/memory"; - if (!Directory.Exists(basePath)) - return Results.Ok(Array.Empty()); - - var files = Directory.GetFiles(basePath, "*.md") - .Select(f => new FileInfo(f)) - .OrderByDescending(f => f.Name) - .Select(f => new - { - name = f.Name, - path = f.FullName.Replace(basePath, "").TrimStart('/'), - size = f.Length, - modifiedAt = f.LastWriteTimeUtc - }) - .ToList(); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (System.IO.File.Exists(longTermPath)) - { - var fi = new FileInfo(longTermPath); - files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc }); - } - - return Results.Ok(files); - } + public async Task GetAll() + => Results.Ok(await memoryService.GetAllAsync()); [HttpGet("search")] public async Task Search([FromQuery] string q) @@ -42,67 +17,13 @@ public class MemoryController : ControllerBase if (string.IsNullOrWhiteSpace(q) || q.Length < 2) return Results.BadRequest("Query must be at least 2 characters."); - var basePath = "/mnt/workspace-iris/memory"; - var results = new List(); - - const int maxFiles = 50; - const int maxFileSize = 1_000_000; - - async Task SearchDir(string dir) - { - if (!Directory.Exists(dir)) return; - var files = Directory.GetFiles(dir, "*.md").Take(maxFiles); - foreach (var file in files) - { - var fi = new FileInfo(file); - if (fi.Length > maxFileSize) continue; - string content; - using (var reader = new StreamReader(file)) - content = await reader.ReadToEndAsync(); - if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) - { - var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); - var start = Math.Max(0, idx - 60); - var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; - results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length }); - } - } - } - - await SearchDir(basePath); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (System.IO.File.Exists(longTermPath)) - { - string content; - using (var reader = new StreamReader(longTermPath)) - content = await reader.ReadToEndAsync(); - if (content.Contains(q, StringComparison.OrdinalIgnoreCase)) - { - var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase); - var start = Math.Max(0, idx - 60); - var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026"; - results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length }); - } - } - - return Results.Ok(results); + return Results.Ok(await memoryService.SearchAsync(q)); } [HttpGet("{name}")] public async Task GetFile(string name) { - if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) - return Results.BadRequest("Invalid filename."); - - var longTermPath = "/mnt/workspace-iris/MEMORY.md"; - if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase)) - filePath = longTermPath; - - if (!System.IO.File.Exists(filePath!)) - return Results.NotFound(); - - var content = await System.IO.File.ReadAllTextAsync(filePath!); - return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) }); + var file = await memoryService.GetFileAsync(name); + return file is null ? Results.NotFound() : Results.Ok(file); } } diff --git a/backend/Controllers/OperationsController.cs b/backend/Controllers/OperationsController.cs index 89dbb59..543c838 100644 --- a/backend/Controllers/OperationsController.cs +++ b/backend/Controllers/OperationsController.cs @@ -1,71 +1,13 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.Data; -using Nexus.Api.Integrations; -using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/operations")] -public class OperationsController( - IAgentRuntime runtime, - IAgentService agentService, - IProjectRepository projectRepo, - ITaskRepository taskRepo, - IActivityRepository activityRepo) : ControllerBase +public class OperationsController(IOperationsService operationsService) : ControllerBase { [HttpGet("snapshot")] public async Task GetSnapshot(CancellationToken ct) - { - var runtimeTask = runtime.GetStatusAsync(ct); - var agentsTask = agentService.GetAgentsAsync(ct); - var projectsTask = projectRepo.GetAllAsync(ct); - var tasksTask = taskRepo.GetAllAsync(ct); - var activityTask = activityRepo.GetRecentAsync(20, ct); - await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); - - var tasks = tasksTask.Result; - var projects = projectsTask.Result; - var agents = agentsTask.Result; - var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); - - var runtimeStatus = runtimeTask.Result; - var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online; - - var lastIncident = tasks - .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) - .OrderByDescending(x => x.UpdatedAt) - .Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt }) - .FirstOrDefault(); - - var projectHealth = new - { - Online = projects.Count(x => x.Status == OperationalStatus.Online), - Offline = projects.Count(x => x.Status == OperationalStatus.Offline), - Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded), - Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown) - }; - - return Results.Ok(new - { - generatedAt = DateTimeOffset.UtcNow, - runtime = runtimeStatus, - models = Array.Empty(), - runtimeHealthy, - metrics = new - { - activeAgents = agents.Count, - queuedTasks = tasks.Count - completedTasks, - successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1), - incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) - }, - lastIncident, - projectHealth, - agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), - projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), - tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), - activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) - }); - } + => Results.Ok(await operationsService.GetSnapshotAsync(ct)); } diff --git a/backend/Controllers/ProjectsController.cs b/backend/Controllers/ProjectsController.cs index 06c18fb..d9f93a4 100644 --- a/backend/Controllers/ProjectsController.cs +++ b/backend/Controllers/ProjectsController.cs @@ -1,17 +1,23 @@ using Microsoft.AspNetCore.Mvc; -using Nexus.Api.Data; using Nexus.Api.DTOs; -using Nexus.Api.Repositories; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/projects")] -public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase +public class ProjectsController(IProjectService projectService) : ControllerBase { [HttpGet] public async Task GetAll(CancellationToken ct) - => Results.Ok(await projectRepo.GetAllAsync(ct)); + => Results.Ok(await projectService.GetAllAsync(ct)); + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id, CancellationToken ct) + { + var project = await projectService.GetByIdAsync(id, ct); + return project is null ? Results.NotFound() : Results.Ok(project); + } [HttpPost] public async Task Create([FromBody] CreateProjectRequest request, CancellationToken ct) @@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit if (string.IsNullOrWhiteSpace(request.Name)) return Results.ValidationProblem(new Dictionary { ["name"] = ["Name is required."] }); - var project = new Project - { - Name = request.Name.Trim(), - Description = request.Description?.Trim() ?? string.Empty, - Status = OperationalStatus.Online - }; - await projectRepo.AddAsync(project, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct); + var project = await projectService.CreateAsync(request, ct); return Results.Created($"/api/v1/projects/{project.Id}", project); } - [HttpGet("{id:guid}")] - public async Task GetById(Guid id, CancellationToken ct) - { - var project = await projectRepo.GetByIdAsync(id, ct); - return project is null ? Results.NotFound() : Results.Ok(project); - } - [HttpPatch("{id:guid}")] public async Task Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct) { - var project = await projectRepo.GetByIdAsync(id, ct); - if (project is null) return Results.NotFound(); - - if (!string.IsNullOrWhiteSpace(request.Name)) - project.Name = request.Name.Trim(); - if (request.Description is not null) - project.Description = request.Description.Trim(); - if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse(request.Status, true, out var parsedStatus)) - project.Status = parsedStatus; - - await projectRepo.UpdateAsync(project, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct); - return Results.Ok(project); + var project = await projectService.UpdateAsync(id, request, ct); + return project is null ? Results.NotFound() : Results.Ok(project); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) { - var project = await projectRepo.GetByIdAsync(id, ct); - if (project is null) return Results.NotFound(); - - var hasTasks = await projectRepo.HasTasksAsync(id, ct); - if (hasTasks) + var result = await projectService.DeleteAsync(id, ct); + return result.Outcome switch { - project.Status = OperationalStatus.Offline; - await projectRepo.UpdateAsync(project, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct); - return Results.Ok(project); - } - - await projectRepo.DeleteAsync(project, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct); - return Results.NoContent(); + ProjectDeleteOutcome.NotFound => Results.NotFound(), + ProjectDeleteOutcome.Archived => Results.Ok(result.Project), + _ => Results.NoContent() + }; } } diff --git a/backend/Controllers/TasksController.cs b/backend/Controllers/TasksController.cs index 1c19edf..953cab7 100644 --- a/backend/Controllers/TasksController.cs +++ b/backend/Controllers/TasksController.cs @@ -1,17 +1,17 @@ using Microsoft.AspNetCore.Mvc; using Nexus.Api.Data; using Nexus.Api.DTOs; -using Nexus.Api.Repositories; +using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/tasks")] -public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase +public class TasksController(ITaskService taskService) : ControllerBase { [HttpGet] public async Task GetAll(CancellationToken ct) - => Results.Ok(await taskRepo.GetAllAsync(ct)); + => Results.Ok(await taskService.GetAllAsync(ct)); [HttpPost] public async Task Create([FromBody] CreateTaskRequest request, CancellationToken ct) @@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ if (string.IsNullOrWhiteSpace(request.Title)) return Results.ValidationProblem(new Dictionary { ["title"] = ["Title is required."] }); - var task = new WorkTask - { - Title = request.Title.Trim(), - Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), - ProjectId = request.ProjectId - }; - await taskRepo.AddAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct); + var task = await taskService.CreateAsync(request, ct); return Results.Created($"/api/v1/tasks/{task.Id}", task); } [HttpGet("pending-approval")] public async Task GetPendingApproval(CancellationToken ct) { - var pending = await taskRepo.GetPendingApprovalAsync(ct); + var pending = await taskService.GetPendingApprovalAsync(ct); return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); } [HttpPost("{id:guid}/approve")] public async Task Approve(Guid id, CancellationToken ct) { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) - return Results.Problem( + var result = await taskService.ApproveAsync(id, ct); + return result.Outcome switch + { + TaskOperationOutcome.NotFound => Results.NotFound(), + TaskOperationOutcome.InvalidState => Results.Problem( title: "Approval denied", detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", - statusCode: StatusCodes.Status403Forbidden); - - task.State = TaskStateHelper.ToStateString(TaskState.Done); - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct); - return Results.Ok(task); + statusCode: StatusCodes.Status403Forbidden), + _ => Results.Ok(result.Task) + }; } [HttpPost("{id:guid}/reject")] public async Task Reject(Guid id, CancellationToken ct) { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) - return Results.Problem( + var result = await taskService.RejectAsync(id, ct); + return result.Outcome switch + { + TaskOperationOutcome.NotFound => Results.NotFound(), + TaskOperationOutcome.InvalidState => Results.Problem( title: "Rejection denied", detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", - statusCode: StatusCodes.Status403Forbidden); - - task.State = TaskStateHelper.ToStateString(TaskState.Backlog); - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct); - return Results.Ok(task); + statusCode: StatusCodes.Status403Forbidden), + _ => Results.Ok(result.Task) + }; } [HttpPatch("{id:guid}/state")] public async Task UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct) { - var allowedStates = TaskStateHelper.AllStates; - if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase)) + if (!TaskStateHelper.IsValidState(request.State)) return Results.ValidationProblem(new Dictionary { ["state"] = ["Unsupported task state."] }); - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) return Results.NotFound(); - task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); - return Results.Ok(task); - } - - [HttpDelete("{id:guid}")] - public async Task Delete(Guid id, CancellationToken ct) - { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) return Results.NotFound(); - - if (!TaskStateHelper.IsDoneOrBacklog(task.State)) - return Results.Problem( - title: "Task deletion denied", - detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.", - statusCode: StatusCodes.Status403Forbidden); - - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct); - await taskRepo.DeleteAsync(task, ct); - return Results.NoContent(); + var result = await taskService.UpdateStateAsync(id, request.State, ct); + return result.Outcome switch + { + TaskOperationOutcome.NotFound => Results.NotFound(), + _ => Results.Ok(result.Task) + }; } [HttpPatch("{id:guid}")] public async Task Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) { - var task = await taskRepo.GetByIdAsync(id, ct); - if (task is null) return Results.NotFound(); + var result = await taskService.UpdateAsync(id, request, ct); + return result.Outcome switch + { + TaskOperationOutcome.NotFound => Results.NotFound(), + _ => Results.Ok(result.Task) + }; + } - if (!string.IsNullOrWhiteSpace(request.Title)) - task.Title = request.Title.Trim(); - if (!string.IsNullOrWhiteSpace(request.Priority)) - task.Priority = request.Priority.Trim(); - if (request.ProjectId.HasValue) - task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; - - await taskRepo.UpdateAsync(task, ct); - await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct); - return Results.Ok(task); + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, CancellationToken ct) + { + var result = await taskService.DeleteAsync(id, ct); + return result.Outcome switch + { + TaskOperationOutcome.NotFound => Results.NotFound(), + TaskOperationOutcome.InvalidState => Results.Problem( + title: "Task deletion denied", + detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.", + statusCode: StatusCodes.Status403Forbidden), + _ => Results.NoContent() + }; } } diff --git a/backend/Controllers/TeamController.cs b/backend/Controllers/TeamController.cs index 89e8368..6693dd9 100644 --- a/backend/Controllers/TeamController.cs +++ b/backend/Controllers/TeamController.cs @@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers; [ApiController] [Route("api/v1/team")] -public class TeamController(IAgentService agentService) : ControllerBase +public class TeamController(ITeamService teamService) : ControllerBase { [HttpGet] public async Task GetTeam(CancellationToken ct) - { - var agents = await agentService.GetAgentsAsync(ct); - var team = new List(); - - foreach (var agent in agents) - { - string identity = ""; - string workspace = agent.Workspace ?? ""; - if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace)) - { - var identityFile = Path.Combine(workspace, "IDENTITY.md"); - if (System.IO.File.Exists(identityFile)) - { - var content = await System.IO.File.ReadAllTextAsync(identityFile, ct); - var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8); - identity = string.Join("\n", lines); - } - } - - team.Add(new - { - agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description, - identity - }); - } - - return Results.Ok(team); - } + => Results.Ok(await teamService.GetTeamAsync(ct)); } diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs index 863ff4a..3516818 100644 --- a/backend/Models/Dashboard.cs +++ b/backend/Models/Dashboard.cs @@ -11,7 +11,12 @@ public sealed record DashboardAgentInfo( string[] Tags, int Progress = 0, int Workload = 0, - string? Goal = null + string? Goal = null, + string RoleBadge = "badge-slate", + string StatusLabel = "Bereit", + string? Elapsed = null, + string? Think = null, + string? Next = null ); public sealed record MessageEntry( diff --git a/backend/Program.cs b/backend/Program.cs index 46f75d4..e96fa1b 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -112,7 +112,7 @@ builder.Services.AddHttpClient("gateway", client => client.Timeout = TimeSpan.FromSeconds(120); }); -builder.Services.AddHttpClient(client => +builder.Services.AddHttpClient(client => { client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] ?? "http://127.0.0.1:18789"); @@ -123,6 +123,16 @@ builder.Services.AddHttpClient(client => builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); // --- Repositories --- builder.Services.AddScoped(); diff --git a/backend/Repositories/IUserRepository.cs b/backend/Repositories/IUserRepository.cs index d1ab7f2..0f8cb6e 100644 --- a/backend/Repositories/IUserRepository.cs +++ b/backend/Repositories/IUserRepository.cs @@ -10,12 +10,11 @@ public interface IUserRepository Task AddAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default); - // Refresh token operations Task GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); + Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default); + Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default); Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default); - - Task SaveChangesAsync(CancellationToken ct = default); } diff --git a/backend/Repositories/UserRepository.cs b/backend/Repositories/UserRepository.cs index 38e2a92..f599695 100644 --- a/backend/Repositories/UserRepository.cs +++ b/backend/Repositories/UserRepository.cs @@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default) => db.SaveChangesAsync(ct); + public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default) + { + var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct); + if (token is null || token.RevokedAt is not null) return; + + token.RevokedAt = DateTimeOffset.UtcNow; + token.ConcurrencyStamp = Guid.NewGuid(); + await db.SaveChangesAsync(ct); + } + + public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default) + { + var activeTokens = await db.RefreshTokens + .Where(r => r.FamilyId == familyId && r.RevokedAt == null) + .ToListAsync(ct); + + if (activeTokens.Count == 0) return; + + var now = DateTimeOffset.UtcNow; + foreach (var token in activeTokens) + { + token.RevokedAt = now; + token.ConcurrencyStamp = Guid.NewGuid(); + } + await db.SaveChangesAsync(ct); + } + public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default) { var cutoff = DateTimeOffset.UtcNow.AddDays(-30); @@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository .ToListAsync(ct); if (oldTokens.Count > 0) + { db.RefreshTokens.RemoveRange(oldTokens); + await db.SaveChangesAsync(ct); + } } - - public Task SaveChangesAsync(CancellationToken ct = default) - => db.SaveChangesAsync(ct); } diff --git a/backend/Services/AgentConfigService.cs b/backend/Services/AgentConfigService.cs new file mode 100644 index 0000000..98517a6 --- /dev/null +++ b/backend/Services/AgentConfigService.cs @@ -0,0 +1,64 @@ +using Nexus.Api.Helpers; + +namespace Nexus.Api.Services; + +public sealed class AgentConfigService : IAgentConfigService +{ + private static readonly HashSet AllowedFiles = new(StringComparer.OrdinalIgnoreCase) + { + "IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md" + }; + + public IReadOnlyList GetConfigFiles(string agentId) + { + var workspacePath = $"/mnt/workspace-{agentId}"; + if (!Directory.Exists(workspacePath)) + return Array.Empty(); + + 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 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 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); + } +} diff --git a/backend/Services/AuthService.cs b/backend/Services/AuthService.cs index f83e4fa..96844b1 100644 --- a/backend/Services/AuthService.cs +++ b/backend/Services/AuthService.cs @@ -71,7 +71,7 @@ public sealed class AuthService : IAuthService if (token.RevokedAt is not null) { - await RevokeFamilyAsync(token.FamilyId, ct); + await _users.RevokeFamilyAsync(token.FamilyId, ct); _logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId); return null; } @@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(refreshToken)) return; - var tokenHash = HashToken(refreshToken); - var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); - if (token is null || token.RevokedAt is not null) return; - - token.RevokedAt = DateTimeOffset.UtcNow; - token.ConcurrencyStamp = Guid.NewGuid(); - await _users.SaveChangesAsync(ct); + await _users.RevokeTokenAsync(tokenHash, ct); } public Task GetUserAsync(Guid userId, CancellationToken ct = default) - => Task.Run(async () => - { - // AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default) - // For read-only access, we call it but the result shouldn't be mutated - return await _users.GetByIdAsync(userId, ct); - }, ct); + => _users.GetByIdAsync(userId, ct).AsTask(); public async Task UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) { @@ -228,19 +217,6 @@ public sealed class AuthService : IAuthService return new JwtSecurityTokenHandler().WriteToken(token); } - private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct) - { - var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct); - var now = DateTimeOffset.UtcNow; - foreach (var token in activeTokens) - { - token.RevokedAt = now; - token.ConcurrencyStamp = Guid.NewGuid(); - } - - await _users.SaveChangesAsync(ct); - } - private static string GenerateRefreshToken() { var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); diff --git a/backend/Services/CalendarService.cs b/backend/Services/CalendarService.cs new file mode 100644 index 0000000..bf7d205 --- /dev/null +++ b/backend/Services/CalendarService.cs @@ -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 logger) : ICalendarService +{ + public async Task> 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>(ct); + return data ?? new List(); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data"); + } + + return BuildFallbackCronJobs(); + } + + public async Task> 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>(ct); + return data ?? new List(); + } + } + 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 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 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 * * *") + ]; + } +} diff --git a/backend/Services/DashboardService.cs b/backend/Services/DashboardService.cs new file mode 100644 index 0000000..2bbe5f3 --- /dev/null +++ b/backend/Services/DashboardService.cs @@ -0,0 +1,209 @@ +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +public sealed class DashboardService( + IOpenClawGatewayClient gateway, + ITaskService taskService, + ILogger logger) : IDashboardService +{ + public async Task 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> GetAgentsAsync() + { + try + { + return await gateway.GetAgentsAsync(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard agents fetch failed"); + return []; + } + } + + public async Task> 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 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> 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> GetQueueAsync(CancellationToken ct) + { + try + { + var cronTask = gateway.GetQueueAsync(); + var tasksTask = taskService.GetOpenAsync(ct); + await Task.WhenAll(cronTask, tasksTask); + + var merged = new List(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 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 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 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 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> 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 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 PriorityOrder = new(StringComparer.OrdinalIgnoreCase) + { + ["high"] = 0, ["medium"] = 1, ["low"] = 2 + }; +} diff --git a/backend/Services/DocService.cs b/backend/Services/DocService.cs new file mode 100644 index 0000000..1b347f0 --- /dev/null +++ b/backend/Services/DocService.cs @@ -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 GetAll() + { + var results = new List(); + + 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 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); + } +} diff --git a/backend/Services/IAgentConfigService.cs b/backend/Services/IAgentConfigService.cs new file mode 100644 index 0000000..e5fd1fb --- /dev/null +++ b/backend/Services/IAgentConfigService.cs @@ -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 GetConfigFiles(string agentId); + Task GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default); + Task SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default); +} diff --git a/backend/Services/ICalendarService.cs b/backend/Services/ICalendarService.cs new file mode 100644 index 0000000..59bb97d --- /dev/null +++ b/backend/Services/ICalendarService.cs @@ -0,0 +1,9 @@ +using Nexus.Api.DTOs; + +namespace Nexus.Api.Services; + +public interface ICalendarService +{ + Task> GetCronJobsAsync(CancellationToken ct = default); + Task> GetUpcomingCronJobsAsync(CancellationToken ct = default); +} diff --git a/backend/Services/IDashboardService.cs b/backend/Services/IDashboardService.cs new file mode 100644 index 0000000..911dc96 --- /dev/null +++ b/backend/Services/IDashboardService.cs @@ -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 GetStatusAsync(); + Task> GetAgentsAsync(); + Task> GetOperationsAsync(int limit, string? agentFilter); + Task SendChatAsync(string agentId, string message); + Task> GetMessagesAsync(string? sessionKey, int limit, int offset); + Task> GetQueueAsync(CancellationToken ct); + Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct); + Task CycleQueuePriorityAsync(string id, CancellationToken ct); + Task GetAgentModelAsync(string agentId); + Task SetAgentModelAsync(string agentId, string model); + Task> GetAgentActivityAsync(string agentId, int limit); + List GetAvailableModels(); +} diff --git a/backend/Services/IDocService.cs b/backend/Services/IDocService.cs new file mode 100644 index 0000000..e1c60e9 --- /dev/null +++ b/backend/Services/IDocService.cs @@ -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 GetAll(); + Task GetFileAsync(string path); +} diff --git a/backend/Services/IIncidentService.cs b/backend/Services/IIncidentService.cs new file mode 100644 index 0000000..be2b0c3 --- /dev/null +++ b/backend/Services/IIncidentService.cs @@ -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> GetAllAsync(); + Task GetByNameAsync(string name); +} diff --git a/backend/Services/IMemoryService.cs b/backend/Services/IMemoryService.cs new file mode 100644 index 0000000..baa1aff --- /dev/null +++ b/backend/Services/IMemoryService.cs @@ -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> GetAllAsync(); + Task> SearchAsync(string query); + Task GetFileAsync(string name); +} diff --git a/backend/Services/IOpenClawGatewayClient.cs b/backend/Services/IOpenClawGatewayClient.cs new file mode 100644 index 0000000..a1378cb --- /dev/null +++ b/backend/Services/IOpenClawGatewayClient.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Nodes; +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +public interface IOpenClawGatewayClient +{ + Task InvokeToolAsync(string tool, object? args = null); + Task GetStatusAsync(); + Task> GetAgentsAsync(); + Task> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0); + Task> GetAllAgentOperationsAsync(int limit = 30); + Task SendChatMessageAsync(string agentId, string message); + Task> GetQueueAsync(); + Task DeleteCronJobAsync(string id); + Task GetAgentModelAsync(string agentId); + Task SetAgentModelAsync(string agentId, string model); + Task> GetAgentActivityAsync(string agentId, int limit = 5); + List GetAvailableModels(); +} diff --git a/backend/Services/IOperationsService.cs b/backend/Services/IOperationsService.cs new file mode 100644 index 0000000..736c609 --- /dev/null +++ b/backend/Services/IOperationsService.cs @@ -0,0 +1,6 @@ +namespace Nexus.Api.Services; + +public interface IOperationsService +{ + Task GetSnapshotAsync(CancellationToken ct = default); +} diff --git a/backend/Services/IProjectService.cs b/backend/Services/IProjectService.cs new file mode 100644 index 0000000..652720a --- /dev/null +++ b/backend/Services/IProjectService.cs @@ -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> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task CreateAsync(CreateProjectRequest request, CancellationToken ct = default); + Task UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); +} diff --git a/backend/Services/ITaskService.cs b/backend/Services/ITaskService.cs new file mode 100644 index 0000000..7bd7d6b --- /dev/null +++ b/backend/Services/ITaskService.cs @@ -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> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetPendingApprovalAsync(CancellationToken ct = default); + Task CreateAsync(CreateTaskRequest request, CancellationToken ct = default); + Task ApproveAsync(Guid id, CancellationToken ct = default); + Task RejectAsync(Guid id, CancellationToken ct = default); + Task UpdateStateAsync(Guid id, string state, CancellationToken ct = default); + Task UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); + + // Dashboard-facing task operations + Task> GetOpenAsync(CancellationToken ct = default); + Task CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default); + Task UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default); + Task UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); + Task CompleteViaQueueAsync(Guid id, CancellationToken ct = default); + Task CyclePriorityAsync(Guid id, CancellationToken ct = default); +} diff --git a/backend/Services/ITeamService.cs b/backend/Services/ITeamService.cs new file mode 100644 index 0000000..3916e38 --- /dev/null +++ b/backend/Services/ITeamService.cs @@ -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> GetTeamAsync(CancellationToken ct = default); +} diff --git a/backend/Services/IncidentService.cs b/backend/Services/IncidentService.cs new file mode 100644 index 0000000..7562091 --- /dev/null +++ b/backend/Services/IncidentService.cs @@ -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> GetAllAsync() + { + if (!Directory.Exists(BasePath)) + return Array.Empty(); + + var incidents = new List(); + 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 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(); +} diff --git a/backend/Services/MemoryService.cs b/backend/Services/MemoryService.cs new file mode 100644 index 0000000..0498bb6 --- /dev/null +++ b/backend/Services/MemoryService.cs @@ -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> GetAllAsync() + { + var files = new List(); + + 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>(files); + } + + public async Task> SearchAsync(string query) + { + var results = new List(); + + 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 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!)); + } +} diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs index 15c7d1f..ed7d553 100644 --- a/backend/Services/OpenClawGatewayClient.cs +++ b/backend/Services/OpenClawGatewayClient.cs @@ -6,7 +6,7 @@ using Nexus.Api.Models; namespace Nexus.Api.Services; -public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) +public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -202,7 +202,12 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration Tags: tags, Progress: progress, Workload: workload, - Goal: goal + Goal: goal, + RoleBadge: DeriveRoleBadge(id), + StatusLabel: DeriveStatusLabel(isActive, status), + Elapsed: FormatElapsed(status), + Think: null, + Next: DeriveNext(isActive, currentTask) )); } return agents; @@ -415,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration if (toolResult is null) return result; - var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o"))); + var json = toolResult.ToJsonString(); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -840,8 +845,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration // 3. Look for "## Oberstes Prinzip" as second choice inRoleSection = false; - reader = new StringReader(soul); - while ((line = reader.ReadLine()) is not null) + using var reader2 = new StringReader(soul); + while ((line = reader2.ReadLine()) is not null) { var trimmed = line.Trim(); if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0) @@ -1060,6 +1065,50 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration return slash > 0 ? modelId[..slash] : "unknown"; } + private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch + { + "iris" => "badge-purple", + "programmer" or "developer" => "badge-blue", + "reviewer" => "badge-amber", + "architekt" => "badge-cyan", + "executor" => "badge-rose", + "researcher" => "badge-green", + _ => "badge-slate" + }; + + private static string DeriveStatusLabel(bool isActive, JsonNode? status) + { + if (!isActive) return "Bereit"; + var statusText = status?["status"]?.GetValue()?.ToLowerInvariant(); + return statusText switch + { + "thinking" or "think" => "Plant", + "blocked" or "block" => "Blockiert", + _ => "Arbeitet" + }; + } + + private static string? FormatElapsed(JsonNode? status) + { + var lastActivity = status?["lastActivity"]?.GetValue() + ?? status?["lastMessage"]?.GetValue(); + if (lastActivity is null) return null; + if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null; + var diff = DateTimeOffset.UtcNow - ts; + if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h"; + return $"{(int)diff.TotalDays}d"; + } + + private static string DeriveNext(bool isActive, string? currentTask) + { + if (!isActive) return "Standby"; + if (!string.IsNullOrWhiteSpace(currentTask) && currentTask != "Working...") + return currentTask.Length > 60 ? currentTask[..60] + "…" : currentTask; + return "Aufgabe ausfΓΌhren"; + } + private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Chief of Staff", diff --git a/backend/Services/OperationsService.cs b/backend/Services/OperationsService.cs new file mode 100644 index 0000000..04c1649 --- /dev/null +++ b/backend/Services/OperationsService.cs @@ -0,0 +1,62 @@ +using Nexus.Api.Data; +using Nexus.Api.Integrations; +using Nexus.Api.Repositories; + +namespace Nexus.Api.Services; + +public sealed class OperationsService( + IAgentRuntime runtime, + IAgentService agentService, + IProjectRepository projectRepo, + ITaskRepository taskRepo, + IActivityRepository activityRepo) : IOperationsService +{ + public async Task GetSnapshotAsync(CancellationToken ct = default) + { + var runtimeTask = runtime.GetStatusAsync(ct); + var agentsTask = agentService.GetAgentsAsync(ct); + var projectsTask = projectRepo.GetAllAsync(ct); + var tasksTask = taskRepo.GetAllAsync(ct); + var activityTask = activityRepo.GetRecentAsync(20, ct); + await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask); + + var tasks = tasksTask.Result; + var projects = projectsTask.Result; + var agents = agentsTask.Result; + var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done)); + var runtimeStatus = runtimeTask.Result; + + var lastIncident = tasks + .Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) + .OrderByDescending(x => x.UpdatedAt) + .Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt }) + .FirstOrDefault(); + + return new + { + generatedAt = DateTimeOffset.UtcNow, + runtime = runtimeStatus, + models = Array.Empty(), + runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online, + metrics = new + { + activeAgents = agents.Count, + queuedTasks = tasks.Count - completedTasks, + successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1), + incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked)) + }, + lastIncident, + projectHealth = new + { + Online = projects.Count(x => x.Status == OperationalStatus.Online), + Offline = projects.Count(x => x.Status == OperationalStatus.Offline), + Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded), + Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown) + }, + agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }), + projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }), + tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }), + activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }) + }; + } +} diff --git a/backend/Services/ProjectService.cs b/backend/Services/ProjectService.cs new file mode 100644 index 0000000..e1fe3da --- /dev/null +++ b/backend/Services/ProjectService.cs @@ -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> GetAllAsync(CancellationToken ct = default) + => await projectRepo.GetAllAsync(ct); + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + => await projectRepo.GetByIdAsync(id, ct); + + public async Task 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 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(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 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); + } +} diff --git a/backend/Services/TaskService.cs b/backend/Services/TaskService.cs new file mode 100644 index 0000000..3986f18 --- /dev/null +++ b/backend/Services/TaskService.cs @@ -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> GetAllAsync(CancellationToken ct = default) + => await taskRepo.GetAllAsync(ct); + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + => await taskRepo.GetByIdAsync(id, ct); + + public async Task> GetPendingApprovalAsync(CancellationToken ct = default) + => await taskRepo.GetPendingApprovalAsync(ct); + + public async Task 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 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 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 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 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 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> 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 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 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 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 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 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); + } +} diff --git a/backend/Services/TeamService.cs b/backend/Services/TeamService.cs new file mode 100644 index 0000000..ef65ed6 --- /dev/null +++ b/backend/Services/TeamService.cs @@ -0,0 +1,34 @@ +namespace Nexus.Api.Services; + +public sealed class TeamService(IAgentService agentService) : ITeamService +{ + public async Task> GetTeamAsync(CancellationToken ct = default) + { + var agents = await agentService.GetAgentsAsync(ct); + var team = new List(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 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)); + } +} diff --git a/compose.yaml b/compose.yaml index 9ca013d..b9762db 100644 --- a/compose.yaml +++ b/compose.yaml @@ -87,7 +87,9 @@ services: window: 120s ports: - "127.0.0.1:18880:80" - depends_on: [api] + depends_on: + api: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] interval: 30s diff --git a/frontend/src/components/dashboard/v2/AgentDetailModal.vue b/frontend/src/components/dashboard/v2/AgentDetailModal.vue index e1362eb..537fa59 100644 --- a/frontend/src/components/dashboard/v2/AgentDetailModal.vue +++ b/frontend/src/components/dashboard/v2/AgentDetailModal.vue @@ -1,25 +1,10 @@ diff --git a/frontend/src/components/dashboard/v2/IrisChat.vue b/frontend/src/components/dashboard/v2/IrisChat.vue index a1c896d..dcae8a4 100644 --- a/frontend/src/components/dashboard/v2/IrisChat.vue +++ b/frontend/src/components/dashboard/v2/IrisChat.vue @@ -1,18 +1,5 @@ diff --git a/frontend/src/components/dashboard/v2/TaskStrip.vue b/frontend/src/components/dashboard/v2/TaskStrip.vue index 67af55d..1cc06b3 100644 --- a/frontend/src/components/dashboard/v2/TaskStrip.vue +++ b/frontend/src/components/dashboard/v2/TaskStrip.vue @@ -1,11 +1,4 @@ diff --git a/frontend/src/stores/agents.ts b/frontend/src/stores/agents.ts index 97cf569..8cf7c7f 100644 --- a/frontend/src/stores/agents.ts +++ b/frontend/src/stores/agents.ts @@ -27,6 +27,11 @@ interface DashboardAgentInfo { progress?: number workload?: number goal?: string | null + roleBadge?: string + statusLabel?: string + elapsed?: string | null + think?: string | null + next?: string | null } interface ModelOption { @@ -35,25 +40,6 @@ interface ModelOption { provider: string } -/* ── Agent Catalog (static enrichment) ────────────── */ - -// Type-safe catalog for static AgentNodeData fields not provided by API -interface AgentCatalogEntry { - elapsed: string; - think: string | null; - next: string; -} - -const AGENT_CATALOG: Record = { - iris: { elapsed: '--', think: null, next: 'Standby' }, - programmer: { elapsed: '--', think: null, next: 'Standby' }, - developer: { elapsed: '--', think: null, next: 'Standby' }, - architekt: { elapsed: '--', think: null, next: 'Standby' }, - reviewer: { elapsed: '--', think: null, next: 'Standby' }, - executor: { elapsed: '--', think: null, next: 'Standby' }, - researcher: { elapsed: '--', think: null, next: 'Standby' }, -} - /* ── Status Mapping ───────────────────────────────── */ function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] { @@ -78,24 +64,24 @@ function avatarFor(id: string, name: string): string { /* ── Enrich API Agent β†’ AgentNodeData ─────────────── */ function enrichAgent(api: DashboardAgentInfo): AgentNodeData { - const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']! const status = mapStatus(api.isActive, api.currentTask) return { id: api.id, name: api.name, role: api.role, + roleBadge: api.roleBadge ?? 'badge-slate', model: api.model, avatar: avatarFor(api.id, api.name), status, - statusLabel: STATUS_LABELS[status], + statusLabel: api.statusLabel ?? STATUS_LABELS[status], task: api.currentTask, goal: api.goal ?? null, progress: api.progress ?? 0, - elapsed: cat.elapsed ?? '--', - next: cat.next ?? 'Standby', + elapsed: api.elapsed ?? '--', + next: api.next ?? 'Standby', tokens: '0', cost: '0.00', - think: cat.think ?? null, + think: api.think ?? null, } } @@ -142,8 +128,19 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia id: data.id, name: data.name, role: data.role, + roleBadge: data.roleBadge || 'badge-slate', model: displayModel, status: data.status === 'block' ? 'idle' : data.status, + statusLabel: data.statusLabel, + task: data.task, + goal: data.goal, + progress, + elapsed: data.elapsed || 'β€”', + next: data.next || 'β€”', + tokens: data.tokens || '0', + cost: data.cost || '0.00', + think: data.think, + md: data.md, tokensToday, costToday: costNum, workload: progress, @@ -163,6 +160,7 @@ export const useAgentStore = defineStore('agents', { error: null as string | null, selectedAgentId: null as string | null, refreshInterval: null as ReturnType | null, + isConnected: false, }), getters: { @@ -211,10 +209,12 @@ export const useAgentStore = defineStore('agents', { async fetchAgents() { try { const res = await apiFetch('/api/dashboard/agents') - if (!res.ok) return + if (!res.ok) { this.isConnected = false; return } const data: DashboardAgentInfo[] = await res.json() this.agents = data.map(enrichAgent) + this.isConnected = true } catch (err) { + this.isConnected = false console.warn('[AgentStore] fetchAgents failed', err) } }, @@ -262,7 +262,7 @@ export const useAgentStore = defineStore('agents', { this.refreshInterval = setInterval(() => { this.fetchAgents() this.fetchModels() - }, 30000) + }, 15000) }, stopPolling() { diff --git a/frontend/src/views/Dashboard/FlowBoard.vue b/frontend/src/views/Dashboard/FlowBoard.vue index 40118be..7724fe3 100644 --- a/frontend/src/views/Dashboard/FlowBoard.vue +++ b/frontend/src/views/Dashboard/FlowBoard.vue @@ -162,7 +162,9 @@ onUnmounted(() => { flex: 1; display: flex; flex-direction: row; - gap: 0; + gap: 18px; + padding: 18px 20px; + overflow: hidden; min-height: 0; } @@ -171,8 +173,8 @@ onUnmounted(() => { display: flex; flex-direction: column; gap: 14px; - padding: 0 18px 0 0; min-height: 0; min-width: 0; + overflow: hidden; } diff --git a/ops/deploy.sh b/ops/deploy.sh index f3235ca..38989f6 100755 --- a/ops/deploy.sh +++ b/ops/deploy.sh @@ -28,10 +28,9 @@ echo "[4/4] Verifikation..." curl -fsS http://localhost:18880/health && echo " βœ… Health-Check bestanden" echo "" -echo "=== Fertig ===" -echo "Nexus Web: http://nexus.noveria.net:18880" -echo "Login: vmbao62@hotmail.de" -echo "Passwort: wird beim ersten Start im Container-Log ausgegeben" +echo "=== Deployment abgeschlossen ===" +echo "Dashboard: https://nexus.noveria.net/dashboard" +echo "Health-API: https://nexus.noveria.net/health" echo "" -echo "Logs: docker compose logs api | grep 'Initial owner'" -echo "Status: docker compose ps" +echo "Login-Informationen: docker compose logs api | grep 'Initial owner'" +echo "Status: docker compose ps" diff --git a/ops/install-ollama-host.sh b/ops/install-ollama-host.sh index e92f9a6..7b2fb08 100755 --- a/ops/install-ollama-host.sh +++ b/ops/install-ollama-host.sh @@ -34,14 +34,17 @@ systemctl daemon-reload systemctl enable --now ollama systemctl restart ollama -for attempt in {1..30}; do +max_attempts=30 +attempt=1 +while [[ "${attempt}" -le "${max_attempts}" ]]; do if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then break fi - if [[ "${attempt}" -eq 30 ]]; then + if [[ "${attempt}" -eq "${max_attempts}" ]]; then systemctl status ollama --no-pager exit 1 fi + attempt=$((attempt + 1)) sleep 2 done diff --git a/phases/deployment.md b/phases/deployment.md index 0edcabb..8d1c733 100644 --- a/phases/deployment.md +++ b/phases/deployment.md @@ -1,18 +1,132 @@ # Deployment -> Letzte Aktualisierung: 2026-06-09 -> Status: βœ… Deployment abgeschlossen +> Letzte Aktualisierung: 2026-06-13 +> Status: βœ… CD v3 (Auto + Manual) > Live-URL: https://nexus.noveria.net -## Ziel +## CD-Philosophie (v3) -Nach Phase 1 soll das Mission-Control-Board deployt und die Infrastruktur so gesetzt sein, dass Bao direkt draufkommen kann. +- **CI lΓ€uft automatisch** bei jedem Push β†’ darf nie brechen +- **CD auto + manuell**: Automaticher Deploy nach CI-Success auf main (patch default), manueller Deploy mit voller Kontrolle via `workflow_dispatch` +- **Loop-Schutz**: Version-Bump-Commits enthalten `[skip ci]` β€” kein Re-Trigger der CI, kein Infinite-Loop +- **Main-Deploys** duerfen VERSION bumpen und einen Git-Tag setzen +- **Nicht-Main-Deploys** (anderer `git_ref`) deployen read-only und mutieren Git nicht +- **Rollback** als eigener Workflow, manuell triggerbar +- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule) + +## Workflows + +### Deploy (`.gitea/workflows/deploy.yaml`) + +**Trigger**: +- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`) + β†’ Default-Parameter: patch bump, all services, main ref +- **Manuell**: Via Gitea Actions β†’ `workflow_dispatch` + +**Loop-Schutz**: +- Version-Bump-Commits enthalten `[skip ci]` β†’ Gitea startet keine neue CI +- Auto-Deploy prΓΌft zusΓ€tzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]` +- Beide Mechanismen zusammen verhindern Endlosschleife: CI β†’ Deploy β†’ Bump β†’ CI … + +**Inputs** (nur bei `workflow_dispatch`): +| Input | Typ | Default | Beschreibung | +|---|---|---|---| +| `version_bump` | choice (patch/minor/major) | patch | Version-Bump-Typ | +| `service` | string | (all) | Einzelner Service oder alle | +| `no_cache` | boolean | false | Docker-Build-Cache deaktivieren | +| `git_ref` | string | main | Branch/Tag/Commit zum Deployen | + +**Ablauf**: +1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet +2. Checkout des gewΓ€hlten Git-Refs +3. Wenn `git_ref = main`: Version-Bump + Git-Tag + Push +4. Wenn `git_ref != main`: VERSION nur lesen, kein Push, kein Tag +5. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace +6. Code-Sync zum Host-Deploy-Pfad +7. `docker compose build && up -d --wait --force-recreate` +8. `.env`-Tempfile wird mit `shred` gelΓΆscht +9. Health-Check (exponentieller Backoff, 6 Versuche) +10. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`) +11. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL + +### Backup (`.gitea/workflows/backup.yaml`) + +**Trigger**: Manuell via Gitea Actions β†’ `workflow_dispatch` (optional: Nightly-Schedule via Cron) + +**Inputs**: +| Input | Typ | Default | Beschreibung | +|---|---|---|---| +| `keep_on_host` | boolean | false | Backup auch auf Host-Pfad kopieren | +| `host_backup_path` | string | `/opt/openclaw/backups` | Host-Zielpfad | + +**Ablauf**: +1. Backup-ID generieren (Timestamp-basiert) +2. `docker exec nexus-postgres-1 pg_dumpall -U nexus` β†’ gzip +3. Upload als Gitea-Artifact (90 Tage Retention, bereits komprimiert) +4. Optional: Kopie auf Host-Pfad via Docker-Volume-Mount +5. IntegritΓ€ts-Check: gzip-Test + SQL-Header-Validierung +6. Backup-Summary mit Restore-Befehl + +**Restore (manuell auf dem Host)**: +```bash +# Aus Gitea-Artifact herunterladen oder von Host-Pfad: +zcat nexus-backup-YYYY-MM-DDTHHMMSSZ.sql.gz | docker exec -i nexus-postgres-1 psql -U nexus -d postgres +# Danach Stack neu starten: +cd /opt/openclaw/data/openclaw/workspace/nexus +docker compose up -d --wait +``` + +**Nightly-Schedule aktivieren**: +In `backup.yaml` die Zeilen auskommentieren: +```yaml +schedule: + - cron: '0 3 * * *' # Jede Nacht um 03:00 UTC +``` + +### Rollback (`.gitea/workflows/rollback.yaml`) + +**Trigger**: Manuell via Gitea Actions β†’ `workflow_dispatch` + +**Inputs**: +| Input | Typ | Beschreibung | +|---|---|---| +| `target_tag` | string | Git-Tag zum ZurΓΌckrollen (z.B. `v0.2.49`) | +| `confirm` | string | Muss exakt `ROLLBACK` sein (Safety-Gate) | + +**Ablauf**: +1. Safety-Gate: BestΓ€tigungstext muss `ROLLBACK` sein +2. Checkout des Target-Tags +3. Tag-Validierung (existiert? welcher Commit?) +4. Safe Secret Handling (gleiches Tempfile-Pattern) +5. Code-Sync des alten Stands zum Host +6. `docker compose build --no-cache && up -d --wait --force-recreate` +7. Health-Check + Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`) +8. Bei Fehler: Reviewer-Handoff mit manueller Rollback-Anleitung + +**DB-Migration bei Rollback**: Die API fΓΌhrt `MigrateAsync` beim Start aus. Wenn die Migrationen des Rollback-Tags ein Prefix der aktuellen DB sind (Normalfall), lΓ€uft EF Core sie als No-Op. Wenn ein Rollback-Tag vor einer destruktiven Migration liegt, ist manuelles DB-Intervention nΓΆtig β€” ein Edge Case, der DevOps signalisiert wird. ## Secrets und Konfiguration -- [x] `.env.template` mit allen erforderlichen Variablen erstellt -- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt -- [x] Migration des Produktionsstacks getestet +### Secrets in Gitea + +Folgende Secrets sind in Gitea (Repo β†’ Settings β†’ Actions β†’ Secrets) konfiguriert: + +| Secret | Verwendung | +|---|---| +| `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort | +| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) | +| `ENV_OWNER_PASSWORD` | Owner-Account-Passwort | +| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token | + +### Safe Secret Handling (v3) + +**Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem. + +**Jetzt (sicher)**: +1. Secrets werden als Step-Environment aus Gitea Secrets bezogen und erst dann in `/tmp/nexus-deploy-env` (mode 600) geschrieben +2. Die Temp-Datei wird via `docker run -v` als read-only ins Compose-Environment gemountet +3. Nach Deploy/Rollback wird die Datei mit `shred -u` gelΓΆscht +4. Das `.env` erscheint **nie** im Workspace oder auf dem Host-Deploy-Pfad ## Build-Anleitung (lokal oder in CI) @@ -54,6 +168,13 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind. - [x] Nginx mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert - [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert - [x] Externe Erreichbarkeit bestaetigt (2026-06-09) +- [x] CI/CD entkoppelt β€” Deploy darf automatisch (v3) oder manuell (2026-06-13) +- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13) +- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13) +- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13) +- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13) +- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13) +- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13) ## Verifizierung (2026-06-09) @@ -66,6 +187,7 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind. ## Offene Arbeit -- [ ] Backup-Strategie fuer Produktionsdaten definieren +- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter) - [ ] Docker-Logs und Container-Health-Monitoring einrichten -- [ ] `.gitignore` final pruefen +- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren +- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern