Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b89289989a | |||
| f95463ef50 | |||
| 2d218853a5 | |||
| adae7ba26d | |||
| 3dd745586b | |||
| f0023ac033 | |||
| 73c5eb69d7 | |||
| 06eac66baa | |||
| b95bec7915 | |||
| 071be50977 | |||
| baf4008d97 | |||
| 83e072bc27 | |||
| a516353ae8 | |||
| 1df663f57c | |||
| e4091eee80 | |||
| dcc8450c62 | |||
| 12998170e3 | |||
| 691152f889 | |||
| 74ef58d274 | |||
| 5e7d074593 | |||
| c496608c86 | |||
| c040696d91 | |||
| 7ba0bd26fa | |||
| 4b1d140b53 | |||
| e0c88238da | |||
| b0e65e3980 | |||
| 648a5d2151 | |||
| 1a024eef96 | |||
| 6280e87078 | |||
| 64459ccdb3 | |||
| 38dc2efc6c | |||
| 390bffa208 | |||
| e034883abd | |||
| 6d4e8e7927 | |||
| 0f8939306d | |||
| 58675f0c69 | |||
| 88cafc7b8e | |||
| 485357c6dc | |||
| 36b32f0e88 | |||
| 8a556c25a0 | |||
| f271602f31 | |||
| 63319e1046 | |||
| b730fa1518 | |||
| fadb5d75c4 | |||
| 45a39d319f | |||
| 5ea7aa9611 | |||
| a6fabb90b0 | |||
| db62354c97 | |||
| 20dedcd6fa | |||
| 4ad0f9e493 | |||
| ac4e1cd3cf | |||
| 01c9bda339 | |||
| 1b11793dad | |||
| 98f98b55d5 | |||
| f28c398d16 | |||
| 358ec3e65d | |||
| 5f3d04f44c | |||
| d169cbe9d5 | |||
| 6cedd8410f | |||
| 9033ff2973 | |||
| 676dbd7589 | |||
| 9330de7af0 | |||
| 6023b5ea24 | |||
| 166c9f9051 | |||
| 2d6e3537e8 | |||
| 3672e56994 | |||
| f378d7aed4 | |||
| 1a7bf8ca11 | |||
| 3907548a1d | |||
| b1888bd8ef | |||
| c29740a466 | |||
| 45c6b24928 | |||
| 5fb62bef8a | |||
| 068b0d31b8 | |||
| 97b8588dc3 | |||
| 6150ea96af | |||
| 81af81fb6f | |||
| 2877035c5c | |||
| 6a1366b472 | |||
| adecfea432 | |||
| b7b44494f0 | |||
| a538025049 | |||
| 6d7454a7c1 | |||
| 3c72e807da | |||
| 702692cf0c | |||
| 51d1917a7b | |||
| 85f3400076 | |||
| a5cbe98f25 | |||
| 5b0e3a19f6 | |||
| e1d6b1eeb3 | |||
| afcbf941a9 | |||
| 49b9778872 | |||
| 6d0dab4889 | |||
| dd509a75be | |||
| e0fc305832 | |||
| c120155170 | |||
| 0241130c2f | |||
| 889af65ae7 | |||
| bdd75c9224 | |||
| f707dceb98 | |||
| 96a44233c0 | |||
| 191cb5cbd2 | |||
| 12e629432c | |||
| 47f0f1d786 | |||
| bf60b8b064 | |||
| b8498f47bb | |||
| f037aa2eeb | |||
| e6520fc26d | |||
| c9d8852609 | |||
| 11e9a257a1 | |||
| ead202ad8b | |||
| effc86e15b | |||
| 0f9809e423 | |||
| c2736d20c1 |
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx tsc *)",
|
||||||
|
"Bash(npx vite *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: '/home/projekte_bao/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: /home/projekte_bao/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
|
||||||
@@ -27,14 +27,13 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore backend/Nexus.Api.csproj
|
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release
|
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ─── Frontend ──────────────────────────────────
|
# ─── Frontend ──────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
@@ -54,16 +53,18 @@ jobs:
|
|||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# --prefer-offline: use cached packages if available in the runner image
|
|
||||||
# Lockfile IS committed — regenerated on changes via pnpm install.
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --no-frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: pnpm exec vue-tsc --noEmit
|
run: pnpm exec vue-tsc --noEmit
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -79,8 +80,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Check for .env leaks
|
- name: Check for .env leaks
|
||||||
run: |
|
run: |
|
||||||
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then
|
echo "🔍 Scanning for potential secrets in source code..."
|
||||||
echo "⚠️ Warning: Potential secrets in source code (review manually)"
|
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
|
||||||
|
echo "$HITS"
|
||||||
|
echo ""
|
||||||
|
echo "Remove these values and use environment variables or a secrets manager instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
|
||||||
|
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$LOOSE" ]; then
|
||||||
|
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
|
||||||
|
echo "$LOOSE"
|
||||||
else
|
else
|
||||||
echo "✅ No obvious secrets found"
|
echo "✅ No obvious secrets found"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+294
-123
@@ -1,169 +1,314 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
run-name: 🚀 Deploy by @${{ gitea.actor }}
|
||||||
|
|
||||||
# ── Concurrency: one deploy at a time, cancel queued ones ──
|
# ───────────────────────────────────────────────────────
|
||||||
# Why: prevents race conditions when CI triggers deploy while
|
# Owner: DevOps (Architekt)
|
||||||
# a manual deploy is still running. The latest deploy wins.
|
# CD v3 — 2026-06-13
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
||||||
|
# → Uses safe defaults: patch bump, all services, main ref.
|
||||||
|
# → Commits marked with [skip ci] are filtered at job level
|
||||||
|
# (prevents version-bump loops).
|
||||||
|
# 2. MANUAL via workflow_dispatch with full parameter control.
|
||||||
|
#
|
||||||
|
# Concurrency: one deploy at a time.
|
||||||
|
# Queued deploys wait — no race conditions with parallel builds.
|
||||||
|
#
|
||||||
|
# Version Management:
|
||||||
|
# The VERSION file in the repo root is the single source of truth.
|
||||||
|
# Version bumps happen in the Dev workflow BEFORE merge to main.
|
||||||
|
# The deploy workflow only reads, validates, and logs the version.
|
||||||
|
# The [skip ci] filter remains as a safety layer for auto-triggers.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
# Trigger: automatic after CI success, or manual dispatch.
|
|
||||||
# Runner: uses ubuntu-latest label (consistently present on
|
|
||||||
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
|
|
||||||
# Standard labels avoid custom-label matching edge cases.
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
on:
|
on:
|
||||||
|
# ── Auto-Trigger: after successful CI on main ──
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI - Build & Test"]
|
workflows: ["CI - Build & Test"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# ── Manual Trigger (full control) ──
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump_version:
|
|
||||||
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
|
|
||||||
required: false
|
|
||||||
default: 'patch'
|
|
||||||
type: string
|
|
||||||
options:
|
|
||||||
- 'patch'
|
|
||||||
- 'minor'
|
|
||||||
- 'major'
|
|
||||||
service:
|
service:
|
||||||
description: 'Service to deploy (empty = all)'
|
description: 'Service to deploy (empty = all)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
type: string
|
type: string
|
||||||
no_cache:
|
no_cache:
|
||||||
description: 'Disable build cache'
|
description: 'Disable Docker build cache'
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
git_ref:
|
||||||
|
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
|
||||||
|
required: false
|
||||||
|
default: 'main'
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Nexus
|
name: Deploy Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
|
if: |
|
||||||
|
(github.event_name == 'workflow_dispatch') ||
|
||||||
|
(github.event_name == 'workflow_run' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
|
||||||
|
|
||||||
|
# ── Env for the deploy target path ──
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||||
|
ENV_TMPFILE: /tmp/nexus-deploy-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
|
||||||
|
# This ensures the password stays consistent across deploys and the DB is the
|
||||||
|
# single source of truth after initial seed (enforced by SeedAudit guard).
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ── Step 1: Checkout ─────────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Checkout latest code
|
# Step 1: Checkout
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
# ── Step 2: Version bump (race-free) ─────
|
# ═══════════════════════════════════════════════════
|
||||||
# Derives current version from git tags (not VERSION file) to
|
# Step 2: Set up Git identity
|
||||||
# avoid race conditions where tag exists but VERSION is stale.
|
# ═══════════════════════════════════════════════════
|
||||||
# Uses --force on tag+push to handle retries after failed runs.
|
- name: Configure Git
|
||||||
- name: Version Bump
|
run: |
|
||||||
|
git config user.email "devops@noveria.net"
|
||||||
|
git config user.name "DevOps"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Resolve deploy version
|
||||||
|
#
|
||||||
|
# Reads VERSION from repo root — the single source of truth.
|
||||||
|
# Validates semver format, logs version + git metadata.
|
||||||
|
# No git mutation: version bumps happen in the Dev workflow.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Resolve Version
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Source of truth: latest git tag
|
# 1. Check VERSION exists
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
if [ ! -f VERSION ]; then
|
||||||
CURRENT_VERSION="${TAG#v}"
|
echo "❌ VERSION file not found"
|
||||||
echo "📦 Current version (from git tags): $CURRENT_VERSION"
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
|
# 2. Read and validate semver format
|
||||||
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "❌ Invalid semver in VERSION: '$VERSION'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "${{ inputs.bump_version }}" in
|
# 3. Log version, git ref, and describe
|
||||||
major)
|
GIT_REF=$(git rev-parse --short HEAD)
|
||||||
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
GIT_DESCRIBE=$(git describe --always --dirty)
|
||||||
minor)
|
|
||||||
MINOR=$((MINOR + 1)); PATCH=0 ;;
|
|
||||||
patch|*)
|
|
||||||
PATCH=$((PATCH + 1)) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
echo "📦 Deploy version: v${VERSION}"
|
||||||
echo "🏷️ New version: $NEW_VERSION"
|
echo "🔖 Git ref: ${GIT_REF}"
|
||||||
echo "$NEW_VERSION" > VERSION
|
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
|
||||||
|
|
||||||
git config user.email "devops@noveria.net"
|
# 4. Set outputs for downstream steps
|
||||||
git config user.name "DevOps"
|
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||||
git add VERSION
|
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||||
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}"
|
# Step 4: Build .env from secrets + host .env (SAFE)
|
||||||
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
|
#
|
||||||
echo "✅ Version bumped to v${NEW_VERSION}"
|
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||||
|
# to a file inside the workspace that gets rsync'd to
|
||||||
# ── Step 3: Sync code + .env to host ──────
|
# the host. The temp file is deleted immediately after
|
||||||
# Creates .env from Gitea secrets in the workspace, then syncs
|
# compose operations complete.
|
||||||
# everything (except .git) to the host deploy path via DIND.
|
#
|
||||||
- name: Sync code + .env to host
|
# OWNER_PASSWORD is read from the host's persistent .env
|
||||||
|
# to ensure it stays the single source of truth. Other
|
||||||
|
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
||||||
|
# come from Gitea secrets.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Prepare .env (secrets + host .env → temp file)
|
||||||
run: |
|
run: |
|
||||||
# Create .env from Gitea secrets in the workspace
|
set -euo pipefail
|
||||||
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
|
|
||||||
|
# Read OWNER_PASSWORD from the host's persistent .env
|
||||||
|
HOST_OWNER_PASSWORD=""
|
||||||
|
if [ -f "${DEPLOY_PATH}/.env" ]; then
|
||||||
|
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
|
||||||
|
fi
|
||||||
|
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
||||||
|
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
||||||
|
echo " The host .env is the single source of truth for the owner password."
|
||||||
|
echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
# Managed via Gitea secrets → do not edit manually on the host
|
# Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
|
||||||
|
# This file lives in /tmp and is removed after deploy completes.
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
|
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
||||||
OWNER_DISPLAY_NAME=
|
OWNER_DISPLAY_NAME=
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
ENVEOF
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Sync code to host (without .env in workspace)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# Sync everything (except .git) from workspace to host
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${{ gitea.workspace }}:/src:ro" \
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -c "
|
sh -c "
|
||||||
cd /src && \
|
cd /src && \
|
||||||
find . -mindepth 1 -maxdepth 1 \
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
! -name .git \
|
! -name .git \
|
||||||
-exec cp -a {} /dest/ \;
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
"
|
"
|
||||||
echo "✅ Code + .env synced to host deploy path"
|
|
||||||
|
|
||||||
# ── Step 4: Docker Buildx ─────────────────
|
echo "✅ Code synced to ${DEPLOY_PATH}"
|
||||||
- name: Set up Docker Buildx
|
|
||||||
run: docker buildx create --use 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Step 5: Build & Deploy ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Build & Deploy
|
||||||
|
#
|
||||||
|
# The temp .env file is bind-mounted read-only into the
|
||||||
|
# docker:cli container so compose can resolve variables.
|
||||||
|
# It is NEVER written into the workspace directory.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Build & Deploy
|
- name: Build & Deploy
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Auto-deploy: always use cache. Manual: respect no_cache input.
|
||||||
|
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
||||||
BUILD_ARGS=""
|
BUILD_ARGS=""
|
||||||
if [ "${{ inputs.no_cache }}" = "true" ]; then
|
if [ "$NO_CACHE" = "true" ]; then
|
||||||
BUILD_ARGS="--no-cache"
|
BUILD_ARGS="--no-cache"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
docker run --rm \
|
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
|
|
||||||
-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 --force-recreate ${{ inputs.service }}
|
|
||||||
else
|
|
||||||
echo '🚀 Deploying all services'
|
|
||||||
docker compose build ${BUILD_ARGS}
|
|
||||||
docker compose up -d --force-recreate
|
|
||||||
fi
|
|
||||||
"
|
|
||||||
|
|
||||||
# ── Step 6: Health Check (backoff) ────────
|
# Write the deploy script to a file to avoid nested quoting issues
|
||||||
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
|
cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
|
||||||
# Why: cold-start containers need variable warmup time;
|
#!/bin/sh
|
||||||
# fixed 5s intervals either wait too long or give up too early.
|
set -e
|
||||||
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
|
cat > /tmp/nexus-deploy-env
|
||||||
|
|
||||||
|
# ── Clean up zombie containers ──
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
|
||||||
|
docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── WAL recovery ──
|
||||||
|
PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)
|
||||||
|
if [ -n "$PG_VOL" ]; then
|
||||||
|
echo "Checking postgres WAL integrity..."
|
||||||
|
docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" \
|
||||||
|
--entrypoint sh postgres:17-alpine -c "
|
||||||
|
echo 'Resetting WAL...'
|
||||||
|
pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'
|
||||||
|
" 2>&1 || echo 'pg_resetwal failed (may be benign)'
|
||||||
|
else
|
||||||
|
echo 'Postgres volume not found - will be created fresh'
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
||||||
|
SERVICE="${DEPLOY_SERVICE:-}"
|
||||||
|
|
||||||
|
if [ -n "$SERVICE" ]; then
|
||||||
|
echo "Deploying service: $SERVICE"
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS $SERVICE
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate $SERVICE
|
||||||
|
else
|
||||||
|
echo 'Deploying all services'
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'Waiting for services to become healthy (up to 180s)...'
|
||||||
|
for i in $(seq 1 36); do
|
||||||
|
STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)
|
||||||
|
if echo "$STATUS" | grep -q 'unhealthy'; then
|
||||||
|
echo " [$i/36] Unhealthy containers - failing fast"
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env logs --tail=30
|
||||||
|
exit 1
|
||||||
|
elif echo "$STATUS" | grep -q 'starting'; then
|
||||||
|
echo " [$i/36] Still starting..."
|
||||||
|
sleep 5
|
||||||
|
else
|
||||||
|
echo 'All containers healthy'
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo 'Timeout waiting for services'
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env logs --tail=20
|
||||||
|
exit 1
|
||||||
|
DEPLOYSCRIPT
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-e "DEPLOY_BUILD_ARGS=${BUILD_ARGS:-}" \
|
||||||
|
-e "DEPLOY_SERVICE=${SERVICE_ARG:-}" \
|
||||||
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-v /tmp/nexus-deploy-script.sh:/deploy.sh:ro \
|
||||||
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
|
docker:cli \
|
||||||
|
sh /deploy.sh < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
|
rm -f /tmp/nexus-deploy-script.sh
|
||||||
|
|
||||||
|
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
|
- name: Health Check
|
||||||
run: |
|
run: |
|
||||||
echo "🏥 Health check..."
|
echo "🏥 Health check..."
|
||||||
@@ -186,11 +331,10 @@ jobs:
|
|||||||
echo "❌ Health check failed after $MAX attempts"
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# ── Step 7: Smoke test (multi-endpoint) ───
|
# ═══════════════════════════════════════════════════
|
||||||
# Tests multiple endpoints to catch partial failures.
|
# Step 9: Smoke Test
|
||||||
# Why: a single /dashboard check can miss backend-only outages;
|
# ═══════════════════════════════════════════════════
|
||||||
# /health tests the API + database + runtime status.
|
- name: Smoke Test
|
||||||
- name: Verify (smoke test)
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Smoke test..."
|
echo "🔍 Smoke test..."
|
||||||
PASS=0
|
PASS=0
|
||||||
@@ -199,7 +343,8 @@ jobs:
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
local path="$1" label="$2" expected="${3:-200}"
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
if [ "$code" = "$expected" ]; then
|
if [ "$code" = "$expected" ]; then
|
||||||
echo " ✅"
|
echo " ✅"
|
||||||
@@ -210,8 +355,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check "/dashboard" "Dashboard" 200
|
check "/dashboard" "Dashboard" 200
|
||||||
check "/health" "Health API" 200
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
@@ -219,25 +365,50 @@ jobs:
|
|||||||
echo "❌ Smoke test failed!"
|
echo "❌ Smoke test failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✅ Deployment verified"
|
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
|
||||||
|
|
||||||
# ── Step 8: Rollback hint ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
# On any failure, prints the previous deploy tag for quick manual rollback.
|
# Step 10: Deployment Summary
|
||||||
# Why: reduces MTTR (mean time to recovery) by providing the exact
|
# ═══════════════════════════════════════════════════
|
||||||
# git tag to roll back to without needing to look it up manually.
|
- name: Deployment Summary
|
||||||
- name: Rollback hint
|
if: always()
|
||||||
|
run: |
|
||||||
|
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||||
|
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 " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
||||||
|
echo " Trigger: ${TRIGGER}"
|
||||||
|
echo " Actor: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 11: Failure → Reviewer Handoff
|
||||||
|
#
|
||||||
|
# On failure: DevOps (Architekt) analyses the log,
|
||||||
|
# notifies Reviewer (Code-Fixer) with the exact error.
|
||||||
|
# This output provides a ready-to-copy message.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Failure — Reviewer Handoff
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔙 ─── Rollback Instructions ─── 🔙"
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
echo ""
|
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
|
||||||
echo " # 1. Checkout previous version:"
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Version: v${{ steps.version.outputs.version }}"
|
||||||
echo " # 2. Redeploy:"
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
|
echo "│ │"
|
||||||
echo " docker compose up -d --force-recreate"
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
echo ""
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
echo " # 3. Or trigger rollback via Gitea:"
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
echo " Trigger 'Deploy to Production' workflow with the previous tag"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
|
||||||
|
echo "│ workflow manuell in Gitea Actions. │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
name: Rollback to Previous Version
|
||||||
|
run-name: 🔙 Rollback by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Owner: DevOps (Architekt)
|
||||||
|
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
|
||||||
|
#
|
||||||
|
# This workflow reverts the deploy path to the code at a
|
||||||
|
# given git tag/ref, then rebuilds and redeploys the stack.
|
||||||
|
#
|
||||||
|
# Strategy: git checkout <tag> → docker compose up -d --build
|
||||||
|
# This is a "full restart rollback" — safest for containerized
|
||||||
|
# apps where DB schema changes may need the matching API binary.
|
||||||
|
#
|
||||||
|
# DB migrations: the API runs MigrateAsync on startup. If the
|
||||||
|
# rollback-tag's migration history is a prefix of the current DB,
|
||||||
|
# EF Core handles this gracefully (no-op for already-applied
|
||||||
|
# migrations). If the tag predates a destructive migration, manual
|
||||||
|
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_tag:
|
||||||
|
description: 'Git tag to roll back to (e.g. v0.2.49)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirm:
|
||||||
|
description: 'Type "ROLLBACK" to confirm'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rollback:
|
||||||
|
name: Rollback Nexus
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /home/projekte_bao/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_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 + host .env (safe temp file)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Prepare .env (secrets + host .env → temp file)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Read OWNER_PASSWORD from the host's persistent .env
|
||||||
|
HOST_OWNER_PASSWORD=""
|
||||||
|
if [ -f "${DEPLOY_PATH}/.env" ]; then
|
||||||
|
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
|
||||||
|
fi
|
||||||
|
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
||||||
|
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
|
POSTGRES_DB=nexus
|
||||||
|
POSTGRES_USER=nexus
|
||||||
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
|
JWT_ISSUER=nexus
|
||||||
|
JWT_AUDIENCE=nexus-web
|
||||||
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
|
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
||||||
|
OWNER_DISPLAY_NAME=
|
||||||
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 4: Sync rollback code to host
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -c "
|
||||||
|
cd /src && \
|
||||||
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name .git \
|
||||||
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Rebuild & Redeploy
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rebuild & Redeploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
|
-v "/tmp:/tmp-host:ro" \
|
||||||
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
-w /workspace/nexus \
|
||||||
|
docker:cli \
|
||||||
|
sh -c "
|
||||||
|
set -e
|
||||||
|
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||||
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||||
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Clean up temp .env
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Clean up temp .env
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "${ENV_TMPFILE}" ]; then
|
||||||
|
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||||
|
echo "🧹 Temp .env removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 7: Health Check
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Health Check
|
||||||
|
run: |
|
||||||
|
echo "🏥 Health check after rollback..."
|
||||||
|
RETRY=0
|
||||||
|
MAX=6
|
||||||
|
WAIT=1
|
||||||
|
while [ $RETRY -lt $MAX ]; do
|
||||||
|
RETRY=$((RETRY + 1))
|
||||||
|
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
|
sleep $WAIT
|
||||||
|
NEXT=$((WAIT + RETRY))
|
||||||
|
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||||
|
done
|
||||||
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 8: Smoke Test
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Smoke Test
|
||||||
|
run: |
|
||||||
|
echo "🔍 Smoke test after rollback..."
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
BASE="https://nexus.noveria.net"
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " ✅"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ (expected $expected)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check "/dashboard" "Dashboard" 200
|
||||||
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "❌ Smoke test failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 9: Rollback Summary
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rollback Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 🔙 Rollback Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " Rolled to: ${{ inputs.target_tag }}"
|
||||||
|
echo " Triggered: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 10: Failure → Reviewer Handoff
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Rollback Failed — Reviewer Handoff
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
|
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
|
||||||
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Target: ${{ inputs.target_tag }}"
|
||||||
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||||
|
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||||
|
echo "│ → Manuellen Rollback erwägen: │"
|
||||||
|
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │"
|
||||||
|
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
@@ -30,4 +30,11 @@ docker-compose.override.yml
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Crash artefacts / Core dumps
|
||||||
|
**/core
|
||||||
|
**/core.*
|
||||||
|
|
||||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
# pnpm (lockfile IS committed for reproducible CI builds)
|
||||||
|
|
||||||
|
# Claude local config (per-developer, not repo-shared)
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
||||||
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||||
|
|
||||||
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy.
|
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||||
|
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
||||||
|
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
||||||
|
> stay read-only. Rollback and database backup are separate manual workflows.
|
||||||
|
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||||
|
|
||||||
## Current foundation
|
## Current foundation
|
||||||
|
|
||||||
@@ -11,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
- ASP.NET Core 10 REST API (Minimal API pattern)
|
- ASP.NET Core 10 REST API (Minimal API pattern)
|
||||||
- Entity Framework Core and PostgreSQL
|
- Entity Framework Core and PostgreSQL
|
||||||
- JWT owner authentication with rotating refresh sessions
|
- JWT owner authentication with rotating refresh sessions
|
||||||
- `IAgentRuntime` abstraction with an OpenClaw adapter
|
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
|
||||||
- `IModelProvider` abstractions for Ollama and NVIDIA
|
|
||||||
- Responsive dark-mode operations dashboard
|
- Responsive dark-mode operations dashboard
|
||||||
- Container-only entry point on `127.0.0.1:18880`
|
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
|
||||||
|
|
||||||
## Local/container start
|
## Local/container start
|
||||||
|
|
||||||
@@ -27,12 +30,11 @@ curl http://127.0.0.1:18880/health
|
|||||||
```
|
```
|
||||||
|
|
||||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
||||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14
|
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
|
||||||
characters. Existing databases are never overwritten by the bootstrap process.
|
characters. Existing databases are never overwritten by the bootstrap process.
|
||||||
|
|
||||||
The web service is loopback-only. Public reverse-proxy activation for
|
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||||
`nexus.noveria.net` remains a separate infrastructure change and must terminate
|
Health checks, rate limiting, and security headers are active.
|
||||||
TLS before forwarding to port `18880`.
|
|
||||||
|
|
||||||
## Workspace mounts
|
## Workspace mounts
|
||||||
|
|
||||||
@@ -41,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
|
|||||||
|
|
||||||
| Host path | Container mount |
|
| Host path | Container mount |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -279,12 +281,72 @@ Backlog → Blocked → In progress / Done
|
|||||||
provider key. Conversation IDs are stable per browser and Iris is the default
|
provider key. Conversation IDs are stable per browser and Iris is the default
|
||||||
agent target.
|
agent target.
|
||||||
|
|
||||||
The configured model-routing policy is:
|
The configured model-routing policy routes through the OpenClaw Gateway only.
|
||||||
|
Ollama and NVIDIA providers have been removed. Currently active models:
|
||||||
|
|
||||||
1. `qwen3:4b` through Ollama for routine and monitoring work
|
| Agent | Model |
|
||||||
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
|
|-------|-------|
|
||||||
3. `gpt-5.5` through OpenClaw for strategic and critical review
|
| Iris | `openai/gpt-5.4` |
|
||||||
|
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
|
||||||
|
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
|
||||||
|
|
||||||
|
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
|
||||||
|
|
||||||
The Settings module reports runtime and provider state without exposing
|
The Settings module reports runtime and provider state without exposing
|
||||||
credentials.
|
credentials.
|
||||||
# Trigger CI
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### CI — Automatic
|
||||||
|
|
||||||
|
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
||||||
|
- **Backend**: .NET restore → build → test
|
||||||
|
- **Frontend**: pnpm install → type-check → test → build
|
||||||
|
- **Security**: Scan for hardcoded secrets in source code
|
||||||
|
|
||||||
|
CI must never break. If it does, Reviewer fixes.
|
||||||
|
|
||||||
|
### CD — Auto + Manual (CD v3)
|
||||||
|
|
||||||
|
Deployment can happen automatically or manually:
|
||||||
|
|
||||||
|
#### Auto-Deploy (after successful CI on main)
|
||||||
|
|
||||||
|
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
||||||
|
- Uses safe defaults: `patch` bump, all services, main ref
|
||||||
|
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
|
||||||
|
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
|
||||||
|
|
||||||
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
|
||||||
|
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 (`/home/projekte_bao/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)
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -44,18 +36,60 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||||
Assert.Null(agent);
|
Assert.Null(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
|
=> new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AgentConfigPath"] = configPath
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static string CreateAgentConfigFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": {
|
||||||
|
"primary": "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "iris",
|
||||||
|
"name": "iris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "programmer",
|
||||||
|
"name": "programmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reviewer",
|
||||||
|
"name": "reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architekt",
|
||||||
|
"name": "architekt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FakeRuntime : IAgentRuntime
|
public sealed class FakeRuntime : IAgentRuntime
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Nexus.Api.Controllers;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public class OperationsSnapshotTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_RequiresAuthorization()
|
||||||
|
{
|
||||||
|
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
|
||||||
|
{
|
||||||
|
var guard = new RepositoryConcurrencyGuard();
|
||||||
|
var runtime = new SnapshotRuntimeStub();
|
||||||
|
var agentService = new SnapshotAgentServiceStub();
|
||||||
|
var projectRepo = new GuardedProjectRepository(guard);
|
||||||
|
var taskRepo = new GuardedTaskRepository(guard);
|
||||||
|
var activityRepo = new GuardedActivityRepository(guard);
|
||||||
|
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
|
||||||
|
|
||||||
|
await service.GetSnapshotAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, guard.MaxConcurrentCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RepositoryConcurrencyGuard
|
||||||
|
{
|
||||||
|
private readonly Lock sync = new();
|
||||||
|
private int currentCalls;
|
||||||
|
|
||||||
|
public int MaxConcurrentCalls { get; private set; }
|
||||||
|
|
||||||
|
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls++;
|
||||||
|
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(25, ct);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
|
||||||
|
{
|
||||||
|
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<Project>
|
||||||
|
{
|
||||||
|
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
|
||||||
|
{
|
||||||
|
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<WorkTask>
|
||||||
|
{
|
||||||
|
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
|
||||||
|
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
|
||||||
|
{
|
||||||
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>(), ct);
|
||||||
|
|
||||||
|
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotRuntimeStub : IAgentRuntime
|
||||||
|
{
|
||||||
|
public string Name => "stub";
|
||||||
|
|
||||||
|
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
|
||||||
|
|
||||||
|
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotAgentServiceStub : IAgentService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
|
||||||
|
[
|
||||||
|
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
|
||||||
|
]);
|
||||||
|
|
||||||
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public class TaskBoardTests
|
||||||
|
{
|
||||||
|
// ── TaskStateHelper: BoardGroupKey ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", "offen")]
|
||||||
|
[InlineData("In progress", "inProgress")]
|
||||||
|
[InlineData("Delegated", "delegated")]
|
||||||
|
[InlineData("Review", "review")]
|
||||||
|
[InlineData("Blocked", "blocked")]
|
||||||
|
[InlineData("Done", "done")]
|
||||||
|
[InlineData("backlog", "offen")]
|
||||||
|
[InlineData("in progress", "inProgress")]
|
||||||
|
[InlineData("delegated", "delegated")]
|
||||||
|
[InlineData("review", "review")]
|
||||||
|
[InlineData("blocked", "blocked")]
|
||||||
|
[InlineData("done", "done")]
|
||||||
|
[InlineData("", "offen")]
|
||||||
|
[InlineData(null, "offen")]
|
||||||
|
[InlineData("unknown", "offen")]
|
||||||
|
public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
|
||||||
|
{
|
||||||
|
var result = TaskStateHelper.BoardGroupKey(state);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: BoardGroupToState ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("offen", "Backlog")]
|
||||||
|
[InlineData("inProgress", "In progress")]
|
||||||
|
[InlineData("inprogress", "In progress")]
|
||||||
|
[InlineData("delegated", "Delegated")]
|
||||||
|
[InlineData("review", "Review")]
|
||||||
|
[InlineData("blocked", "Blocked")]
|
||||||
|
[InlineData("done", "Done")]
|
||||||
|
[InlineData("Offen", "Backlog")]
|
||||||
|
[InlineData("", null)]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
[InlineData("unknown", null)]
|
||||||
|
public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
|
||||||
|
{
|
||||||
|
var result = TaskStateHelper.BoardGroupToState(groupKey);
|
||||||
|
Assert.Equal(expected, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: AllStates has 6 entries ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllStates_ContainsAllSixStates()
|
||||||
|
{
|
||||||
|
var states = TaskStateHelper.AllStates;
|
||||||
|
Assert.Equal(6, states.Length);
|
||||||
|
Assert.Contains("Backlog", states);
|
||||||
|
Assert.Contains("In progress", states);
|
||||||
|
Assert.Contains("Delegated", states);
|
||||||
|
Assert.Contains("Review", states);
|
||||||
|
Assert.Contains("Blocked", states);
|
||||||
|
Assert.Contains("Done", states);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsValidState ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", true)]
|
||||||
|
[InlineData("In progress", true)]
|
||||||
|
[InlineData("Delegated", true)]
|
||||||
|
[InlineData("Review", true)]
|
||||||
|
[InlineData("Blocked", true)]
|
||||||
|
[InlineData("Done", true)]
|
||||||
|
[InlineData("backlog", true)]
|
||||||
|
[InlineData("offen", false)]
|
||||||
|
[InlineData("", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
[InlineData("unknown", false)]
|
||||||
|
public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsValidState(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsInProgressOrBlocked ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("In progress", true)]
|
||||||
|
[InlineData("Blocked", true)]
|
||||||
|
[InlineData("Backlog", false)]
|
||||||
|
[InlineData("Delegated", false)]
|
||||||
|
[InlineData("Review", false)]
|
||||||
|
[InlineData("Done", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: IsDoneOrBacklog ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Done", true)]
|
||||||
|
[InlineData("Backlog", true)]
|
||||||
|
[InlineData("In progress", false)]
|
||||||
|
[InlineData("Delegated", false)]
|
||||||
|
[InlineData("Review", false)]
|
||||||
|
[InlineData("Blocked", false)]
|
||||||
|
[InlineData(null, false)]
|
||||||
|
public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: ToDisplayString ──
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Backlog", "Offen")]
|
||||||
|
[InlineData("In progress", "In Bearbeitung")]
|
||||||
|
[InlineData("Delegated", "Delegiert")]
|
||||||
|
[InlineData("Review", "Review")]
|
||||||
|
[InlineData("Blocked", "Blockiert")]
|
||||||
|
[InlineData("Done", "Erledigt")]
|
||||||
|
[InlineData("backlog", "Offen")]
|
||||||
|
[InlineData("", "")]
|
||||||
|
[InlineData(null, "")]
|
||||||
|
[InlineData("unknown", "unknown")]
|
||||||
|
public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskState helper: ToStateString and ToTaskState roundtrip ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToStateString_And_ToTaskState_RoundTrip()
|
||||||
|
{
|
||||||
|
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Delegated, TaskState.Review, TaskState.Blocked, TaskState.Done };
|
||||||
|
foreach (var state in states)
|
||||||
|
{
|
||||||
|
var str = state.ToStateString();
|
||||||
|
var parsed = str.ToTaskState();
|
||||||
|
Assert.Equal(state, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToTaskState_DefaultsToBacklog_ForUnknownString()
|
||||||
|
{
|
||||||
|
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_Iris_CanChangeAnyTask()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_Bao_CanChangeAnyTask()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_SubAgents_NeverAllowed()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
|
||||||
|
{
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_NexusSystem_IsAllowed()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
|
||||||
|
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||||
|
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanChangeState_UnknownCaller_Rejected()
|
||||||
|
{
|
||||||
|
var task = new WorkTask { Title = "test", IsAgentTask = false };
|
||||||
|
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
|
||||||
|
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
|
||||||
|
Assert.False(TaskStateHelper.CanChangeState(null, task));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TaskStateHelper: CanEditContent ──
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_Iris_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("iris"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_Bao_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("bao"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_SubAgents_AreAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("programmer"));
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("architekt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_NexusSystem_IsAllowed()
|
||||||
|
{
|
||||||
|
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanEditContent_UnknownCaller_Rejected()
|
||||||
|
{
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(""));
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(null));
|
||||||
|
Assert.False(TaskStateHelper.CanEditContent(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Admin/User-Management – erreichbar für owner und admin-Rollen.
|
||||||
|
///
|
||||||
|
/// Sicherheitsregeln:
|
||||||
|
/// - Nur owner und admin dürfen User verwalten.
|
||||||
|
/// - Die Rolle "owner" kann weder vergeben noch überschrieben werden – sie ist
|
||||||
|
/// eine Sonderrolle, die nur bei der initialen Seed-Erstellung gesetzt wird.
|
||||||
|
/// - Über die API sind nur die Rollen "admin", "user" und "viewer" wählbar.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/v1/admin")]
|
||||||
|
[Authorize(Roles = "owner,admin")]
|
||||||
|
public class AdminController(
|
||||||
|
IUserRepository userRepository,
|
||||||
|
ILogger<AdminController> logger) : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly string[] SettableRoles = ["admin", "user", "viewer"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Alle registrierten User auflisten.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("users")]
|
||||||
|
public async Task<IResult> GetUsers(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var users = await userRepository.GetAllAsync(ct);
|
||||||
|
var result = users.Select(u => new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
Email = u.Email,
|
||||||
|
DisplayName = u.DisplayName,
|
||||||
|
Role = u.Role,
|
||||||
|
CreatedAt = u.CreatedAt,
|
||||||
|
LastLoginAt = u.LastLoginAt,
|
||||||
|
}).ToList();
|
||||||
|
return Results.Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Neuen User anlegen.
|
||||||
|
/// Die Rolle "owner" kann NICHT gesetzt werden.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("users")]
|
||||||
|
public async Task<IResult> CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["request"] = ["Email and password are required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.Password.Length < 10)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["password"] = ["Password must be at least 10 characters."]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Role validieren – owner ist nicht über API setzbar
|
||||||
|
var targetRole = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant();
|
||||||
|
if (!SettableRoles.Contains(targetRole))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = [$"Invalid role. Valid roles: {string.Join(", ", SettableRoles)}."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
||||||
|
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
||||||
|
if (existing is not null)
|
||||||
|
return Results.Conflict(new { error = "A user with this email already exists." });
|
||||||
|
|
||||||
|
var user = new NexusUser
|
||||||
|
{
|
||||||
|
Email = request.Email.Trim(),
|
||||||
|
NormalizedEmail = normalizedEmail,
|
||||||
|
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
|
||||||
|
? request.Email.Split('@')[0]
|
||||||
|
: request.DisplayName.Trim(),
|
||||||
|
PasswordHash = PasswordSecurity.Hash(request.Password),
|
||||||
|
Role = targetRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
await userRepository.AddAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} created user {Email} with role {Role}", UserRole(), user.Email, user.Role);
|
||||||
|
|
||||||
|
return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// User löschen. Eigene owner-User und der eigene Account sind geschützt.
|
||||||
|
/// </summary>
|
||||||
|
[HttpDelete("users/{id:guid}")]
|
||||||
|
public async Task<IResult> DeleteUser(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
|
||||||
|
|
||||||
|
if (user.Id.ToString() == CurrentUserId())
|
||||||
|
return Results.Problem("You cannot delete your own account.", statusCode: 403);
|
||||||
|
|
||||||
|
await userRepository.DeleteAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} deleted user {Email}", UserRole(), user.Email);
|
||||||
|
return Results.NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rolle eines Users ändern. "owner" kann weder gesetzt noch überschrieben werden.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPatch("users/{id:guid}/role")]
|
||||||
|
public async Task<IResult> UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Role))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = ["Role is required."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var newRole = request.Role.Trim().ToLowerInvariant();
|
||||||
|
if (!SettableRoles.Contains(newRole))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
["role"] = [$"Invalid role. Valid: {string.Join(", ", SettableRoles)}. Owner is reserved."]
|
||||||
|
});
|
||||||
|
|
||||||
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
|
if (user is null)
|
||||||
|
return Results.NotFound(new { error = "User not found." });
|
||||||
|
|
||||||
|
// Niemals owner überschreiben
|
||||||
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Owner role cannot be modified via API.", statusCode: 403);
|
||||||
|
|
||||||
|
// admin darf andere admins nicht ändern (nur owner)
|
||||||
|
var callerRole = UserRole();
|
||||||
|
if (callerRole == "admin" && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Results.Problem("Admin users can only be managed by the owner.", statusCode: 403);
|
||||||
|
|
||||||
|
// admin darf sich nicht selbst herabstufen
|
||||||
|
if (callerRole == "admin" && user.Id.ToString() == CurrentUserId() && newRole != "admin")
|
||||||
|
return Results.Problem("You cannot demote yourself.", statusCode: 403);
|
||||||
|
|
||||||
|
user.Role = newRole;
|
||||||
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await userRepository.UpdateAsync(user, ct);
|
||||||
|
logger.LogInformation("User {Role} changed role for {Email} from {OldRole} to {NewRole}",
|
||||||
|
callerRole, user.Email, user.Role, newRole);
|
||||||
|
|
||||||
|
return Results.Ok(new AdminUserInfo
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
Email = user.Email,
|
||||||
|
DisplayName = user.DisplayName,
|
||||||
|
Role = user.Role,
|
||||||
|
CreatedAt = user.CreatedAt,
|
||||||
|
LastLoginAt = user.LastLoginAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liefert die Rolle des aufrufenden Users.</summary>
|
||||||
|
private string UserRole()
|
||||||
|
=> User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value?.ToLowerInvariant() ?? "unknown";
|
||||||
|
|
||||||
|
/// <summary>Liefert die Subject-ID des aufrufenden Users.</summary>
|
||||||
|
private string? CurrentUserId()
|
||||||
|
=> User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Helpers;
|
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
@@ -15,6 +13,7 @@ public class AgentsController(
|
|||||||
IAgentService agentService,
|
IAgentService agentService,
|
||||||
IAgentRuntime runtime,
|
IAgentRuntime runtime,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
|
IAgentConfigService agentConfigService,
|
||||||
ILogger<AgentsController> logger) : ControllerBase
|
ILogger<AgentsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -22,8 +21,7 @@ public class AgentsController(
|
|||||||
{
|
{
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
return Results.Ok(agents.Select(a => new AgentListResponse(
|
return Results.Ok(agents.Select(a => new AgentListResponse(
|
||||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
|
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@@ -34,8 +32,7 @@ public class AgentsController(
|
|||||||
return Results.Ok(new AgentDetailResponse(
|
return Results.Ok(new AgentDetailResponse(
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
||||||
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
||||||
agent.SubAgents, agent.IdentityName
|
agent.SubAgents, agent.IdentityName));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/activity")]
|
[HttpGet("{id}/activity")]
|
||||||
@@ -58,9 +55,7 @@ public class AgentsController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
||||||
|
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -73,79 +68,52 @@ public class AgentsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Agent Config Editor ==========
|
// ── Config Editor ──
|
||||||
|
|
||||||
[HttpGet("{id}/config")]
|
[HttpGet("{id}/config")]
|
||||||
public IResult GetConfig(string id)
|
public IResult GetConfig(string id)
|
||||||
{
|
=> Results.Ok(agentConfigService.GetConfigFiles(id));
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
|
||||||
if (!Directory.Exists(workspacePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
|
||||||
};
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(workspacePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.Where(f => allowedFiles.Contains(f.Name))
|
|
||||||
.OrderBy(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
fileName = f.Name,
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id}/config/{fileName}")]
|
[HttpGet("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
return file is null
|
||||||
|
? Results.NotFound()
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
|
|
||||||
var fi = new FileInfo(safePath!);
|
|
||||||
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/config/{fileName}")]
|
[HttpPut("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
|
||||||
|
|
||||||
if (request.Content is null)
|
if (request.Content is null)
|
||||||
return Results.BadRequest(new { error = "Content is required." });
|
return Results.BadRequest(new { error = "Content is required." });
|
||||||
|
|
||||||
if (request.Content.Length > 500 * 1024)
|
if (request.Content.Length > 500 * 1024)
|
||||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||||
|
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var tempPath = safePath + ".tmp";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
|
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||||
System.IO.File.Move(tempPath, safePath, overwrite: true);
|
return result is null
|
||||||
|
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||||
|
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||||
}
|
}
|
||||||
catch
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
|
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
throw;
|
return Results.Problem(
|
||||||
|
title: "Permission denied",
|
||||||
|
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
|
return Results.Problem(
|
||||||
|
title: "File write error",
|
||||||
|
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fi = new FileInfo(safePath);
|
|
||||||
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.RateLimiting;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
@@ -14,7 +15,8 @@ public class AuthController(
|
|||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
IHostEnvironment env) : ControllerBase
|
IHostEnvironment env,
|
||||||
|
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("csrf")]
|
[HttpGet("csrf")]
|
||||||
public IActionResult GetCsrfToken()
|
public IActionResult GetCsrfToken()
|
||||||
@@ -30,11 +32,38 @@ public class AuthController(
|
|||||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
||||||
|
|
||||||
var session = await authService.LoginAsync(request, ct);
|
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
if (session is null) return Results.Unauthorized();
|
|
||||||
|
|
||||||
|
var session = await authService.LoginAsync(request, ct);
|
||||||
|
if (session is null)
|
||||||
|
{
|
||||||
|
var remaining = attemptTracker.RecordFailedAttempt(ip);
|
||||||
|
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
|
||||||
|
|
||||||
|
// Attach remaining info to the 401 response via headers only
|
||||||
|
// (the frontend can also parse the 429 body)
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
|
if (retryAfterSeconds > 0)
|
||||||
|
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||||
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
// Return a structured body so the frontend can display remaining attempts
|
||||||
|
return Results.Json(new
|
||||||
|
{
|
||||||
|
error = "invalid_credentials",
|
||||||
|
message = "Invalid email or password.",
|
||||||
|
remaining,
|
||||||
|
retryAfterSeconds
|
||||||
|
}, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — reset attempt counter
|
||||||
|
attemptTracker.Reset(ip);
|
||||||
SetRefreshCookie(Response, session.RefreshToken);
|
SetRefreshCookie(Response, session.RefreshToken);
|
||||||
Response.Headers.CacheControl = "no-store";
|
Response.Headers.CacheControl = "no-store";
|
||||||
|
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||||
|
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
return Results.Ok(ToAuthResponse(session));
|
return Results.Ok(ToAuthResponse(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +83,8 @@ public class AuthController(
|
|||||||
|
|
||||||
SetRefreshCookie(Response, session.RefreshToken);
|
SetRefreshCookie(Response, session.RefreshToken);
|
||||||
Response.Headers.CacheControl = "no-store";
|
Response.Headers.CacheControl = "no-store";
|
||||||
|
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||||
|
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||||
return Results.Ok(ToAuthResponse(session));
|
return Results.Ok(ToAuthResponse(session));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +122,23 @@ public class AuthController(
|
|||||||
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
|
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("admin-reset-password")]
|
||||||
|
[EnableRateLimiting("agents")]
|
||||||
|
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.NewPassword) || string.IsNullOrWhiteSpace(request.AdminToken))
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["request"] = ["Email, new password, and admin token are required."] });
|
||||||
|
|
||||||
|
if (request.NewPassword.Length < 10)
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["newPassword"] = ["New password must be at least 10 characters."] });
|
||||||
|
|
||||||
|
var success = await authService.AdminResetPasswordAsync(request.Email, request.NewPassword, request.AdminToken, ct);
|
||||||
|
if (!success)
|
||||||
|
return Results.Problem("Password reset failed. Check the admin token, email, and that the user exists.", statusCode: 400);
|
||||||
|
|
||||||
|
return Results.Ok(new { message = "Password reset successfully." });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
|
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,80 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/calendar")]
|
[Route("api/v1/calendar")]
|
||||||
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
|
public class CalendarController(ICalendarService calendarService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<CronJobEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackJobs = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
|
|
||||||
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallbackJobs);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("upcoming")]
|
[HttpGet("upcoming")]
|
||||||
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<UpcomingCronEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var fallback = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
|
|
||||||
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallback);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/dashboard")]
|
||||||
|
public class DashboardController(
|
||||||
|
IDashboardService dashboardService,
|
||||||
|
ITaskService taskService,
|
||||||
|
IActivityRepository activityService,
|
||||||
|
IHttpContextAccessor httpContextAccessor) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("status")]
|
||||||
|
public async Task<DashboardStatus> GetStatus()
|
||||||
|
=> await dashboardService.GetStatusAsync();
|
||||||
|
|
||||||
|
[HttpGet("agents")]
|
||||||
|
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||||
|
=> await dashboardService.GetAgentsAsync();
|
||||||
|
|
||||||
|
[HttpGet("operations")]
|
||||||
|
public async Task<List<FeedEntry>> GetOperations(
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
[FromQuery] string? agent = null)
|
||||||
|
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||||
|
|
||||||
|
[HttpPost("chat/send")]
|
||||||
|
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
return new ChatResponse(false, null, "Message is required");
|
||||||
|
|
||||||
|
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||||
|
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("chat/messages")]
|
||||||
|
public async Task<List<MessageEntry>> GetMessages(
|
||||||
|
[FromQuery] string? sessionKey,
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
[FromQuery] int offset = 0)
|
||||||
|
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||||
|
|
||||||
|
[HttpGet("queue")]
|
||||||
|
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||||
|
=> await dashboardService.GetQueueAsync(ct);
|
||||||
|
|
||||||
|
[HttpDelete("queue/{id}")]
|
||||||
|
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
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" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("queue/{id}/priority")]
|
||||||
|
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
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 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("agents/{id}/model")]
|
||||||
|
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||||
|
{
|
||||||
|
var info = await dashboardService.GetAgentModelAsync(id);
|
||||||
|
return info is null
|
||||||
|
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||||
|
: Ok(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("agents/{id}/model")]
|
||||||
|
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Model))
|
||||||
|
return BadRequest(new { error = "Model is required" });
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("agents/{id}/activity")]
|
||||||
|
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||||
|
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||||
|
|
||||||
|
[HttpGet("models")]
|
||||||
|
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||||
|
=> Ok(dashboardService.GetAvailableModels());
|
||||||
|
|
||||||
|
// ── Task Endpoints ──
|
||||||
|
|
||||||
|
[HttpGet("tasks")]
|
||||||
|
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tasks = await taskService.GetOpenAsync(ct);
|
||||||
|
return tasks.Select(MapToDto).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||||
|
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
|
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
|
||||||
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||||
|
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await taskService.UpdateDashboardTaskAsync(
|
||||||
|
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{id:guid}/status")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||||
|
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Enforce workflow rules based on caller agent
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
// Resolve caller agent from header or JWT
|
||||||
|
var callerAgent = ResolveCallerAgent();
|
||||||
|
|
||||||
|
// Nur Iris und Bao dürfen Status ändern
|
||||||
|
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
|
||||||
|
{
|
||||||
|
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
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!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task Board Endpoints ──
|
||||||
|
|
||||||
|
[HttpGet("tasks/board")]
|
||||||
|
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||||
|
=> await taskService.GetBoardAsync(ct);
|
||||||
|
|
||||||
|
[HttpPatch("tasks/{id:guid}/move")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
||||||
|
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.State))
|
||||||
|
return BadRequest(new { error = "State is required." });
|
||||||
|
|
||||||
|
// Enforce workflow rules based on caller agent
|
||||||
|
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (currentTask is null)
|
||||||
|
return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
// Resolve caller agent from header or JWT
|
||||||
|
var callerAgent = ResolveCallerAgent();
|
||||||
|
|
||||||
|
// Nur Iris und Bao dürfen Status ändern
|
||||||
|
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
|
||||||
|
{
|
||||||
|
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await taskService.MoveTaskAsync(id, request.State, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||||
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||||
|
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveCallerAgent()
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext is null) return "";
|
||||||
|
|
||||||
|
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
|
return agentHeader.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var user = httpContext.User;
|
||||||
|
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
return nameClaim?.ToLowerInvariant() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── New Endpoints: Reset Stale, Children, Activity ──
|
||||||
|
|
||||||
|
[HttpPost("tasks/reset-stale")]
|
||||||
|
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
|
||||||
|
[FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
|
||||||
|
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
|
||||||
|
return Ok(new ResetStaleResponse(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}/children")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var children = await taskService.GetChildTasksAsync(id, ct);
|
||||||
|
return Ok(children.Select(MapToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
|
||||||
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
return Ok(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("tasks/{id:guid}/activity")]
|
||||||
|
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var events = await taskService.GetTaskActivityAsync(id, ct);
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("tasks/{id:guid}/activity")]
|
||||||
|
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
|
||||||
|
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var task = await taskService.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return NotFound(new { error = "Task not found." });
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
|
return BadRequest(new { error = "Message is required." });
|
||||||
|
|
||||||
|
var ev = new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = request.Type ?? "comment",
|
||||||
|
Message = request.Message.Trim(),
|
||||||
|
TaskId = id
|
||||||
|
};
|
||||||
|
|
||||||
|
await activityService.AddAsync(ev, ct);
|
||||||
|
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent Workflow Endpoints (Iris Overview) ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns agent-tasks that are still open and waiting for input.
|
||||||
|
/// Iris uses this to see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("tasks/agent-waiting")]
|
||||||
|
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var waiting = await taskService.GetWaitingTasksAsync(ct);
|
||||||
|
return Ok(waiting.Select(MapToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a complete agent-workflow overview grouped by expected respondent
|
||||||
|
/// + stale detection. This is the main Iris dashboard data.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("tasks/agent-overview")]
|
||||||
|
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||||
|
CancellationToken ct, [FromQuery] int staleHours = 2)
|
||||||
|
{
|
||||||
|
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||||
|
return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an agent-task: a task that is tracked as originating from the agent workflow.
|
||||||
|
/// Sub-agents (programmer, reviewer) can only CREATE, not move state.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("tasks/agent")]
|
||||||
|
public async Task<ActionResult<DashboardTaskDto>> CreateAgentTask(
|
||||||
|
[FromBody] CreateAgentTaskRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var task = await taskService.CreateAgentTaskAsync(
|
||||||
|
request.Title, request.Detail, request.Source ?? "iris",
|
||||||
|
request.Priority, request.AssignedTo, request.ExpectedFrom,
|
||||||
|
request.ParentTaskId, ct);
|
||||||
|
|
||||||
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
|
}
|
||||||
|
catch (ArgumentException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { error = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
|
t.IsAgentTask, t.ExpectedFrom);
|
||||||
|
}
|
||||||
@@ -1,47 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/docs")]
|
[Route("api/v1/docs")]
|
||||||
public class DocsController : ControllerBase
|
public class DocsController(IDocService docService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public IResult GetAll()
|
||||||
{
|
=> Results.Ok(docService.GetAll());
|
||||||
var workspaceRoot = "/mnt/workspace-iris";
|
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
void ScanDir(string dir, string category)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
|
||||||
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
|
|
||||||
continue;
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
results.Add(new
|
|
||||||
{
|
|
||||||
name = fi.Name,
|
|
||||||
path = file.Replace(workspaceRoot, "").TrimStart('/'),
|
|
||||||
category,
|
|
||||||
type = ext.Replace(".", ""),
|
|
||||||
size = fi.Length,
|
|
||||||
modifiedAt = fi.LastWriteTimeUtc
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
|
|
||||||
ScanDir("/mnt/workspace-iris/skills", "skills");
|
|
||||||
ScanDir("/mnt/workspace-iris", "workspace");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
|
|
||||||
|
|
||||||
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{**path}")]
|
[HttpGet("{**path}")]
|
||||||
public async Task<IResult> GetFile(string path)
|
public async Task<IResult> GetFile(string path)
|
||||||
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
return Results.BadRequest("Path required.");
|
return Results.BadRequest("Path required.");
|
||||||
|
|
||||||
string? resolvedPath = null;
|
var file = await docService.GetFileAsync(path);
|
||||||
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
{
|
|
||||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
|
|
||||||
{
|
|
||||||
resolvedPath = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedPath is null)
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
|
|
||||||
var fi = new FileInfo(resolvedPath);
|
|
||||||
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
@@ -7,6 +8,13 @@ namespace Nexus.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("/health/live")]
|
||||||
|
public IResult Live()
|
||||||
|
{
|
||||||
|
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
public async Task<IResult> Get(CancellationToken ct)
|
public async Task<IResult> Get(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,100 +1,20 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/incidents")]
|
[Route("api/v1/incidents")]
|
||||||
public class IncidentsController : ControllerBase
|
public class IncidentsController(IIncidentService incidentService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await incidentService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var incidents = new List<object>();
|
|
||||||
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > 1_000_000) continue;
|
|
||||||
var name = Path.GetFileNameWithoutExtension(file);
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(file);
|
|
||||||
|
|
||||||
var title = name;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
var severity = "unknown";
|
|
||||||
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
|
|
||||||
if (severityMatch.Success)
|
|
||||||
severity = severityMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
|
||||||
var excerpt = excerptEnd > 0
|
|
||||||
? content[..excerptEnd].Trim()
|
|
||||||
: content[..Math.Min(300, content.Length)].Trim();
|
|
||||||
if (excerpt.Length > 200)
|
|
||||||
excerpt = excerpt[..200] + "\u2026";
|
|
||||||
|
|
||||||
incidents.Add(new
|
|
||||||
{
|
|
||||||
name = Path.GetFileName(file),
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
severity,
|
|
||||||
excerpt,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(incidents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetOne(string name)
|
public async Task<IResult> GetOne(string name)
|
||||||
{
|
{
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
var incident = await incidentService.GetByNameAsync(name);
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
|
return incident is null ? Results.NotFound() : Results.Ok(incident);
|
||||||
return Results.BadRequest("Invalid filename.");
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
{
|
|
||||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = Path.Combine(basePath, name + ".md");
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
var fi = new FileInfo(filePath!);
|
|
||||||
var fileName = Path.GetFileName(filePath!);
|
|
||||||
|
|
||||||
var title = fileName;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
name = fileName,
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
content,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/memory")]
|
[Route("api/v1/memory")]
|
||||||
public class MemoryController : ControllerBase
|
public class MemoryController(IMemoryService memoryService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await memoryService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(basePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.OrderByDescending(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
name = f.Name,
|
|
||||||
path = f.FullName.Replace(basePath, "").TrimStart('/'),
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(longTermPath);
|
|
||||||
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<IResult> Search([FromQuery] string q)
|
public async Task<IResult> Search([FromQuery] string q)
|
||||||
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||||
return Results.BadRequest("Query must be at least 2 characters.");
|
return Results.BadRequest("Query must be at least 2 characters.");
|
||||||
|
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
return Results.Ok(await memoryService.SearchAsync(q));
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
const int maxFiles = 50;
|
|
||||||
const int maxFileSize = 1_000_000;
|
|
||||||
|
|
||||||
async Task SearchDir(string dir)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > maxFileSize) continue;
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(file))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SearchDir(basePath);
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(longTermPath))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetFile(string name)
|
public async Task<IResult> GetFile(string name)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
|
var file = await memoryService.GetFileAsync(name);
|
||||||
return Results.BadRequest("Invalid filename.");
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = longTermPath;
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/dashboard/notifications")]
|
||||||
|
public class NotificationsController(INotificationService notificationService) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
[FromQuery] int limit = 50,
|
||||||
|
[FromQuery] bool unreadOnly = false,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
|
||||||
|
return Ok(notifications.Select(MapToDto).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("unread-count")]
|
||||||
|
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
|
||||||
|
return Ok(new UnreadCountDto(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/read")]
|
||||||
|
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ok = await notificationService.MarkAsReadAsync(id, ct);
|
||||||
|
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("read-all")]
|
||||||
|
public async Task<ActionResult> MarkAllAsRead(
|
||||||
|
[FromQuery] string forUser = "bao",
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
|
||||||
|
return Ok(new { marked = count });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NotificationDto MapToDto(Notification n) => new(
|
||||||
|
n.Id, n.Type, n.Title, n.Message,
|
||||||
|
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
|
||||||
|
}
|
||||||
@@ -1,71 +1,15 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/operations")]
|
[Route("api/v1/operations")]
|
||||||
public class OperationsController(
|
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||||
IAgentRuntime runtime,
|
|
||||||
IAgentService agentService,
|
|
||||||
IProjectRepository projectRepo,
|
|
||||||
ITaskRepository taskRepo,
|
|
||||||
IActivityRepository activityRepo) : ControllerBase
|
|
||||||
{
|
{
|
||||||
[HttpGet("snapshot")]
|
[HttpGet("snapshot")]
|
||||||
|
[Authorize]
|
||||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
|
||||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
|
||||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
|
||||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
|
||||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
|
||||||
|
|
||||||
var tasks = tasksTask.Result;
|
|
||||||
var projects = projectsTask.Result;
|
|
||||||
var agents = agentsTask.Result;
|
|
||||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
|
||||||
|
|
||||||
var runtimeStatus = runtimeTask.Result;
|
|
||||||
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
|
|
||||||
|
|
||||||
var lastIncident = tasks
|
|
||||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
.OrderByDescending(x => x.UpdatedAt)
|
|
||||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
var projectHealth = new
|
|
||||||
{
|
|
||||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
|
||||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
|
||||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
|
||||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
generatedAt = DateTimeOffset.UtcNow,
|
|
||||||
runtime = runtimeStatus,
|
|
||||||
models = Array.Empty<object>(),
|
|
||||||
runtimeHealthy,
|
|
||||||
metrics = new
|
|
||||||
{
|
|
||||||
activeAgents = agents.Count,
|
|
||||||
queuedTasks = tasks.Count - completedTasks,
|
|
||||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
|
||||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
},
|
|
||||||
lastIncident,
|
|
||||||
projectHealth,
|
|
||||||
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
|
||||||
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
|
||||||
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
|
||||||
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/projects")]
|
[Route("api/v1/projects")]
|
||||||
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
|
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await projectRepo.GetAllAsync(ct));
|
=> Results.Ok(await projectService.GetAllAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var project = await projectService.GetByIdAsync(id, ct);
|
||||||
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||||||
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
|
|||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var project = new Project
|
var project = await projectService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Name = request.Name.Trim(),
|
|
||||||
Description = request.Description?.Trim() ?? string.Empty,
|
|
||||||
Status = OperationalStatus.Online
|
|
||||||
};
|
|
||||||
await projectRepo.AddAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
|
||||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var project = await projectService.UpdateAsync(id, request, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
project.Name = request.Name.Trim();
|
|
||||||
if (request.Description is not null)
|
|
||||||
project.Description = request.Description.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
|
||||||
project.Status = parsedStatus;
|
|
||||||
|
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
|
||||||
return Results.Ok(project);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var result = await projectService.DeleteAsync(id, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
|
||||||
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
|
|
||||||
if (hasTasks)
|
|
||||||
{
|
{
|
||||||
project.Status = OperationalStatus.Offline;
|
ProjectDeleteOutcome.NotFound => Results.NotFound(),
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
_ => Results.NoContent()
|
||||||
return Results.Ok(project);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await projectRepo.DeleteAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
|
public class TasksController(ITaskService taskService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await taskRepo.GetAllAsync(ct));
|
=> Results.Ok(await taskService.GetAllAsync(ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
||||||
@@ -19,107 +21,111 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
|
|||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = await taskService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
|
||||||
ProjectId = request.ProjectId
|
|
||||||
};
|
|
||||||
await taskRepo.AddAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pending-approval")]
|
[HttpGet("pending-approval")]
|
||||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pending = await taskRepo.GetPendingApprovalAsync(ct);
|
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||||
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/approve")]
|
[HttpPost("{id:guid}/approve")]
|
||||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.ApproveAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Approval denied",
|
title: "Approval denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.RejectAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Rejection denied",
|
title: "Rejection denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/state")]
|
[HttpPatch("{id:guid}/state")]
|
||||||
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allowedStates = TaskStateHelper.AllStates;
|
if (!TaskStateHelper.IsValidState(request.State))
|
||||||
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
|
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
|
{
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
return Results.Ok(task);
|
title: "Action denied",
|
||||||
}
|
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
[HttpDelete("{id:guid}")]
|
_ => Results.Ok(result.Task)
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
};
|
||||||
{
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
|
||||||
if (task is null) return Results.NotFound();
|
|
||||||
|
|
||||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
|
||||||
return Results.Problem(
|
|
||||||
title: "Task deletion denied",
|
|
||||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateAsync(id, request, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
[HttpDelete("{id:guid}")]
|
||||||
task.Title = request.Title.Trim();
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
{
|
||||||
task.Priority = request.Priority.Trim();
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
if (request.ProjectId.HasValue)
|
return result.Outcome switch
|
||||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
|
title: "Task deletion denied",
|
||||||
|
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.NoContent()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
|
||||||
return Results.Ok(task);
|
/// <summary>
|
||||||
|
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||||
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("board")]
|
||||||
|
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||||
|
=> Results.Ok(await taskService.GetBoardAsync(ct));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog.
|
||||||
|
/// Wird vom Iris Autonomous Worker genutzt.
|
||||||
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("reset-stale")]
|
||||||
|
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||||
|
return Results.Ok(new ResetStaleResponse(count));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/team")]
|
[Route("api/v1/team")]
|
||||||
public class TeamController(IAgentService agentService) : ControllerBase
|
public class TeamController(ITeamService teamService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetTeam(CancellationToken ct)
|
public async Task<IResult> GetTeam(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await teamService.GetTeamAsync(ct));
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
|
||||||
var team = new List<object>();
|
|
||||||
|
|
||||||
foreach (var agent in agents)
|
|
||||||
{
|
|
||||||
string identity = "";
|
|
||||||
string workspace = agent.Workspace ?? "";
|
|
||||||
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
|
|
||||||
{
|
|
||||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
|
||||||
if (System.IO.File.Exists(identityFile))
|
|
||||||
{
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
|
|
||||||
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
|
|
||||||
identity = string.Join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
team.Add(new
|
|
||||||
{
|
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
|
||||||
identity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(team);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
public sealed record AdminResetPasswordRequest
|
||||||
|
{
|
||||||
|
/// <summary>The email of the user whose password should be reset.</summary>
|
||||||
|
public required string Email { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The new password to set.</summary>
|
||||||
|
public required string NewPassword { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Admin reset token from configuration (Admin:ResetToken).</summary>
|
||||||
|
public required string AdminToken { get; init; }
|
||||||
|
}
|
||||||
@@ -26,6 +26,29 @@ public sealed record UserInfo
|
|||||||
public string Role { get; init; } = string.Empty;
|
public string Role { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUserInfo
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; }
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string DisplayName { get; init; } = string.Empty;
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAt { get; init; }
|
||||||
|
public DateTimeOffset? LastLoginAt { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminCreateUserRequest
|
||||||
|
{
|
||||||
|
public string Email { get; init; } = string.Empty;
|
||||||
|
public string Password { get; init; } = string.Empty;
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Role { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AdminUpdateRoleRequest
|
||||||
|
{
|
||||||
|
public string Role { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record UpdateProfileRequest
|
public sealed record UpdateProfileRequest
|
||||||
{
|
{
|
||||||
[MaxLength(100)]
|
[MaxLength(100)]
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
|
|||||||
string? Title,
|
string? Title,
|
||||||
DateTimeOffset? Since
|
DateTimeOffset? Since
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+130
-4
@@ -18,8 +18,10 @@ public enum TaskState
|
|||||||
{
|
{
|
||||||
Backlog,
|
Backlog,
|
||||||
InProgress,
|
InProgress,
|
||||||
|
Delegated,
|
||||||
Blocked,
|
Blocked,
|
||||||
Done
|
Done,
|
||||||
|
Review
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TaskStateHelper
|
public static class TaskStateHelper
|
||||||
@@ -28,20 +30,35 @@ public static class TaskStateHelper
|
|||||||
{
|
{
|
||||||
[TaskState.Backlog] = "Backlog",
|
[TaskState.Backlog] = "Backlog",
|
||||||
[TaskState.InProgress] = "In progress",
|
[TaskState.InProgress] = "In progress",
|
||||||
|
[TaskState.Delegated] = "Delegated",
|
||||||
[TaskState.Blocked] = "Blocked",
|
[TaskState.Blocked] = "Blocked",
|
||||||
[TaskState.Done] = "Done"
|
[TaskState.Done] = "Done",
|
||||||
|
[TaskState.Review] = "Review"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["Backlog"] = TaskState.Backlog,
|
["Backlog"] = TaskState.Backlog,
|
||||||
["In progress"] = TaskState.InProgress,
|
["In progress"] = TaskState.InProgress,
|
||||||
|
["Delegated"] = TaskState.Delegated,
|
||||||
["Blocked"] = TaskState.Blocked,
|
["Blocked"] = TaskState.Blocked,
|
||||||
["Done"] = TaskState.Done
|
["Done"] = TaskState.Done,
|
||||||
|
["Review"] = TaskState.Review
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Mapping from state string to display label.</summary>
|
||||||
|
private static readonly Dictionary<string, string> DisplayLabels = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Backlog"] = "Offen",
|
||||||
|
["In progress"] = "In Bearbeitung",
|
||||||
|
["Delegated"] = "Delegiert",
|
||||||
|
["Review"] = "Review",
|
||||||
|
["Blocked"] = "Blockiert",
|
||||||
|
["Done"] = "Erledigt"
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Valid task-state string values for API validation.</summary>
|
/// <summary>Valid task-state string values for API validation.</summary>
|
||||||
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"];
|
public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"];
|
||||||
|
|
||||||
/// <summary>Convert a TaskState enum to its API string representation.</summary>
|
/// <summary>Convert a TaskState enum to its API string representation.</summary>
|
||||||
public static string ToStateString(this TaskState state) => StateToString[state];
|
public static string ToStateString(this TaskState state) => StateToString[state];
|
||||||
@@ -54,6 +71,10 @@ public static class TaskStateHelper
|
|||||||
public static bool IsValidState(string? state) =>
|
public static bool IsValidState(string? state) =>
|
||||||
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
|
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
|
||||||
|
|
||||||
|
/// <summary>Returns the German display label for a state string.</summary>
|
||||||
|
public static string ToDisplayString(string? state) =>
|
||||||
|
state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? "";
|
||||||
|
|
||||||
public static bool IsInProgressOrBlocked(string? state) =>
|
public static bool IsInProgressOrBlocked(string? state) =>
|
||||||
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -61,6 +82,77 @@ public static class TaskStateHelper
|
|||||||
public static bool IsDoneOrBacklog(string? state) =>
|
public static bool IsDoneOrBacklog(string? state) =>
|
||||||
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the caller is allowed to change this task's state.
|
||||||
|
/// POLICY:
|
||||||
|
/// - **Iris und Bao** dürfen Status ändern / verschieben.
|
||||||
|
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern.
|
||||||
|
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
|
||||||
|
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanChangeState(string? callerAgent, WorkTask task)
|
||||||
|
{
|
||||||
|
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||||
|
|
||||||
|
// Sub-agents must never move state
|
||||||
|
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" };
|
||||||
|
if (subAgents.Contains(caller)) return false;
|
||||||
|
|
||||||
|
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
|
||||||
|
if (caller == "nexus-system") return true;
|
||||||
|
|
||||||
|
// Iris und Bao dürfen Status ändern
|
||||||
|
return caller == "iris" || caller == "bao";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if the caller is allowed to edit a task's content fields
|
||||||
|
/// (title, detail, priority, assignedTo, dueDate).
|
||||||
|
/// POLICY:
|
||||||
|
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
|
||||||
|
/// - Nur unbekannte/leere Caller werden abgewiesen.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanEditContent(string? callerAgent)
|
||||||
|
{
|
||||||
|
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(caller)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Group key for board responses (lowercased English state).</summary>
|
||||||
|
public static string BoardGroupKey(string? state)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(state)) return "offen";
|
||||||
|
var lower = state.ToLowerInvariant();
|
||||||
|
return lower switch
|
||||||
|
{
|
||||||
|
"backlog" => "offen",
|
||||||
|
"in progress" => "inProgress",
|
||||||
|
"delegated" => "delegated",
|
||||||
|
"review" => "review",
|
||||||
|
"blocked" => "blocked",
|
||||||
|
"done" => "done",
|
||||||
|
_ => "offen"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Map a board group key back to the canonical state string.</summary>
|
||||||
|
public static string? BoardGroupToState(string? groupKey)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(groupKey)) return null;
|
||||||
|
var lower = groupKey.ToLowerInvariant();
|
||||||
|
return lower switch
|
||||||
|
{
|
||||||
|
"offen" => "Backlog",
|
||||||
|
"inprogress" => "In progress",
|
||||||
|
"delegated" => "Delegated",
|
||||||
|
"review" => "Review",
|
||||||
|
"blocked" => "Blocked",
|
||||||
|
"done" => "Done",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class Project
|
public sealed class Project
|
||||||
@@ -77,16 +169,50 @@ public sealed class WorkTask
|
|||||||
{
|
{
|
||||||
public Guid Id { get; init; } = Guid.NewGuid();
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
public required string Title { get; set; }
|
public required string Title { get; set; }
|
||||||
|
public string? Detail { get; set; }
|
||||||
public string State { get; set; } = "Backlog";
|
public string State { get; set; } = "Backlog";
|
||||||
public string Priority { get; set; } = "Normal";
|
public string Priority { get; set; } = "Normal";
|
||||||
|
public string Source { get; set; } = "bao";
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True if this task was created programmatically by an agent (not manually by Bao).
|
||||||
|
/// Agent-tasks in the board are subject to stricter workflow rules.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsAgentTask { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Which agent/user is expected to respond next.
|
||||||
|
/// Helps Iris see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
public string? ExpectedFrom { get; set; }
|
||||||
|
|
||||||
|
public Guid? ParentTaskId { get; set; }
|
||||||
|
public WorkTask? ParentTask { get; set; }
|
||||||
|
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
|
||||||
public Guid? ProjectId { get; set; }
|
public Guid? ProjectId { get; set; }
|
||||||
|
public DateTimeOffset? DueDate { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class Notification
|
||||||
|
{
|
||||||
|
public Guid Id { get; init; } = Guid.NewGuid();
|
||||||
|
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
|
||||||
|
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
|
||||||
|
public string? Message { get; set; } // Detailtext
|
||||||
|
public required string ForUser { get; set; } // "bao" oder "iris"
|
||||||
|
public Guid? TaskId { get; set; } // Verknüpfte Task
|
||||||
|
public bool IsRead { get; set; } = false;
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ActivityEvent
|
public sealed class ActivityEvent
|
||||||
{
|
{
|
||||||
public long Id { get; init; }
|
public long Id { get; init; }
|
||||||
public required string Type { get; set; }
|
public required string Type { get; set; }
|
||||||
public required string Message { get; set; }
|
public required string Message { get; set; }
|
||||||
|
public Guid? TaskId { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace Nexus.Api.Data;
|
namespace Nexus.Api.Data;
|
||||||
|
|
||||||
@@ -28,6 +29,20 @@ public class NexusUser
|
|||||||
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks one-time seed operations so they are never re-executed — even
|
||||||
|
/// if the underlying data is deleted. This is the single guard that
|
||||||
|
/// prevents owner-password drift after DB resets or volume recreations.
|
||||||
|
/// </summary>
|
||||||
|
public class SeedAudit
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[MaxLength(80)]
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
public class RefreshToken
|
public class RefreshToken
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260611154800_AddTaskDetailFields")]
|
||||||
|
partial class AddTaskDetailFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskDetailFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: false,
|
||||||
|
defaultValueSql: "NOW()");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Detail",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(2000)",
|
||||||
|
maxLength: 2000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Source",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "bao");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_AssignedTo",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "AssignedTo");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_Source",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "Source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_AssignedTo",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_Source",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssignedTo",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CreatedAt",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Detail",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Source",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260618214335_AddNotifications")]
|
||||||
|
partial class AddNotifications
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotifications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||||
|
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
|
||||||
|
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||||
|
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
|
||||||
|
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
IsRead = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Notifications", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
|
||||||
|
table: "Notifications",
|
||||||
|
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Notifications");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskParentChild : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ParentTaskId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ParentTaskId",
|
||||||
|
principalTable: "Tasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Tasks_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentTaskId",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTaskDueDate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "DueDate",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DueDate",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddActivityTaskReference : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "TaskId",
|
||||||
|
table: "Activity",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Activity_TaskId",
|
||||||
|
table: "Activity",
|
||||||
|
column: "TaskId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Activity_TaskId",
|
||||||
|
table: "Activity");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TaskId",
|
||||||
|
table: "Activity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260618233003_AddDelegatedState")]
|
||||||
|
partial class AddDelegatedState
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDelegatedState : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Delegated state is a pure code change to the TaskState enum and
|
||||||
|
// TaskStateHelper. No schema change required since the State column
|
||||||
|
// is already a free-form string column.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// No schema to revert.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260620174200_AddAgentTaskFields")]
|
||||||
|
partial class AddAgentTaskFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAgentTaskFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAgentTask",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ExpectedFrom",
|
||||||
|
table: "Tasks",
|
||||||
|
type: "character varying(60)",
|
||||||
|
maxLength: 60,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_IsAgentTask",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "IsAgentTask");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Tasks_ExpectedFrom",
|
||||||
|
table: "Tasks",
|
||||||
|
column: "ExpectedFrom");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_IsAgentTask",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Tasks_ExpectedFrom",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExpectedFrom",
|
||||||
|
table: "Tasks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAgentTask",
|
||||||
|
table: "Tasks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(NexusDbContext))]
|
||||||
|
[Migration("20260621081500_AddSeedAudit")]
|
||||||
|
partial class AddSeedAudit
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("Activity");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("character varying(120)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(160)
|
||||||
|
.HasColumnType("character varying(160)");
|
||||||
|
|
||||||
|
b.Property<int>("Progress")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Projects");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid>("FamilyId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByTokenHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "FamilyId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("SeedAudit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Priority")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProjectId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
|
b.ToTable("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.NexusUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Nexus.Api.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSeedAudit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "SeedAudit",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_SeedAudit", x => x.Key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "SeedAudit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Type")
|
b.Property<string>("Type")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId");
|
||||||
|
|
||||||
b.ToTable("Activity");
|
b.ToTable("Activity");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
|
|||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ForUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRead")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(240)
|
||||||
|
.HasColumnType("character varying(240)");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ForUser", "IsRead", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("Notifications");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -166,12 +214,50 @@ namespace Nexus.Api.Migrations
|
|||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("character varying(80)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
b.ToTable("SeedAudit");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("AssignedTo")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Detail")
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("character varying(2000)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DueDate")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ExpectedFrom")
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAgentTask")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentTaskId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<string>("Priority")
|
b.Property<string>("Priority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -179,6 +265,11 @@ namespace Nexus.Api.Migrations
|
|||||||
b.Property<Guid?>("ProjectId")
|
b.Property<Guid?>("ProjectId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(60)
|
||||||
|
.HasColumnType("character varying(60)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -193,6 +284,16 @@ namespace Nexus.Api.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssignedTo");
|
||||||
|
|
||||||
|
b.HasIndex("ExpectedFrom");
|
||||||
|
|
||||||
|
b.HasIndex("IsAgentTask");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId");
|
||||||
|
|
||||||
|
b.HasIndex("Source");
|
||||||
|
|
||||||
b.ToTable("Tasks");
|
b.ToTable("Tasks");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,10 +308,25 @@ namespace Nexus.Api.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
|
||||||
|
.WithMany("ChildTasks")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("ParentTask");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ChildTasks");
|
||||||
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,45 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
|||||||
{
|
{
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
||||||
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
||||||
public DbSet<NexusUser> Users => Set<NexusUser>();
|
public DbSet<NexusUser> Users => Set<NexusUser>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
|
||||||
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240);
|
modelBuilder.Entity<WorkTask>(entity =>
|
||||||
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
{
|
||||||
|
entity.Property(x => x.Title).HasMaxLength(240);
|
||||||
|
entity.Property(x => x.Detail).HasMaxLength(2000);
|
||||||
|
entity.Property(x => x.Source).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.AssignedTo).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
|
||||||
|
entity.HasIndex(x => x.Source);
|
||||||
|
entity.HasIndex(x => x.AssignedTo);
|
||||||
|
entity.HasIndex(x => x.IsAgentTask);
|
||||||
|
entity.HasIndex(x => x.ExpectedFrom);
|
||||||
|
entity.HasOne(x => x.ParentTask)
|
||||||
|
.WithMany(x => x.ChildTasks)
|
||||||
|
.HasForeignKey(x => x.ParentTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
modelBuilder.Entity<Notification>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Title).HasMaxLength(240);
|
||||||
|
entity.Property(x => x.Message).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.Type).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.ForUser).HasMaxLength(60);
|
||||||
|
entity.HasIndex(x => new { x.ForUser, x.IsRead, x.CreatedAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ActivityEvent>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Message).HasMaxLength(1000);
|
||||||
|
entity.HasIndex(x => x.TaskId);
|
||||||
|
});
|
||||||
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ RUN dotnet publish -c Release -o /app/publish
|
|||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
RUN apk add --no-cache curl
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
|
ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using Nexus.Api.Middleware;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for configuring the Nexus application pipeline and startup.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
||||||
|
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
|
||||||
|
/// are deleted — the DB is the single source of truth for the owner password after first seed.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
||||||
|
{
|
||||||
|
var configuration = app.Configuration;
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
const string seedKey = "owner_created";
|
||||||
|
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
|
||||||
|
if (alreadySeeded)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
||||||
|
var ownerPassword = configuration["Owner:Password"];
|
||||||
|
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
|
||||||
|
var hasUsers = await db.Users.AnyAsync();
|
||||||
|
|
||||||
|
if (!hasUsers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||||
|
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
||||||
|
|
||||||
|
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
||||||
|
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
|
||||||
|
: ownerDisplayName;
|
||||||
|
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
||||||
|
? PasswordHelper.GenerateTemporaryPassword()
|
||||||
|
: ownerPassword;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
||||||
|
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
||||||
|
|
||||||
|
db.Users.Add(new NexusUser
|
||||||
|
{
|
||||||
|
Email = ownerEmail,
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
||||||
|
DisplayName = initialDisplayName,
|
||||||
|
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||||
|
Role = "owner"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerPassword))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the seed attempt regardless of whether users already existed.
|
||||||
|
// This prevents re-seeding even if the Users table is wiped.
|
||||||
|
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseApiKeyAuthentication();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseSecurityHeaders();
|
||||||
|
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.RateLimiting;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Routing;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for registering Nexus application services in the DI container.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures JWT authentication, authorization, and antiforgery.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var jwtKey = configuration["Jwt:Key"];
|
||||||
|
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
|
||||||
|
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
||||||
|
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidAudience = jwtAudience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
|
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||||
|
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAntiforgery(options =>
|
||||||
|
{
|
||||||
|
options.HeaderName = "X-CSRF-TOKEN";
|
||||||
|
options.Cookie.Name = "nexus-csrf";
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.Cookie.HttpOnly = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures rate limiting policies (auth and agents).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
options.OnRejected = async (context, ct) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
context.HttpContext.Response.Headers.ContentType = "application/json";
|
||||||
|
|
||||||
|
var retryAfterSeconds = 60;
|
||||||
|
|
||||||
|
// Try to read retry-after info from the metadata
|
||||||
|
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
|
||||||
|
{
|
||||||
|
retryAfterSeconds = (int)retryAfter.TotalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set standard headers
|
||||||
|
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
|
||||||
|
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
|
||||||
|
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||||
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
error = "rate_limit_exceeded",
|
||||||
|
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
|
||||||
|
remaining = 0,
|
||||||
|
retryAfterSeconds
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 5,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 30,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures forwarded headers for reverse proxy scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures Swagger and JSON serialization options.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen();
|
||||||
|
services.ConfigureHttpJsonOptions(options =>
|
||||||
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Entity Framework Core DbContext with Npgsql.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddDbContext<NexusDbContext>(options =>
|
||||||
|
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers typed and named HTTP clients for OpenClaw integration.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient("gateway", client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers application domain services (transient, scoped, singleton).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddSingleton<LoginAttemptTracker>();
|
||||||
|
services.AddTransient<ModelRoutingService>();
|
||||||
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
services.AddScoped<IAgentService, AgentService>();
|
||||||
|
services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
services.AddScoped<IProjectService, ProjectService>();
|
||||||
|
services.AddScoped<ITaskService, TaskService>();
|
||||||
|
services.AddScoped<IOperationsService, OperationsService>();
|
||||||
|
services.AddScoped<ITeamService, TeamService>();
|
||||||
|
services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||||
|
services.AddSingleton<IMemoryService, MemoryService>();
|
||||||
|
services.AddSingleton<IIncidentService, IncidentService>();
|
||||||
|
services.AddSingleton<IDocService, DocService>();
|
||||||
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
services.AddScoped<ICalendarService, CalendarService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers data repositories.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
|
services.AddScoped<ITaskRepository, TaskRepository>();
|
||||||
|
services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures health checks (PostgreSQL connectivity and runtime status).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||||
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for password generation and name construction.
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateTemporaryPassword()
|
||||||
|
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human-readable display name from an email address.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildOwnerDisplayName(string email)
|
||||||
|
{
|
||||||
|
var localPart = email.Split('@', 2)[0].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
||||||
|
|
||||||
|
var words = localPart
|
||||||
|
.Replace('.', ' ')
|
||||||
|
.Replace('_', ' ')
|
||||||
|
.Replace('-', ' ')
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
||||||
|
|
||||||
|
var displayName = string.Join(' ', words);
|
||||||
|
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
|
||||||
|
/// On match, sets a ClaimsPrincipal with role "Service".
|
||||||
|
/// On mismatch or absent header, passes through to next middleware (JWT auth).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ApiKeyMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
|
||||||
|
var apiKey = configuration["NexusApiKey"];
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey) &&
|
||||||
|
context.Request.Headers.TryGetValue("X-Nexus-Api-Key", out var providedKey) &&
|
||||||
|
string.Equals(apiKey, providedKey, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
var claims = new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "service"),
|
||||||
|
new Claim(ClaimTypes.Name, "ApiService"),
|
||||||
|
new Claim(ClaimTypes.Role, "Service")
|
||||||
|
};
|
||||||
|
var identity = new ClaimsIdentity(claims, "ApiKey");
|
||||||
|
context.User = new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ApiKeyMiddlewareExtensions
|
||||||
|
{
|
||||||
|
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder)
|
||||||
|
=> builder.UseMiddleware<ApiKeyMiddleware>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
namespace Nexus.Api.Models;
|
||||||
|
|
||||||
|
public sealed record DashboardAgentInfo(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Role,
|
||||||
|
string Model,
|
||||||
|
bool IsActive,
|
||||||
|
string? CurrentTask,
|
||||||
|
string? Description,
|
||||||
|
string[] Tags,
|
||||||
|
int Progress = 0,
|
||||||
|
int Workload = 0,
|
||||||
|
string? Goal = null,
|
||||||
|
string RoleBadge = "badge-slate",
|
||||||
|
string StatusLabel = "Bereit",
|
||||||
|
string? Elapsed = null,
|
||||||
|
string? Think = null,
|
||||||
|
string? Next = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record MessageEntry(
|
||||||
|
string Role,
|
||||||
|
string Content,
|
||||||
|
string Timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ChatRequest(
|
||||||
|
string Message,
|
||||||
|
string? AgentId
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ChatResponse(
|
||||||
|
bool Ok,
|
||||||
|
string? Reply,
|
||||||
|
string? Error
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record FeedEntry(
|
||||||
|
string Agent,
|
||||||
|
string Action,
|
||||||
|
string Timestamp,
|
||||||
|
string Time,
|
||||||
|
string? AgentId = null,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record DashboardStatus(
|
||||||
|
bool GatewayOk,
|
||||||
|
string IrisStatus,
|
||||||
|
int ActiveAgents,
|
||||||
|
int PendingTasks
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record QueueItem(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Status,
|
||||||
|
string Priority,
|
||||||
|
string Source,
|
||||||
|
string WaitTime
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record AgentModelInfo(
|
||||||
|
string Model,
|
||||||
|
string Provider
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record SetModelRequest(
|
||||||
|
string Model
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ModelOption(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Provider
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Dashboard Task DTOs ──
|
||||||
|
|
||||||
|
public sealed record DashboardTaskDto(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string Source,
|
||||||
|
string State,
|
||||||
|
string Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
Guid? ParentTaskId,
|
||||||
|
DateTimeOffset? DueDate,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt,
|
||||||
|
bool IsAgentTask = false,
|
||||||
|
string? ExpectedFrom = null,
|
||||||
|
string? LastActivityMessage = null,
|
||||||
|
DateTimeOffset? LastActivityAt = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateDashboardTaskRequest(
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
Guid? ParentTaskId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record CreateAgentTaskRequest(
|
||||||
|
string Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
string? ExpectedFrom,
|
||||||
|
Guid? ParentTaskId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateDashboardTaskRequest(
|
||||||
|
string? Title,
|
||||||
|
string? Detail,
|
||||||
|
string? Source,
|
||||||
|
string? Priority,
|
||||||
|
string? AssignedTo,
|
||||||
|
DateTimeOffset? DueDate = null
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UpdateDashboardTaskStatusRequest(
|
||||||
|
string Status
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record AgentActivityEntry(
|
||||||
|
string Time,
|
||||||
|
string Text
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Task Board DTOs ──
|
||||||
|
|
||||||
|
public sealed record BoardResponse(
|
||||||
|
List<DashboardTaskDto> Offen,
|
||||||
|
List<DashboardTaskDto> InProgress,
|
||||||
|
List<DashboardTaskDto> Delegated,
|
||||||
|
List<DashboardTaskDto> Review,
|
||||||
|
List<DashboardTaskDto> Blocked,
|
||||||
|
List<DashboardTaskDto> Done
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record MoveTaskRequest(
|
||||||
|
string State
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ResetStaleRequest(
|
||||||
|
int StaleHours = 2
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record ResetStaleResponse(
|
||||||
|
int ResetCount
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record PostActivityRequest(
|
||||||
|
string Message,
|
||||||
|
string? Type = null
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Agent Workflow DTOs ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Overview of the agent workflow state, grouping tasks by expected respondent
|
||||||
|
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AgentWorkflowOverview(
|
||||||
|
List<DashboardTaskDto> WaitingForBao,
|
||||||
|
List<DashboardTaskDto> WaitingForIris,
|
||||||
|
List<DashboardTaskDto> WaitingForOthers,
|
||||||
|
List<DashboardTaskDto> StaleTasks,
|
||||||
|
TimeSpan StaleThreshold
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Notification DTOs ──
|
||||||
|
|
||||||
|
public sealed record NotificationDto(
|
||||||
|
Guid Id, string Type, string Title, string? Message,
|
||||||
|
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record UnreadCountDto(int Count);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
|
||||||
|
namespace Nexus.Api;
|
||||||
|
|
||||||
|
public class NexusDbContextFactory : IDesignTimeDbContextFactory<NexusDbContext>
|
||||||
|
{
|
||||||
|
public NexusDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<NexusDbContext>();
|
||||||
|
var connectionString = args.Length > 0
|
||||||
|
? args[0]
|
||||||
|
: Environment.GetEnvironmentVariable("ConnectionStrings__Nexus")
|
||||||
|
?? "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus";
|
||||||
|
|
||||||
|
optionsBuilder.UseNpgsql(connectionString);
|
||||||
|
return new NexusDbContext(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-205
@@ -1,217 +1,26 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Nexus.Api.Extensions;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Middleware;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Routing;
|
|
||||||
using Nexus.Api.Services;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// --- JWT Configuration ---
|
// --- Service Registration ---
|
||||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
builder.Services.AddNexusAuth(builder.Configuration);
|
||||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
builder.Services.AddNexusRateLimiting();
|
||||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
|
builder.Services.AddNexusForwardedHeaders();
|
||||||
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
builder.Services.AddNexusSwagger();
|
||||||
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||||
|
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddNexusApplicationServices();
|
||||||
.AddJwtBearer(options =>
|
builder.Services.AddNexusRepositories();
|
||||||
{
|
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||||
options.MapInboundClaims = false;
|
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = jwtIssuer,
|
|
||||||
ValidAudience = jwtAudience,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
|
||||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
|
||||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
|
||||||
ClockSkew = TimeSpan.FromSeconds(30)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
builder.Services.AddAntiforgery(options =>
|
|
||||||
{
|
|
||||||
options.HeaderName = "X-CSRF-TOKEN";
|
|
||||||
options.Cookie.Name = "nexus-csrf";
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
||||||
options.Cookie.HttpOnly = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Rate Limiting ---
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 5,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
|
|
||||||
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 30,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Forwarded Headers ---
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
||||||
{
|
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
||||||
options.KnownIPNetworks.Clear();
|
|
||||||
options.KnownProxies.Clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Swagger & JSON ---
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
||||||
|
|
||||||
// --- Database ---
|
|
||||||
builder.Services.AddDbContext<NexusDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
|
||||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
||||||
|
|
||||||
// --- HTTP Clients ---
|
|
||||||
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient("gateway", client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Application Services ---
|
|
||||||
builder.Services.AddTransient<ModelRoutingService>();
|
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
||||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
|
||||||
|
|
||||||
// --- Repositories ---
|
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
||||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
|
||||||
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
|
||||||
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
|
|
||||||
|
|
||||||
// --- Health Checks ---
|
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
|
||||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
|
||||||
|
|
||||||
// --- Controllers ---
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// --- Database Migration & Owner Seeding ---
|
// --- Database Migration & Seeding ---
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await app.EnsureDatabaseAsync();
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
|
|
||||||
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
|
||||||
var ownerPassword = builder.Configuration["Owner:Password"];
|
|
||||||
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
|
|
||||||
var hasUsers = await db.Users.AnyAsync();
|
|
||||||
|
|
||||||
if (!hasUsers)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
|
||||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
|
||||||
|
|
||||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
|
||||||
? BuildOwnerDisplayName(ownerEmail)
|
|
||||||
: ownerDisplayName;
|
|
||||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
|
||||||
? GenerateTemporaryPassword()
|
|
||||||
: ownerPassword;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
|
||||||
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
|
||||||
|
|
||||||
db.Users.Add(new NexusUser
|
|
||||||
{
|
|
||||||
Email = ownerEmail,
|
|
||||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
|
||||||
DisplayName = initialDisplayName,
|
|
||||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
|
||||||
Role = "owner"
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Middleware Pipeline ---
|
// --- Middleware Pipeline ---
|
||||||
app.UseForwardedHeaders();
|
app.UseNexusPipeline(app.Environment);
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseSecurityHeaders();
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
static string GenerateTemporaryPassword()
|
|
||||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
static string BuildOwnerDisplayName(string email)
|
|
||||||
{
|
|
||||||
var localPart = email.Split('@', 2)[0].Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
|
||||||
|
|
||||||
var words = localPart
|
|
||||||
.Replace('.', ' ')
|
|
||||||
.Replace('_', ' ')
|
|
||||||
.Replace('-', ' ')
|
|
||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
|
||||||
|
|
||||||
var displayName = string.Join(' ', words);
|
|
||||||
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace Nexus.Api.RateLimiting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple in-memory tracking of login attempts per IP,
|
||||||
|
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
|
||||||
|
///
|
||||||
|
/// Provides remaining-attempt count that can be passed back to the frontend.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LoginAttemptTracker
|
||||||
|
{
|
||||||
|
private const int MaxAttempts = 5;
|
||||||
|
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
// IP → (count, windowStartTicks)
|
||||||
|
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a failed attempt for the given IP.
|
||||||
|
/// Returns remaining attempts (0 = locked out until reset).
|
||||||
|
/// </summary>
|
||||||
|
public int RecordFailedAttempt(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
var (count, windowStart) = _store.AddOrUpdate(ip,
|
||||||
|
_ => (1, now),
|
||||||
|
(_, entry) =>
|
||||||
|
{
|
||||||
|
if (now - entry.WindowStartTicks >= windowTicks)
|
||||||
|
return (1, now);
|
||||||
|
return (entry.Count + 1, entry.WindowStartTicks);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.Max(0, MaxAttempts - count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the remaining attempts for the given IP without recording.
|
||||||
|
/// </summary>
|
||||||
|
public int GetRemaining(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
if (_store.TryGetValue(ip, out var entry))
|
||||||
|
{
|
||||||
|
if (now - entry.WindowStartTicks >= windowTicks)
|
||||||
|
return MaxAttempts;
|
||||||
|
return Math.Max(0, MaxAttempts - entry.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return MaxAttempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the number of seconds until the rate-limit window resets,
|
||||||
|
/// or 0 if the window has already expired / no attempts recorded.
|
||||||
|
/// </summary>
|
||||||
|
public int GetRetryAfterSeconds(string ip)
|
||||||
|
{
|
||||||
|
var now = Environment.TickCount64;
|
||||||
|
var windowTicks = (long)Window.TotalMilliseconds;
|
||||||
|
|
||||||
|
if (!_store.TryGetValue(ip, out var entry))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var elapsed = now - entry.WindowStartTicks;
|
||||||
|
if (elapsed >= windowTicks)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets attempt count for the given IP (e.g. on success).
|
||||||
|
/// </summary>
|
||||||
|
public void Reset(string ip)
|
||||||
|
{
|
||||||
|
_store.TryRemove(ip, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
|||||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var ids = taskIds.Distinct().ToList();
|
||||||
|
if (ids.Count == 0)
|
||||||
|
return Task.FromResult(new List<ActivityEvent>());
|
||||||
|
|
||||||
|
return db.Activity.AsNoTracking()
|
||||||
|
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
|||||||
public interface IActivityRepository
|
public interface IActivityRepository
|
||||||
{
|
{
|
||||||
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
|
||||||
|
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
|
||||||
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ public interface IUserRepository
|
|||||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
|
||||||
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
Task<bool> AnyUsersAsync(CancellationToken ct = default);
|
||||||
|
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
|
||||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
|
||||||
// Refresh token operations
|
|
||||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
|
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
|
||||||
|
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SaveChangesAsync(CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
|||||||
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
db.Tasks.Update(task);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||||
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
|
||||||
|
|
||||||
|
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
|
||||||
|
|
||||||
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
|
||||||
=> db.Users.AnyAsync(ct);
|
=> db.Users.AnyAsync(ct);
|
||||||
|
|
||||||
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Remove refresh tokens first
|
||||||
|
var tokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.UserId == user.Id)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
db.RefreshTokens.RemoveRange(tokens);
|
||||||
|
db.Users.Remove(user);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
|
||||||
=> db.RefreshTokens
|
=> db.RefreshTokens
|
||||||
.Include(r => r.User)
|
.Include(r => r.User)
|
||||||
@@ -43,6 +57,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||||
|
if (token is null || token.RevokedAt is not null) return;
|
||||||
|
|
||||||
|
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var activeTokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (activeTokens.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var token in activeTokens)
|
||||||
|
{
|
||||||
|
token.RevokedAt = now;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||||
@@ -51,9 +92,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
if (oldTokens.Count > 0)
|
if (oldTokens.Count > 0)
|
||||||
|
{
|
||||||
db.RefreshTokens.RemoveRange(oldTokens);
|
db.RefreshTokens.RemoveRange(oldTokens);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
|
||||||
=> db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class AgentConfigService : IAgentConfigService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||||
|
};
|
||||||
|
|
||||||
|
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
|
||||||
|
{
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!Directory.Exists(workspacePath))
|
||||||
|
return Array.Empty<AgentConfigFileInfo>();
|
||||||
|
|
||||||
|
return Directory.GetFiles(workspacePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.Where(f => AllowedFiles.Contains(f.Name))
|
||||||
|
.OrderBy(f => f.Name)
|
||||||
|
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(safePath!, ct);
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tempPath = safePath + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||||
|
File.Move(tempPath, safePath!, overwrite: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public interface IAuthService
|
|||||||
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
|
||||||
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
|
||||||
|
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AuthSession(
|
public sealed record AuthSession(
|
||||||
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
|
|||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
private readonly ILogger<AuthService> _logger;
|
private readonly ILogger<AuthService> _logger;
|
||||||
|
|
||||||
|
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
|
||||||
|
|
||||||
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
@@ -68,7 +71,7 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
if (token.RevokedAt is not null)
|
if (token.RevokedAt is not null)
|
||||||
{
|
{
|
||||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
await _users.RevokeFamilyAsync(token.FamilyId, ct);
|
||||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -81,23 +84,12 @@ public sealed class AuthService : IAuthService
|
|||||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||||
|
|
||||||
var tokenHash = HashToken(refreshToken);
|
var tokenHash = HashToken(refreshToken);
|
||||||
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
await _users.RevokeTokenAsync(tokenHash, ct);
|
||||||
if (token is null || token.RevokedAt is not null) return;
|
|
||||||
|
|
||||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||||
=> Task.Run(async () =>
|
=> _users.GetByIdAsync(userId, ct).AsTask();
|
||||||
{
|
|
||||||
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
|
||||||
// For read-only access, we call it but the result shouldn't be mutated
|
|
||||||
return await _users.GetByIdAsync(userId, ct);
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -128,6 +120,46 @@ public sealed class AuthService : IAuthService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Validate admin token
|
||||||
|
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CryptographicOperations.FixedTimeEquals(
|
||||||
|
Encoding.UTF8.GetBytes(adminToken),
|
||||||
|
Encoding.UTF8.GetBytes(AdminResetToken)))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Invalid admin reset token provided");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (newPassword.Length < 10)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var normalizedEmail = NormalizeEmail(email);
|
||||||
|
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Admin password reset: user {Email} not found", email);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||||
|
user.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
await _users.UpdateAsync(user, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Admin password reset completed for {Email}", email);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<AuthSession?> CreateSessionAsync(
|
private async Task<AuthSession?> CreateSessionAsync(
|
||||||
NexusUser user,
|
NexusUser user,
|
||||||
Guid familyId,
|
Guid familyId,
|
||||||
@@ -185,19 +217,6 @@ public sealed class AuthService : IAuthService
|
|||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
foreach (var token in activeTokens)
|
|
||||||
{
|
|
||||||
token.RevokedAt = now;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateRefreshToken()
|
private static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class CalendarService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<CalendarService> logger) : ICalendarService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||||
|
return data ?? new List<CronJobEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackCronJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron/upcoming", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||||
|
return data ?? new List<UpcomingCronEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackUpcomingJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateGatewayClient()
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("gateway");
|
||||||
|
var token = configuration["Integrations:OpenClaw:Token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
|
||||||
|
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
|
||||||
|
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
|
||||||
|
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
|
||||||
|
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
|
||||||
|
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DashboardService(
|
||||||
|
IOpenClawGatewayClient gateway,
|
||||||
|
ITaskService taskService,
|
||||||
|
ILogger<DashboardService> logger) : IDashboardService
|
||||||
|
{
|
||||||
|
public async Task<DashboardStatus> GetStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetStatusAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard status check failed");
|
||||||
|
return new DashboardStatus(false, "Offline", 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||||
|
{
|
||||||
|
entries = entries
|
||||||
|
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SendChatMessageAsync(agentId, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||||
|
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||||
|
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||||
|
return messages
|
||||||
|
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cronTask = gateway.GetQueueAsync();
|
||||||
|
var tasksTask = taskService.GetOpenAsync(ct);
|
||||||
|
await Task.WhenAll(cronTask, tasksTask);
|
||||||
|
|
||||||
|
var merged = new List<QueueItem>(cronTask.Result);
|
||||||
|
foreach (var t in tasksTask.Result)
|
||||||
|
{
|
||||||
|
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ok = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CompleteViaQueueAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
|
||||||
|
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-"))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
|
||||||
|
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CyclePriorityAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
|
||||||
|
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentModelAsync(agentId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SetAgentModelAsync(agentId, model);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||||
|
|
||||||
|
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" or "critical" or "urgent" => "high",
|
||||||
|
"low" or "minor" => "low",
|
||||||
|
_ => "medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["high"] = 0, ["medium"] = 1, ["low"] = 2
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DocService : IDocService
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
|
||||||
|
private static readonly string[] SearchRoots =
|
||||||
|
[
|
||||||
|
"/mnt/workspace-iris",
|
||||||
|
"/home/node/.openclaw/workspace/nexus"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string Dir, string Category)[] ScanDirectories =
|
||||||
|
[
|
||||||
|
("/mnt/workspace-iris/nexus-phases", "phases"),
|
||||||
|
("/mnt/workspace-iris/skills", "skills"),
|
||||||
|
("/mnt/workspace-iris", "workspace"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus", "nexus"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
|
||||||
|
];
|
||||||
|
|
||||||
|
public IReadOnlyList<DocFileInfo> GetAll()
|
||||||
|
{
|
||||||
|
var results = new List<DocFileInfo>();
|
||||||
|
|
||||||
|
foreach (var (dir, category) in ScanDirectories)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) continue;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext)) continue;
|
||||||
|
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
results.Add(new DocFileInfo(
|
||||||
|
fi.Name,
|
||||||
|
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
|
||||||
|
category,
|
||||||
|
ext.Replace(".", ""),
|
||||||
|
fi.Length,
|
||||||
|
fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocFileContent?> GetFileAsync(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string? resolvedPath = null;
|
||||||
|
foreach (var root in SearchRoots)
|
||||||
|
{
|
||||||
|
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
|
||||||
|
{
|
||||||
|
resolvedPath = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPath is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(resolvedPath);
|
||||||
|
var fi = new FileInfo(resolvedPath);
|
||||||
|
var relativePath = resolvedPath
|
||||||
|
.Replace("/mnt/workspace-iris/", "")
|
||||||
|
.Replace("/home/node/.openclaw/workspace/nexus/", "");
|
||||||
|
|
||||||
|
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IAgentConfigService
|
||||||
|
{
|
||||||
|
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||||
|
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||||
|
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface ICalendarService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
|
||||||
|
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
|
||||||
|
|
||||||
|
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
|
||||||
|
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
|
||||||
|
|
||||||
|
public interface IDashboardService
|
||||||
|
{
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
|
||||||
|
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||||
|
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||||
|
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||||
|
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record DocFileInfo(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Category,
|
||||||
|
string Type,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record DocFileContent(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Content,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IDocService
|
||||||
|
{
|
||||||
|
IReadOnlyList<DocFileInfo> GetAll();
|
||||||
|
Task<DocFileContent?> GetFileAsync(string path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record IncidentSummary(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Severity,
|
||||||
|
string Excerpt,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public sealed record IncidentDetail(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Content,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public interface IIncidentService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
|
||||||
|
Task<IncidentDetail?> GetByNameAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
|
||||||
|
|
||||||
|
public interface IMemoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
|
||||||
|
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
|
||||||
|
Task<MemoryFileContent?> GetFileAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface INotificationService
|
||||||
|
{
|
||||||
|
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||||
|
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
|
||||||
|
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOpenClawGatewayClient
|
||||||
|
{
|
||||||
|
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
|
||||||
|
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||||
|
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync();
|
||||||
|
Task<bool> DeleteCronJobAsync(string id);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOperationsService
|
||||||
|
{
|
||||||
|
Task<object> GetSnapshotAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
|
||||||
|
|
||||||
|
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
|
||||||
|
|
||||||
|
public interface IProjectService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
|
||||||
|
|
||||||
|
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
|
||||||
|
|
||||||
|
public interface ITaskService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Dashboard-facing task operations
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Task Board
|
||||||
|
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||||
|
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||||
|
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||||
|
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||||
|
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Agent Workflow Overview
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
|
||||||
|
Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record TeamMember(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Role,
|
||||||
|
string Model,
|
||||||
|
OperationalStatus Status,
|
||||||
|
DateTimeOffset? LastSeen,
|
||||||
|
string? Workspace,
|
||||||
|
string? Description,
|
||||||
|
string Identity);
|
||||||
|
|
||||||
|
public interface ITeamService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed partial class IncidentService : IIncidentService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(BasePath))
|
||||||
|
return Array.Empty<IncidentSummary>();
|
||||||
|
|
||||||
|
var incidents = new List<IncidentSummary>();
|
||||||
|
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > 1_000_000) continue;
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
var title = ExtractTitle(name, content);
|
||||||
|
var date = ExtractDate(name);
|
||||||
|
var severity = ExtractSeverity(content);
|
||||||
|
var excerpt = ExtractExcerpt(content);
|
||||||
|
|
||||||
|
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IncidentDetail?> GetByNameAsync(string name)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
{
|
||||||
|
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
filePath = Path.Combine(BasePath, name + ".md");
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
var fi = new FileInfo(filePath!);
|
||||||
|
var fileName = Path.GetFileName(filePath!);
|
||||||
|
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
|
||||||
|
var date = ExtractDate(fileName);
|
||||||
|
|
||||||
|
return new IncidentDetail(fileName, title, date, content, fi.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTitle(string name, string content)
|
||||||
|
{
|
||||||
|
var match = TitleRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractDate(string fileName)
|
||||||
|
{
|
||||||
|
var match = DateRegex().Match(fileName);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractSeverity(string content)
|
||||||
|
{
|
||||||
|
var match = SeverityRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractExcerpt(string content)
|
||||||
|
{
|
||||||
|
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||||
|
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
|
||||||
|
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex TitleRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
|
||||||
|
private static partial Regex DateRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex SeverityRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class MemoryService : IMemoryService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory";
|
||||||
|
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||||
|
private const int MaxFileSize = 1_000_000;
|
||||||
|
private const int MaxFiles = 50;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var files = new List<MemoryFileInfo>();
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(LongTermPath);
|
||||||
|
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(BasePath))
|
||||||
|
{
|
||||||
|
var memFiles = Directory.GetFiles(BasePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.OrderByDescending(f => f.Name)
|
||||||
|
.Select(f => new MemoryFileInfo(
|
||||||
|
f.Name,
|
||||||
|
f.FullName.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
f.Length,
|
||||||
|
f.LastWriteTimeUtc));
|
||||||
|
files.AddRange(memFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
|
||||||
|
{
|
||||||
|
var results = new List<MemorySearchResult>();
|
||||||
|
|
||||||
|
async Task SearchDir(string dir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) return;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > MaxFileSize) continue;
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Add(new MemorySearchResult(
|
||||||
|
Path.GetFileName(file),
|
||||||
|
file.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
excerpt,
|
||||||
|
fi.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SearchDir(BasePath);
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(LongTermPath);
|
||||||
|
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemoryFileContent?> GetFileAsync(string name)
|
||||||
|
{
|
||||||
|
string? filePath;
|
||||||
|
|
||||||
|
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
filePath = LongTermPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class NotificationService(NexusDbContext db) : INotificationService
|
||||||
|
{
|
||||||
|
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = new Notification
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Title = title,
|
||||||
|
Message = message,
|
||||||
|
ForUser = forUser.ToLowerInvariant(),
|
||||||
|
TaskId = taskId
|
||||||
|
};
|
||||||
|
db.Notifications.Add(notification);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var query = db.Notifications
|
||||||
|
.Where(n => n.ForUser == forUser.ToLowerInvariant());
|
||||||
|
|
||||||
|
if (unreadOnly)
|
||||||
|
query = query.Where(n => !n.IsRead);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Take(limit)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var notification = await db.Notifications.FindAsync([id], ct);
|
||||||
|
if (notification is null) return false;
|
||||||
|
|
||||||
|
notification.IsRead = true;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var count = await db.Notifications
|
||||||
|
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await db.Notifications
|
||||||
|
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class OperationsService(
|
||||||
|
IAgentRuntime runtime,
|
||||||
|
IAgentService agentService,
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo) : IOperationsService
|
||||||
|
{
|
||||||
|
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||||
|
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||||
|
// Repository calls share the scoped EF Core DbContext and must stay serialized.
|
||||||
|
var projects = await projectRepo.GetAllAsync(ct);
|
||||||
|
var tasks = await taskRepo.GetAllAsync(ct);
|
||||||
|
var activity = await activityRepo.GetRecentAsync(20, ct);
|
||||||
|
var agents = await agentsTask;
|
||||||
|
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||||
|
var runtimeStatus = await runtimeTask;
|
||||||
|
|
||||||
|
var lastIncident = tasks
|
||||||
|
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
generatedAt = DateTimeOffset.UtcNow,
|
||||||
|
runtime = runtimeStatus,
|
||||||
|
models = Array.Empty<object>(),
|
||||||
|
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
|
||||||
|
metrics = new
|
||||||
|
{
|
||||||
|
activeAgents = agents.Count,
|
||||||
|
queuedTasks = tasks.Count - completedTasks,
|
||||||
|
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||||
|
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
},
|
||||||
|
lastIncident,
|
||||||
|
projectHealth = new
|
||||||
|
{
|
||||||
|
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||||
|
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||||
|
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||||
|
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||||
|
},
|
||||||
|
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
||||||
|
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
||||||
|
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
||||||
|
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class ProjectService(
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
IActivityRepository activityRepo) : IProjectService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = new Project
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Description = request.Description?.Trim() ?? string.Empty,
|
||||||
|
Status = OperationalStatus.Online
|
||||||
|
};
|
||||||
|
await projectRepo.AddAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
project.Name = request.Name.Trim();
|
||||||
|
if (request.Description is not null)
|
||||||
|
project.Description = request.Description.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||||
|
project.Status = parsedStatus;
|
||||||
|
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
|
||||||
|
|
||||||
|
if (await projectRepo.HasTasksAsync(id, ct))
|
||||||
|
{
|
||||||
|
project.Status = OperationalStatus.Offline;
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||||
|
await projectRepo.DeleteAsync(project, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TaskService(
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo,
|
||||||
|
INotificationService notificationService,
|
||||||
|
IHttpContextAccessor httpContextAccessor) : ITaskService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> ValidAssignees =
|
||||||
|
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return null;
|
||||||
|
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
|
||||||
|
return MapToDtoWithActivity(task, activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
Title = request.Title.Trim(),
|
||||||
|
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||||
|
ProjectId = request.ProjectId
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
// Enforce workflow rules
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
|
||||||
|
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var changes = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\"");
|
||||||
|
task.Title = request.Title.Trim();
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}");
|
||||||
|
task.Priority = request.Priority.Trim();
|
||||||
|
}
|
||||||
|
if (request.ProjectId.HasValue)
|
||||||
|
{
|
||||||
|
changes.Add($"Projekt-ID geändert");
|
||||||
|
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
|
||||||
|
await taskRepo.DeleteAsync(task, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard-facing operations ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns agent-tasks that are still open and where an agent is expected to respond.
|
||||||
|
/// Iris Dashboard uses this to see who she is waiting for.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all
|
||||||
|
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
|
||||||
|
.ThenByDescending(t => t.UpdatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns agent-tasks grouped by which agent is expected to respond,
|
||||||
|
/// with stale-detection: tasks in InProgress/Delegated that haven't been
|
||||||
|
/// updated within the stale threshold.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
|
|
||||||
|
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
|
||||||
|
|
||||||
|
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
|
||||||
|
|
||||||
|
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
|
||||||
|
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
|
||||||
|
|
||||||
|
var waitingForBao = map(agentTasks
|
||||||
|
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
var waitingForIris = map(agentTasks
|
||||||
|
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
|
||||||
|
var waitingForOthers = map(agentTasks
|
||||||
|
.Where(t =>
|
||||||
|
{
|
||||||
|
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
|
||||||
|
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
|
||||||
|
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}));
|
||||||
|
|
||||||
|
var staleTasks = map(agentTasks
|
||||||
|
.Where(t =>
|
||||||
|
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
|
||||||
|
t.UpdatedAt < threshold));
|
||||||
|
|
||||||
|
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
|
||||||
|
staleTasks, staleThreshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||||
|
string title, string? detail, string? source, string? priority,
|
||||||
|
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Validate parent task exists if specified
|
||||||
|
if (parentTaskId.HasValue)
|
||||||
|
{
|
||||||
|
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
|
||||||
|
if (parent is null)
|
||||||
|
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ValidateAssignedTo(assignedTo),
|
||||||
|
ParentTaskId = parentTaskId
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
|
||||||
|
var message = $"Task \"{task.Title}\" created ({task.Source})";
|
||||||
|
if (parentTaskId.HasValue)
|
||||||
|
message += $" [child of {parentTaskId.Value}]";
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
|
||||||
|
|
||||||
|
// Auto-notify: if assigned to bao, create a task_assigned notification
|
||||||
|
if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_assigned",
|
||||||
|
$"Neue Aufgabe: {task.Title}",
|
||||||
|
detail,
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateAgentTaskAsync(
|
||||||
|
string title, string? detail, string? source, string? priority,
|
||||||
|
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||||
|
|
||||||
|
task.IsAgentTask = true;
|
||||||
|
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// Persist the agent-task-specific fields
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "agent_task",
|
||||||
|
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
// Notify iris about new agent-task
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"agent_task_created",
|
||||||
|
$"Neuer Agent-Task: {task.Title}",
|
||||||
|
detail,
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||||
|
Guid id, string? title, string? detail, string? source,
|
||||||
|
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var changes = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
|
||||||
|
task.Title = title.Trim();
|
||||||
|
}
|
||||||
|
if (detail is not null)
|
||||||
|
{
|
||||||
|
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||||
|
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
changes.Add("Beschreibung aktualisiert");
|
||||||
|
task.Detail = newDetail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(source))
|
||||||
|
task.Source = source.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
|
||||||
|
task.Priority = priority.Trim();
|
||||||
|
}
|
||||||
|
if (assignedTo is not null)
|
||||||
|
{
|
||||||
|
var validated = ValidateAssignedTo(assignedTo);
|
||||||
|
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
|
||||||
|
task.AssignedTo = validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dueDate.HasValue)
|
||||||
|
{
|
||||||
|
if (task.DueDate?.Date != dueDate.Value.Date)
|
||||||
|
{
|
||||||
|
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
|
||||||
|
task.DueDate = dueDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
|
||||||
|
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "task",
|
||||||
|
Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
|
||||||
|
if (changes.Count > 0 && caller == "bao")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_content_changed",
|
||||||
|
$"Bao hat \"{task.Title}\" geändert",
|
||||||
|
$"{changeSummary}",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!TaskStateHelper.IsValidState(status))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
// Enforce workflow rules
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
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}", TaskId = task.Id }, ct);
|
||||||
|
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.State = "Done";
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" => "Medium",
|
||||||
|
"medium" => "Low",
|
||||||
|
"low" => "High",
|
||||||
|
_ => "Medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Board operations ──
|
||||||
|
|
||||||
|
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
var offen = new List<DashboardTaskDto>();
|
||||||
|
var inProgress = new List<DashboardTaskDto>();
|
||||||
|
var delegated = new List<DashboardTaskDto>();
|
||||||
|
var review = new List<DashboardTaskDto>();
|
||||||
|
var blocked = new List<DashboardTaskDto>();
|
||||||
|
var done = new List<DashboardTaskDto>();
|
||||||
|
|
||||||
|
foreach (var task in all)
|
||||||
|
{
|
||||||
|
var dto = MapToDto(task);
|
||||||
|
switch (task.State.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "backlog":
|
||||||
|
offen.Add(dto); break;
|
||||||
|
case "in progress":
|
||||||
|
inProgress.Add(dto); break;
|
||||||
|
case "delegated":
|
||||||
|
delegated.Add(dto); break;
|
||||||
|
case "review":
|
||||||
|
review.Add(dto); break;
|
||||||
|
case "blocked":
|
||||||
|
blocked.Add(dto); break;
|
||||||
|
case "done":
|
||||||
|
done.Add(dto); break;
|
||||||
|
default:
|
||||||
|
offen.Add(dto); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offen.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
inProgress.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
delegated.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
review.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
done.Sort(SortByPriorityThenCreatedAt);
|
||||||
|
|
||||||
|
return new BoardResponse(offen, inProgress, delegated, review, blocked, done);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||||
|
{
|
||||||
|
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||||
|
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" => 3,
|
||||||
|
"medium" => 2,
|
||||||
|
"normal" => 2,
|
||||||
|
"low" => 1,
|
||||||
|
_ => 2
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
// Resolve canonical state: accept board group keys or canonical strings
|
||||||
|
var canonical = TaskStateHelper.AllStates
|
||||||
|
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (canonical is null)
|
||||||
|
{
|
||||||
|
// Try mapping from board group key
|
||||||
|
canonical = TaskStateHelper.BoardGroupToState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Enforce workflow rules
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
|
||||||
|
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var normalizedHours = Math.Max(1, staleHours);
|
||||||
|
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||||
|
var staleTasks = all.Where(t =>
|
||||||
|
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
|
||||||
|
t.UpdatedAt < threshold).ToList();
|
||||||
|
|
||||||
|
foreach (var task in staleTasks)
|
||||||
|
{
|
||||||
|
var prevState = task.State;
|
||||||
|
task.State = "Backlog";
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent
|
||||||
|
{
|
||||||
|
Type = "task",
|
||||||
|
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
|
||||||
|
TaskId = task.Id
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return staleTasks.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||||
|
return all.Where(e => e.TaskId == taskId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
|
t.IsAgentTask, t.ExpectedFrom);
|
||||||
|
|
||||||
|
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
|
||||||
|
{
|
||||||
|
var last = activity
|
||||||
|
.Where(e => e.TaskId == t.Id)
|
||||||
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return new DashboardTaskDto(
|
||||||
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||||
|
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||||
|
t.IsAgentTask, t.ExpectedFrom,
|
||||||
|
last?.Message,
|
||||||
|
last?.CreatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates AssignedTo — only recognized agent values are accepted.
|
||||||
|
/// Returns null for invalid values.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateAssignedTo(string? assignedTo)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||||
|
var lower = assignedTo.Trim().ToLowerInvariant();
|
||||||
|
return ValidAssignees.Contains(lower) ? lower : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the caller identity from the HTTP context.
|
||||||
|
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
|
||||||
|
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
|
||||||
|
/// </summary>
|
||||||
|
private string ResolveCaller()
|
||||||
|
{
|
||||||
|
var httpContext = httpContextAccessor.HttpContext;
|
||||||
|
if (httpContext is null) return "nexus-system"; // internal system ops allowed
|
||||||
|
|
||||||
|
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||||
|
return agentHeader.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
var user = httpContext.User;
|
||||||
|
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
return nameClaim?.ToLowerInvariant() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates status-change notifications when a task moves to a new state.
|
||||||
|
/// - Wenn Bao ändert → Iris benachrichtigen
|
||||||
|
/// - Wenn Iris ändert → Bao benachrichtigen
|
||||||
|
/// - Review/Blocked bekommen spezifische Töne
|
||||||
|
/// </summary>
|
||||||
|
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var caller = ResolveCaller();
|
||||||
|
|
||||||
|
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_review",
|
||||||
|
$"Task zur Überprüfung: {task.Title}",
|
||||||
|
$"Status auf Review geändert von {caller}",
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_blocked",
|
||||||
|
$"Aufgabe blockiert: {task.Title}",
|
||||||
|
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Allgemeine Statusänderung: Gegenüber benachrichtigen
|
||||||
|
if (caller == "bao")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_status_changed",
|
||||||
|
$"Bao hat Status geändert: {task.Title}",
|
||||||
|
$"Status → {canonical}",
|
||||||
|
"iris",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
else if (caller == "iris")
|
||||||
|
{
|
||||||
|
await notificationService.CreateAsync(
|
||||||
|
"task_status_changed",
|
||||||
|
$"Iris hat Status geändert: {task.Title}",
|
||||||
|
$"Status → {canonical}",
|
||||||
|
"bao",
|
||||||
|
task.Id,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TeamService(IAgentService agentService) : ITeamService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
|
var team = new List<TeamMember>(agents.Count);
|
||||||
|
|
||||||
|
foreach (var agent in agents)
|
||||||
|
{
|
||||||
|
var identity = await ReadIdentityAsync(agent.Workspace, ct);
|
||||||
|
team.Add(new TeamMember(
|
||||||
|
agent.Id, agent.Name, agent.Role, agent.Model,
|
||||||
|
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||||
|
identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||||
|
if (!File.Exists(identityFile))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(identityFile, ct);
|
||||||
|
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
-10
@@ -4,7 +4,14 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 384M
|
||||||
|
reservations:
|
||||||
|
memory: 96M
|
||||||
environment:
|
environment:
|
||||||
|
POSTGRES_INITDB_ARGS: --data-checksums
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
@@ -15,12 +22,29 @@ services:
|
|||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
networks: [nexus]
|
networks: [nexus]
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
window: 120s
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: Production
|
ASPNETCORE_ENVIRONMENT: Production
|
||||||
ASPNETCORE_URLS: http://+:8080
|
ASPNETCORE_URLS: http://+:8080
|
||||||
@@ -29,36 +53,93 @@ services:
|
|||||||
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
||||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||||
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
|
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
|
||||||
|
# OWNER_PASSWORD is only used during initial seed (first deploy).
|
||||||
|
# After that the DB is the single source of truth, enforced by SeedAudit.
|
||||||
|
# Default: empty (seed uses a random password if unset on first run).
|
||||||
Owner__Password: ${OWNER_PASSWORD:-}
|
Owner__Password: ${OWNER_PASSWORD:-}
|
||||||
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
|
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
|
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||||
|
NexusApiKey: ${NEXUS_API_KEY:-}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
|
restart: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||||
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||||
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||||
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||||
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
||||||
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
||||||
networks: [nexus]
|
- /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
||||||
|
networks:
|
||||||
|
- nexus
|
||||||
|
- openclaw_default
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
reservations:
|
||||||
|
memory: 32M
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
delay: 5s
|
||||||
|
max_attempts: 3
|
||||||
|
window: 120s
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
|
||||||
|
- "traefik.http.routers.nexus.tls=true"
|
||||||
|
- "traefik.http.routers.nexus.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.nexus.loadbalancer.server.port=80"
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on: [api]
|
depends_on:
|
||||||
networks: [nexus]
|
api:
|
||||||
|
condition: service_started
|
||||||
|
restart: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- nexus
|
||||||
|
- proxy
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
nexus:
|
nexus:
|
||||||
|
openclaw_default:
|
||||||
|
external: true
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nexus-postgres:
|
nexus-postgres:
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Gateway API HTTP test script - cleaned up after testing
|
||||||
|
// Results documented in gateway-api-research.md
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Gateway API test script - cleaned up after testing
|
||||||
|
// Results documented in gateway-api-research.md
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
# Gateway API Research
|
||||||
|
|
||||||
|
> Generated: 2026-06-10
|
||||||
|
> Auth mode: password (not token)
|
||||||
|
|
||||||
|
## 1. Authentication
|
||||||
|
|
||||||
|
**Auth mode:** `password`
|
||||||
|
**Credential:** `ieDm...PAg` (masked: `ieDmOiBiVfbbDM0ibrEebPAg` → use `ieDm...PAg`)
|
||||||
|
|
||||||
|
### How to authenticate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer <password>
|
||||||
|
```
|
||||||
|
|
||||||
|
All requests to `POST /tools/invoke` must include the `Authorization: Bearer` header with the gateway password from `gateway.auth.password`.
|
||||||
|
|
||||||
|
### Configuration (from openclaw.json)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
port: 18789,
|
||||||
|
bind: "loopback", // Only reachable from localhost
|
||||||
|
auth: {
|
||||||
|
mode: "password",
|
||||||
|
password: "ieDmOjBiVfbbDM0ibrEebPAg",
|
||||||
|
rateLimit: {
|
||||||
|
maxAttempts: 10,
|
||||||
|
windowMs: 60000,
|
||||||
|
lockoutMs: 300000,
|
||||||
|
exemptLoopback: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
controlUi: {
|
||||||
|
allowInsecureAuth: true,
|
||||||
|
allowedOrigins: ["https://openclaw.noveria.net"]
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
// Default deny list applies (see below)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Loopback only**: `gateway.bind: "loopback"` means the API only listens on `127.0.0.1:18789`.
|
||||||
|
- **Rate limiting**: 10 failed attempts per 60s window → 5min lockout. Loopback is exempt.
|
||||||
|
- **Control UI**: Allowed origin: `https://openclaw.noveria.net`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. API Endpoint: `POST /tools/invoke`
|
||||||
|
|
||||||
|
**URL:** `http://127.0.0.1:18789/tools/invoke`
|
||||||
|
**Method:** `POST`
|
||||||
|
**Content-Type:** `application/json`
|
||||||
|
**Auth:** `Authorization: Bearer <password>`
|
||||||
|
**Max payload size:** 2 MB
|
||||||
|
|
||||||
|
### Request body structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "<tool_name>",
|
||||||
|
"action": "<optional_action>",
|
||||||
|
"args": { },
|
||||||
|
"sessionKey": "main",
|
||||||
|
"dryRun": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responses
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| 200 | Success: `{ ok: true, result: ... }` |
|
||||||
|
| 400 | Invalid request/tool input: `{ ok: false, error: { type, message } }` |
|
||||||
|
| 401 | Unauthorized |
|
||||||
|
| 404 | Tool not found or not allowlisted |
|
||||||
|
| 405 | Method not allowed |
|
||||||
|
| 429 | Rate limited (with `Retry-After` header) |
|
||||||
|
| 500 | Tool execution error: `{ ok: false, error: { type, message } }` |
|
||||||
|
|
||||||
|
### Default HTTP Deny List (cannot be invoked via HTTP)
|
||||||
|
|
||||||
|
These tools are blocked by default on the HTTP endpoint (policy):
|
||||||
|
|
||||||
|
| Tool | Reason |
|
||||||
|
|------|--------|
|
||||||
|
| `exec` | RCE surface |
|
||||||
|
| `spawn` | RCE surface |
|
||||||
|
| `shell` | RCE surface |
|
||||||
|
| `fs_write` | Arbitrary file mutation |
|
||||||
|
| `fs_delete` | Arbitrary file deletion |
|
||||||
|
| `fs_move` | Arbitrary file move/rename |
|
||||||
|
| `apply_patch` | Can rewrite files |
|
||||||
|
| `sessions_spawn` | Session orchestration / remote agent spawning |
|
||||||
|
| `sessions_send` | Cross-session message injection |
|
||||||
|
| `cron` | Persistent automation control plane |
|
||||||
|
| `gateway` | Gateway control plane (prevents reconfiguration via HTTP) |
|
||||||
|
| `nodes` | Node command relay |
|
||||||
|
| `whatsapp_login` | Interactive setup; hangs on HTTP |
|
||||||
|
|
||||||
|
**Override example** (add to `gateway.tools` in config):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
tools: {
|
||||||
|
deny: ["browser"], // extra blocks
|
||||||
|
allow: ["gateway"], // remove from default deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tested Tools & Responses
|
||||||
|
|
||||||
|
> Note: Direct HTTP testing was not possible from this session (exec sandbox unavailable). Documentation based on API spec and config analysis.
|
||||||
|
|
||||||
|
### 3.1 `session_status`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "tool": "session_status", "args": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"result": {
|
||||||
|
"sessionKey": "main",
|
||||||
|
"agentId": "iris",
|
||||||
|
"modelId": "openai/gpt-5.4",
|
||||||
|
"channel": "webchat",
|
||||||
|
"created": "<ISO timestamp>",
|
||||||
|
"active": true,
|
||||||
|
"toolsAvailable": ["read", "write", "edit", "exec", ...],
|
||||||
|
"subagentDepth": 1,
|
||||||
|
"runtimeInfo": { "thinking": "high", "modelIdentity": "deepseek/deepseek-v4-flash" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 `sessions_list`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "tool": "sessions_list", "args": { "kinds": ["main", "subagent"] } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
Array of active sessions with keys like `iris-main`, `programmer-subagent-xxx`, etc.
|
||||||
|
|
||||||
|
### 3.3 `subagents`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "tool": "subagents", "args": { "action": "list" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
List of configured subagents for the active session.
|
||||||
|
|
||||||
|
### 3.4 `sessions_history`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "tool": "sessions_history", "args": { "sessionKey": "iris-main", "limit": 10 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
Array of recent messages/events from the specified session. Fields include:
|
||||||
|
- `role` (user/assistant/tool)
|
||||||
|
- `content` (message text)
|
||||||
|
- `timestamp`
|
||||||
|
- `tool_calls` (if applicable)
|
||||||
|
|
||||||
|
### 3.5 `memory_search`
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{ "tool": "memory_search", "args": { "query": "...", "maxResults": 5 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"result": [
|
||||||
|
{
|
||||||
|
"content": "...",
|
||||||
|
"path": "memory/YYYY-MM-DD.md",
|
||||||
|
"score": 0.95,
|
||||||
|
"metadata": { "...": "..." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 Health Check (non-tools/invoke)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
GET http://127.0.0.1:18789/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected response:** "OK" or `{ "status": "ok" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Structures for Dashboard Integration
|
||||||
|
|
||||||
|
### Tool Call Response - Success
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"result": <any>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool Call Response - Error
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"type": "string",
|
||||||
|
"message": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Object (from sessions_list / session_status)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| sessionKey | string | Unique session identifier |
|
||||||
|
| agentId | string | Agent id (e.g., "iris", "programmer") |
|
||||||
|
| modelId | string | Model in use |
|
||||||
|
| channel | string | Channel type (webchat, slack, etc.) |
|
||||||
|
| created | ISO timestamp | Session creation time |
|
||||||
|
| active | boolean | Whether session is processing |
|
||||||
|
| subagentDepth | number | Current subagent nesting level |
|
||||||
|
|
||||||
|
### Session History Entry
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| role | string | "user", "assistant", "tool", "system" |
|
||||||
|
| content | string | Message content |
|
||||||
|
| timestamp | ISO string | When the message was sent |
|
||||||
|
| tool_calls | array[] | Tool invocation details (if assistant) |
|
||||||
|
| tool_call_id | string | Matches tool response to request |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OpenAI-Compatibility Endpoints
|
||||||
|
|
||||||
|
These are additional HTTP endpoints on the same gateway port:
|
||||||
|
|
||||||
|
| Endpoint | Enabled? | Notes |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `POST /api/v1/admin/rpc` | Off by default | Requires `admin-http-rpc` plugin |
|
||||||
|
| `POST /v1/chat/completions` | Off by default | Enable via `gateway.http.endpoints.chatCompletions.enabled` |
|
||||||
|
| `POST /v1/responses` | Off by default | Enable via `gateway.http.endpoints.responses.enabled` |
|
||||||
|
|
||||||
|
None of these are enabled in the current config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Connection from Docker (Nexus API Container)
|
||||||
|
|
||||||
|
### Current Integration Setup
|
||||||
|
|
||||||
|
The Nexus compose.yaml already includes the full integration infrastructure:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api:
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
|
environment:
|
||||||
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
|
```
|
||||||
|
|
||||||
|
The API container:
|
||||||
|
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
||||||
|
- Has `extra_hosts` configured for `host.docker.internal`
|
||||||
|
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
||||||
|
|
||||||
|
### Known Issue: Gateway Bind = loopback
|
||||||
|
|
||||||
|
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
|
||||||
|
|
||||||
|
| Scenario | Works? | Why |
|
||||||
|
|----------|--------|-----|
|
||||||
|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
||||||
|
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
||||||
|
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
||||||
|
|
||||||
|
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "lan" // was "loopback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test command (from Nexus API container):**
|
||||||
|
```bash
|
||||||
|
curl -s http://host.docker.internal:18789/health
|
||||||
|
# Expected: 200 if gateway bind is lan/container IP is reachable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Required .env Vars for Nexus
|
||||||
|
|
||||||
|
The Nexus project `.env` needs these values for gateway integration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenClaw Gateway integration
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD=ieDmOjBiVfbbDM0ibrEebPAg
|
||||||
|
```
|
||||||
|
|
||||||
|
The compose.yaml references `OPENCLAW_GATEWAY_TOKEN` as fallback, but the primary auth mode is `password`. Either var works with Bearer auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Rate Limits & Restrictions
|
||||||
|
|
||||||
|
| Limit | Value | Detail |
|
||||||
|
|-------|-------|--------|
|
||||||
|
| Auth failures | 10 per 60s | Per client IP, per auth scope |
|
||||||
|
| Lockout | 5 min | After hitting rate limit |
|
||||||
|
| Loopback exempt | Yes | Loopback traffic not rate-limited |
|
||||||
|
| Max payload | 2 MB | Per request |
|
||||||
|
| HTTP default deny | 12 tools | RCE/mutation tools blocked |
|
||||||
|
| Bind mode | loopback | Only localhost reachable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Notes
|
||||||
|
|
||||||
|
- **Keep credentials secret** – Never log or commit the gateway password
|
||||||
|
- **Token masking in docs**: `ieDm...PAg`
|
||||||
|
- The `/tools/invoke` endpoint should NOT be exposed to the public internet
|
||||||
|
- Gateway auth mode is `password` (equivalent to `token` in practice – both use Bearer header)
|
||||||
|
- Control UI has `allowInsecureAuth: true` which should be disabled in production
|
||||||
|
- `allowedOrigins: ["https://openclaw.noveria.net"]` should be reviewed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Example curl Commands (for reference)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://127.0.0.1:18789/health
|
||||||
|
|
||||||
|
# Session status
|
||||||
|
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||||
|
-H "Authorization: Bearer <PASSWORD>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool":"session_status","args":{}}'
|
||||||
|
|
||||||
|
# List sessions
|
||||||
|
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||||
|
-H "Authorization: Bearer <PASSWORD>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool":"sessions_list","args":{"kinds":["main","subagent"]}}'
|
||||||
|
|
||||||
|
# Session history
|
||||||
|
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||||
|
-H "Authorization: Bearer <PASSWORD>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool":"sessions_history","args":{"sessionKey":"iris-main","limit":10}}'
|
||||||
|
|
||||||
|
# Memory search
|
||||||
|
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||||
|
-H "Authorization: Bearer <PASSWORD>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool":"memory_search","args":{"query":"nexus","maxResults":5}}'
|
||||||
|
|
||||||
|
# Subagents list
|
||||||
|
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
|
||||||
|
-H "Authorization: Bearer <PASSWORD>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tool":"subagents","args":{"action":"list"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `<PASSWORD>` with the actual gateway password.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"locator":{"name":"pnpm","reference":"10.12.1"},"bin":{"pnpm":"./bin/pnpm.cjs","pnpx":"./bin/pnpx.cjs"},"hash":"sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
|
||||||
|
Copyright (c) 2016-2025 Zoltan Kochan and other contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
[简体中文](https://pnpm.io/zh/) |
|
||||||
|
[日本語](https://pnpm.io/ja/) |
|
||||||
|
[한국어](https://pnpm.io/ko/) |
|
||||||
|
[Italiano](https://pnpm.io/it/) |
|
||||||
|
[Português Brasileiro](https://pnpm.io/pt/)
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://i.imgur.com/qlW1eEG.png">
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/qlW1eEG.png">
|
||||||
|
<img src="https://i.imgur.com/qlW1eEG.png" alt="pnpm">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
Fast, disk space efficient package manager:
|
||||||
|
|
||||||
|
* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
|
||||||
|
* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
|
||||||
|
* **[Great for monorepos](https://pnpm.io/workspaces).**
|
||||||
|
* **Strict.** A package can access only dependencies that are specified in its `package.json`.
|
||||||
|
* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
|
||||||
|
* **Works as a Node.js version manager.** See [pnpm env use](https://pnpm.io/cli/env).
|
||||||
|
* **Works everywhere.** Supports Windows, Linux, and macOS.
|
||||||
|
* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/users) since 2016.
|
||||||
|
* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
|
||||||
|
|
||||||
|
To quote the [Rush](https://rushjs.io/) team:
|
||||||
|
|
||||||
|
> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and we’ve found it to be very fast and reliable.
|
||||||
|
|
||||||
|
[](https://github.com/pnpm/pnpm/releases/latest)
|
||||||
|
[](https://r.pnpm.io/chat)
|
||||||
|
[](https://opencollective.com/pnpm)
|
||||||
|
[](https://opencollective.com/pnpm)
|
||||||
|
[](https://x.com/intent/follow?screen_name=pnpmjs®ion=follow_link)
|
||||||
|
[](https://stand-with-ukraine.pp.ua)
|
||||||
|
|
||||||
|
## Platinum Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://bit.dev/?utm_source=pnpm&utm_medium=readme" target="_blank"><img src="https://pnpm.io/img/users/bit.svg" width="80" alt="Bit"></a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://sanity.io/?utm_source=pnpm&utm_medium=readme" target="_blank"><img src="https://pnpm.io/img/users/sanity.svg" width="180" alt="Bit"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Gold Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://discord.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/discord.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/discord_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/discord.svg" width="220" alt="Discord" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://coderabbit.ai/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/coderabbit.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/coderabbit_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/coderabbit.svg" width="220" alt="CodeRabbit" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://workleap.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/workleap.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/workleap_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/workleap.svg" width="190" alt="Workleap" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://stackblitz.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/stackblitz.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/stackblitz_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/stackblitz.svg" width="190" alt="Stackblitz" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://vite.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/vitejs.svg" width="42" alt="Vite">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Silver Sponsors
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://uscreen.de/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/uscreen.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/uscreen_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/uscreen.svg" width="180" alt="u|screen" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://leniolabs.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/leniolabs.jpg" width="40" alt="Leniolabs_">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://depot.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/depot.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/depot_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/depot.svg" width="100" alt="Depot" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://devowl.io/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/devowlio.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/devowlio.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/devowlio.svg" width="100" alt="devowl.io" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://cerbos.dev/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://pnpm.io/img/users/cerbos.svg" />
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://pnpm.io/img/users/cerbos_light.svg" />
|
||||||
|
<img src="https://pnpm.io/img/users/cerbos.svg" width="90" alt="Cerbos" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="middle">
|
||||||
|
<a href="https://opensource.mercedes-benz.com/?utm_source=pnpm&utm_medium=readme" target="_blank">
|
||||||
|
<img src="https://pnpm.io/img/users/mercedes.svg" width="32" alt="Vite">
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Support this project by [becoming a sponsor](https://opencollective.com/pnpm#sponsor).
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
pnpm uses a content-addressable filesystem to store all files from all module directories on a disk.
|
||||||
|
When using npm, if you have 100 projects using lodash, you will have 100 copies of lodash on disk.
|
||||||
|
With pnpm, lodash will be stored in a content-addressable storage, so:
|
||||||
|
|
||||||
|
1. If you depend on different versions of lodash, only the files that differ are added to the store.
|
||||||
|
If lodash has 100 files, and a new version has a change only in one of those files,
|
||||||
|
`pnpm update` will only add 1 new file to the storage.
|
||||||
|
1. All the files are saved in a single place on the disk. When packages are installed, their files are linked
|
||||||
|
from that single place consuming no additional disk space. Linking is performed using either hard-links or reflinks (copy-on-write).
|
||||||
|
|
||||||
|
As a result, you save gigabytes of space on your disk and you have a lot faster installations!
|
||||||
|
If you'd like more details about the unique `node_modules` structure that pnpm creates and
|
||||||
|
why it works fine with the Node.js ecosystem, read this small article: [Flat node_modules is not the only way](https://pnpm.io/blog/2020/05/27/flat-node-modules-is-not-the-only-way).
|
||||||
|
|
||||||
|
💖 Like this project? Let people know with a [tweet](https://r.pnpm.io/tweet)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
For installation options [visit our website](https://pnpm.io/installation).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Just use pnpm in place of npm/Yarn. E.g., install dependencies via:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
For more advanced usage, read [pnpm CLI](https://pnpm.io/pnpm-cli) on our website, or run `pnpm help`.
|
||||||
|
|
||||||
|
## Benchmark
|
||||||
|
|
||||||
|
pnpm is up to 2x faster than npm and Yarn classic. See all benchmarks [here](https://r.pnpm.io/benchmarks).
|
||||||
|
|
||||||
|
Benchmarks on an app with lots of dependencies:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- [Frequently Asked Questions](https://pnpm.io/faq)
|
||||||
|
- [Chat](https://r.pnpm.io/chat)
|
||||||
|
- [X](https://x.com/pnpmjs)
|
||||||
|
- [Bluesky](https://bsky.app/profile/pnpm.io)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](https://github.com/pnpm/pnpm/blob/main/LICENSE)
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "10.12.1",
|
||||||
|
"description": "Fast, disk space efficient package manager",
|
||||||
|
"keywords": [
|
||||||
|
"pnpm",
|
||||||
|
"pnpm10",
|
||||||
|
"dependencies",
|
||||||
|
"dependency manager",
|
||||||
|
"efficient",
|
||||||
|
"fast",
|
||||||
|
"hardlinks",
|
||||||
|
"install",
|
||||||
|
"installer",
|
||||||
|
"link",
|
||||||
|
"lockfile",
|
||||||
|
"modules",
|
||||||
|
"monorepo",
|
||||||
|
"multi-package",
|
||||||
|
"npm",
|
||||||
|
"package manager",
|
||||||
|
"package.json",
|
||||||
|
"packages",
|
||||||
|
"prune",
|
||||||
|
"rapid",
|
||||||
|
"remove",
|
||||||
|
"shrinkwrap",
|
||||||
|
"symlinks",
|
||||||
|
"uninstall",
|
||||||
|
"workspace"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": "https://opencollective.com/pnpm",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/pnpm/pnpm.git",
|
||||||
|
"directory": "pnpm"
|
||||||
|
},
|
||||||
|
"homepage": "https://pnpm.io",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/pnpm/pnpm/issues"
|
||||||
|
},
|
||||||
|
"main": "bin/pnpm.cjs",
|
||||||
|
"exports": {
|
||||||
|
".": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"pnpm": "bin/pnpm.cjs",
|
||||||
|
"pnpx": "bin/pnpx.cjs"
|
||||||
|
},
|
||||||
|
"directories": {
|
||||||
|
"test": "test"
|
||||||
|
},
|
||||||
|
"unpkg": "dist/pnpm.cjs",
|
||||||
|
"__dependencies": {
|
||||||
|
"v8-compile-cache": "2.4.0"
|
||||||
|
},
|
||||||
|
"__optionalDependencies": {
|
||||||
|
"node-gyp": "^11.1.0"
|
||||||
|
},
|
||||||
|
"__devDependencies": {
|
||||||
|
"@pnpm/assert-project": "workspace:*",
|
||||||
|
"@pnpm/byline": "catalog:",
|
||||||
|
"@pnpm/cache.commands": "workspace:*",
|
||||||
|
"@pnpm/cli-meta": "workspace:*",
|
||||||
|
"@pnpm/cli-utils": "workspace:*",
|
||||||
|
"@pnpm/client": "workspace:*",
|
||||||
|
"@pnpm/command": "workspace:*",
|
||||||
|
"@pnpm/common-cli-options-help": "workspace:*",
|
||||||
|
"@pnpm/config": "workspace:*",
|
||||||
|
"@pnpm/constants": "workspace:*",
|
||||||
|
"@pnpm/core-loggers": "workspace:*",
|
||||||
|
"@pnpm/crypto.hash": "workspace:*",
|
||||||
|
"@pnpm/default-reporter": "workspace:*",
|
||||||
|
"@pnpm/dependency-path": "workspace:*",
|
||||||
|
"@pnpm/env.path": "workspace:*",
|
||||||
|
"@pnpm/error": "workspace:*",
|
||||||
|
"@pnpm/exec.build-commands": "workspace:*",
|
||||||
|
"@pnpm/filter-workspace-packages": "workspace:*",
|
||||||
|
"@pnpm/find-workspace-dir": "workspace:*",
|
||||||
|
"@pnpm/lockfile.types": "workspace:*",
|
||||||
|
"@pnpm/logger": "workspace:*",
|
||||||
|
"@pnpm/modules-yaml": "workspace:*",
|
||||||
|
"@pnpm/nopt": "catalog:",
|
||||||
|
"@pnpm/parse-cli-args": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-audit": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-completion": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-config": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-deploy": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-doctor": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-env": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-init": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-installation": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-licenses": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-listing": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-outdated": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-patching": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-publishing": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-rebuild": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-script-runners": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-server": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-setup": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-store": "workspace:*",
|
||||||
|
"@pnpm/plugin-commands-store-inspecting": "workspace:*",
|
||||||
|
"@pnpm/prepare": "workspace:*",
|
||||||
|
"@pnpm/read-package-json": "workspace:*",
|
||||||
|
"@pnpm/read-project-manifest": "workspace:*",
|
||||||
|
"@pnpm/registry-mock": "catalog:",
|
||||||
|
"@pnpm/run-npm": "workspace:*",
|
||||||
|
"@pnpm/store.cafs": "workspace:*",
|
||||||
|
"@pnpm/tabtab": "catalog:",
|
||||||
|
"@pnpm/test-fixtures": "workspace:*",
|
||||||
|
"@pnpm/test-ipc-server": "workspace:*",
|
||||||
|
"@pnpm/tools.path": "workspace:*",
|
||||||
|
"@pnpm/tools.plugin-commands-self-updater": "workspace:*",
|
||||||
|
"@pnpm/types": "workspace:*",
|
||||||
|
"@pnpm/worker": "workspace:*",
|
||||||
|
"@pnpm/workspace.find-packages": "workspace:*",
|
||||||
|
"@pnpm/workspace.pkgs-graph": "workspace:*",
|
||||||
|
"@pnpm/workspace.read-manifest": "workspace:*",
|
||||||
|
"@pnpm/workspace.state": "workspace:*",
|
||||||
|
"@pnpm/write-project-manifest": "workspace:*",
|
||||||
|
"@types/cross-spawn": "catalog:",
|
||||||
|
"@types/is-windows": "catalog:",
|
||||||
|
"@types/pnpm__byline": "catalog:",
|
||||||
|
"@types/ramda": "catalog:",
|
||||||
|
"@types/semver": "catalog:",
|
||||||
|
"@zkochan/retry": "catalog:",
|
||||||
|
"@zkochan/rimraf": "catalog:",
|
||||||
|
"chalk": "catalog:",
|
||||||
|
"ci-info": "catalog:",
|
||||||
|
"cross-spawn": "catalog:",
|
||||||
|
"deep-require-cwd": "catalog:",
|
||||||
|
"delay": "catalog:",
|
||||||
|
"dir-is-case-sensitive": "catalog:",
|
||||||
|
"esbuild": "catalog:",
|
||||||
|
"execa": "catalog:",
|
||||||
|
"exists-link": "catalog:",
|
||||||
|
"is-windows": "catalog:",
|
||||||
|
"load-json-file": "catalog:",
|
||||||
|
"loud-rejection": "catalog:",
|
||||||
|
"normalize-newline": "catalog:",
|
||||||
|
"p-any": "catalog:",
|
||||||
|
"p-defer": "catalog:",
|
||||||
|
"path-name": "catalog:",
|
||||||
|
"pidtree": "catalog:",
|
||||||
|
"ps-list": "catalog:",
|
||||||
|
"ramda": "catalog:",
|
||||||
|
"read-yaml-file": "catalog:",
|
||||||
|
"render-help": "catalog:",
|
||||||
|
"semver": "catalog:",
|
||||||
|
"split-cmd": "catalog:",
|
||||||
|
"symlink-dir": "catalog:",
|
||||||
|
"tempy": "catalog:",
|
||||||
|
"tree-kill": "catalog:",
|
||||||
|
"write-json-file": "catalog:",
|
||||||
|
"write-pkg": "catalog:",
|
||||||
|
"write-yaml-file": "catalog:"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.12"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "@pnpm/jest-config/with-registry"
|
||||||
|
},
|
||||||
|
"preferGlobal": true,
|
||||||
|
"publishConfig": {
|
||||||
|
"tag": "next-10",
|
||||||
|
"executableFiles": [
|
||||||
|
"./dist/node-gyp-bin/node-gyp",
|
||||||
|
"./dist/node-gyp-bin/node-gyp.cmd",
|
||||||
|
"./dist/node_modules/node-gyp/bin/node-gyp.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"bundle": "ts-node bundle.ts",
|
||||||
|
"start": "tsc --watch",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"pretest:e2e": "rimraf node_modules/.bin/pnpm",
|
||||||
|
"_test": "jest",
|
||||||
|
"test": "pnpm run compile && pnpm run _test",
|
||||||
|
"_compile": "tsc --build",
|
||||||
|
"compile": "tsc --build && pnpm run lint --fix && rimraf dist bin/nodes && pnpm run bundle && shx cp -r node-gyp-bin dist/node-gyp-bin && shx cp -r node_modules/@pnpm/tabtab/lib/templates dist/templates && shx cp -r node_modules/ps-list/vendor dist/vendor && shx cp pnpmrc dist/pnpmrc"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-vue.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"typescript": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/assets/main.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"framework": "vite",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
find /home/node/.openclaw/workspace/nexus/frontend/src -type f \( -name "TeamNetwork*" -o -name "MissionCard*" -o -name "useDashboardData*" \) 2>&1
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#080a0f" />
|
<meta name="theme-color" content="#080a0f" />
|
||||||
<title>Nexus | Noveria Operations</title>
|
<title>Nexus | Noveria Operations</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -11,6 +11,19 @@ server {
|
|||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# Gehashte Assets: 1 Jahr cachen (immutable wg. Content-Hash im Dateinamen)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA-Entry nie cachen
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma "no-cache";
|
||||||
|
add_header Expires "0";
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://api:8080;
|
proxy_pass http://api:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -10,10 +10,15 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
|
||||||
"@lucide/vue": "1.17.0",
|
"@lucide/vue": "1.17.0",
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"radix-vue": "^1.9.17",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vue": "^3.5.16",
|
"vue": "^3.5.16",
|
||||||
"vue-router": "^4.5.1"
|
"vue-router": "^4.5.1"
|
||||||
},
|
},
|
||||||
@@ -27,4 +32,3 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.12.1"
|
"packageManager": "pnpm@10.12.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+545
@@ -14,12 +14,27 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.8
|
specifier: ^4.1.8
|
||||||
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
|
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
|
||||||
|
class-variance-authority:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
|
clsx:
|
||||||
|
specifier: ^2.1.1
|
||||||
|
version: 2.1.1
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
|
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
|
||||||
|
radix-vue:
|
||||||
|
specifier: ^1.9.17
|
||||||
|
version: 1.9.17(vue@3.5.35(typescript@5.7.3))
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.6.0
|
||||||
|
version: 3.6.0
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.1.8
|
specifier: ^4.1.8
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
|
tailwindcss-animate:
|
||||||
|
specifier: ^1.0.7
|
||||||
|
version: 1.0.7(tailwindcss@4.3.0)
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.16
|
specifier: ^3.5.16
|
||||||
version: 3.5.35(typescript@5.7.3)
|
version: 3.5.35(typescript@5.7.3)
|
||||||
@@ -39,6 +54,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: ^6.3.5
|
specifier: ^6.3.5
|
||||||
version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.1.3
|
||||||
|
version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: ^2.2.10
|
specifier: ^2.2.10
|
||||||
version: 2.2.12(typescript@5.7.3)
|
version: 2.2.12(typescript@5.7.3)
|
||||||
@@ -218,6 +236,24 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.5':
|
||||||
|
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.6':
|
||||||
|
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.11':
|
||||||
|
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.11':
|
||||||
|
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
|
||||||
|
|
||||||
|
'@internationalized/date@3.12.2':
|
||||||
|
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
|
||||||
|
|
||||||
|
'@internationalized/number@3.6.7':
|
||||||
|
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -364,6 +400,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.0':
|
'@tailwindcss/node@4.3.0':
|
||||||
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
|
||||||
|
|
||||||
@@ -454,12 +493,29 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0':
|
||||||
|
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.28':
|
||||||
|
resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^2.7.0 || ^3.0.0
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2':
|
||||||
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
'@types/node@22.19.20':
|
'@types/node@22.19.20':
|
||||||
resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
|
resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.20':
|
||||||
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.2.4':
|
'@vitejs/plugin-vue@5.2.4':
|
||||||
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
@@ -467,6 +523,35 @@ packages:
|
|||||||
vite: ^5.0.0 || ^6.0.0
|
vite: ^5.0.0 || ^6.0.0
|
||||||
vue: ^3.2.25
|
vue: ^3.2.25
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.6':
|
||||||
|
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.6':
|
||||||
|
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
|
||||||
|
peerDependencies:
|
||||||
|
msw: ^2.4.9
|
||||||
|
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
msw:
|
||||||
|
optional: true
|
||||||
|
vite:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.6':
|
||||||
|
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.6':
|
||||||
|
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.6':
|
||||||
|
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.6':
|
||||||
|
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.6':
|
||||||
|
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||||
|
|
||||||
'@volar/language-core@2.4.15':
|
'@volar/language-core@2.4.15':
|
||||||
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
|
||||||
|
|
||||||
@@ -528,9 +613,26 @@ packages:
|
|||||||
'@vue/shared@3.5.35':
|
'@vue/shared@3.5.35':
|
||||||
resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
|
resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
|
||||||
|
|
||||||
|
'@vueuse/core@10.11.1':
|
||||||
|
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||||
|
|
||||||
|
'@vueuse/metadata@10.11.1':
|
||||||
|
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||||
|
|
||||||
|
'@vueuse/shared@10.11.1':
|
||||||
|
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||||
|
|
||||||
alien-signals@1.0.13:
|
alien-signals@1.0.13:
|
||||||
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
|
||||||
|
|
||||||
|
aria-hidden@1.2.6:
|
||||||
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
assertion-error@2.0.1:
|
||||||
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
@@ -540,6 +642,25 @@ packages:
|
|||||||
brace-expansion@2.1.1:
|
brace-expansion@2.1.1:
|
||||||
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
|
||||||
|
|
||||||
|
cac@6.7.14:
|
||||||
|
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
check-error@2.1.3:
|
||||||
|
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||||
|
engines: {node: '>= 16'}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||||
|
|
||||||
|
clsx@2.1.1:
|
||||||
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -550,6 +671,22 @@ packages:
|
|||||||
de-indent@1.0.2:
|
de-indent@1.0.2:
|
||||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
deep-eql@5.0.2:
|
||||||
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
defu@6.1.7:
|
||||||
|
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -562,6 +699,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
es-module-lexer@1.7.0:
|
||||||
|
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -570,6 +710,16 @@ packages:
|
|||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
expect-type@1.3.0:
|
||||||
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3:
|
||||||
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -602,6 +752,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-tokens@9.0.1:
|
||||||
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -672,6 +825,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
||||||
|
loupe@3.2.1:
|
||||||
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
@@ -682,6 +838,9 @@ packages:
|
|||||||
mitt@3.0.1:
|
mitt@3.0.1:
|
||||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||||
|
|
||||||
|
ms@2.1.3:
|
||||||
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
muggle-string@0.4.1:
|
muggle-string@0.4.1:
|
||||||
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
|
||||||
|
|
||||||
@@ -690,9 +849,21 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.1.11:
|
||||||
|
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
||||||
|
|
||||||
|
pathe@2.0.3:
|
||||||
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
|
pathval@2.0.1:
|
||||||
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
perfect-debounce@1.0.0:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
@@ -716,6 +887,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
radix-vue@1.9.17:
|
||||||
|
resolution: {integrity: sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: '>= 3.2.0'
|
||||||
|
|
||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
@@ -724,6 +900,9 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
siginfo@2.0.0:
|
||||||
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -732,10 +911,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
stackback@0.0.2:
|
||||||
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
strip-literal@3.1.0:
|
||||||
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
superjson@2.2.6:
|
superjson@2.2.6:
|
||||||
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
|
tailwind-merge@3.6.0:
|
||||||
|
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||||
|
|
||||||
|
tailwindcss-animate@1.0.7:
|
||||||
|
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||||
|
peerDependencies:
|
||||||
|
tailwindcss: '>=3.0.0 || insiders'
|
||||||
|
|
||||||
tailwindcss@4.3.0:
|
tailwindcss@4.3.0:
|
||||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||||
|
|
||||||
@@ -743,10 +939,31 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tinybench@2.9.0:
|
||||||
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
|
tinyexec@0.3.2:
|
||||||
|
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
tinypool@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||||
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0:
|
||||||
|
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tinyspy@4.0.4:
|
||||||
|
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
typescript@5.7.3:
|
typescript@5.7.3:
|
||||||
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
|
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
@@ -755,6 +972,11 @@ packages:
|
|||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
vite-node@3.2.4:
|
||||||
|
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vite@6.4.3:
|
vite@6.4.3:
|
||||||
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
|
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
|
||||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
@@ -795,9 +1017,48 @@ packages:
|
|||||||
yaml:
|
yaml:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vitest@3.2.6:
|
||||||
|
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
|
||||||
|
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@edge-runtime/vm': '*'
|
||||||
|
'@types/debug': ^4.1.12
|
||||||
|
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
|
||||||
|
'@vitest/browser': 3.2.6
|
||||||
|
'@vitest/ui': 3.2.6
|
||||||
|
happy-dom: '*'
|
||||||
|
jsdom: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@edge-runtime/vm':
|
||||||
|
optional: true
|
||||||
|
'@types/debug':
|
||||||
|
optional: true
|
||||||
|
'@types/node':
|
||||||
|
optional: true
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
'@vitest/ui':
|
||||||
|
optional: true
|
||||||
|
happy-dom:
|
||||||
|
optional: true
|
||||||
|
jsdom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue-router@4.6.4:
|
vue-router@4.6.4:
|
||||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -817,6 +1078,11 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/helper-string-parser@7.29.7': {}
|
'@babel/helper-string-parser@7.29.7': {}
|
||||||
@@ -910,6 +1176,34 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.12':
|
'@esbuild/win32-x64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@floating-ui/core@1.7.5':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.6':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.5
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
|
||||||
|
'@floating-ui/utils@0.2.11': {}
|
||||||
|
|
||||||
|
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.7.3))':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/utils': 0.2.11
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
|
'@internationalized/date@3.12.2':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
|
'@internationalized/number@3.6.7':
|
||||||
|
dependencies:
|
||||||
|
'@swc/helpers': 0.5.23
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1008,6 +1302,10 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.61.1':
|
'@rollup/rollup-win32-x64-msvc@4.61.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@swc/helpers@0.5.23':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@tailwindcss/node@4.3.0':
|
'@tailwindcss/node@4.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
@@ -1076,17 +1374,75 @@ snapshots:
|
|||||||
tailwindcss: 4.3.0
|
tailwindcss: 4.3.0
|
||||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
|
'@tanstack/virtual-core@3.17.0': {}
|
||||||
|
|
||||||
|
'@tanstack/vue-virtual@3.13.28(vue@3.5.35(typescript@5.7.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/virtual-core': 3.17.0
|
||||||
|
vue: 3.5.35(typescript@5.7.3)
|
||||||
|
|
||||||
|
'@types/chai@5.2.3':
|
||||||
|
dependencies:
|
||||||
|
'@types/deep-eql': 4.0.2
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
|
||||||
|
'@types/deep-eql@4.0.2': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
'@types/node@22.19.20':
|
'@types/node@22.19.20':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))':
|
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
vue: 3.5.35(typescript@5.7.3)
|
vue: 3.5.35(typescript@5.7.3)
|
||||||
|
|
||||||
|
'@vitest/expect@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/spy': 3.2.6
|
||||||
|
'@vitest/utils': 3.2.6
|
||||||
|
chai: 5.3.3
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 3.2.6
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
|
||||||
|
'@vitest/pretty-format@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
'@vitest/runner@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/utils': 3.2.6
|
||||||
|
pathe: 2.0.3
|
||||||
|
strip-literal: 3.1.0
|
||||||
|
|
||||||
|
'@vitest/snapshot@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.6
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
|
||||||
|
'@vitest/spy@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
tinyspy: 4.0.4
|
||||||
|
|
||||||
|
'@vitest/utils@3.2.6':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/pretty-format': 3.2.6
|
||||||
|
loupe: 3.2.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@volar/language-core@2.4.15':
|
'@volar/language-core@2.4.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@volar/source-map': 2.4.15
|
'@volar/source-map': 2.4.15
|
||||||
@@ -1191,8 +1547,33 @@ snapshots:
|
|||||||
|
|
||||||
'@vue/shared@3.5.35': {}
|
'@vue/shared@3.5.35': {}
|
||||||
|
|
||||||
|
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.7.3))':
|
||||||
|
dependencies:
|
||||||
|
'@types/web-bluetooth': 0.0.20
|
||||||
|
'@vueuse/metadata': 10.11.1
|
||||||
|
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
|
'@vueuse/metadata@10.11.1': {}
|
||||||
|
|
||||||
|
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.7.3))':
|
||||||
|
dependencies:
|
||||||
|
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
- vue
|
||||||
|
|
||||||
alien-signals@1.0.13: {}
|
alien-signals@1.0.13: {}
|
||||||
|
|
||||||
|
aria-hidden@1.2.6:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
birpc@2.9.0: {}
|
birpc@2.9.0: {}
|
||||||
@@ -1201,6 +1582,24 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
|
cac@6.7.14: {}
|
||||||
|
|
||||||
|
chai@5.3.3:
|
||||||
|
dependencies:
|
||||||
|
assertion-error: 2.0.1
|
||||||
|
check-error: 2.1.3
|
||||||
|
deep-eql: 5.0.2
|
||||||
|
loupe: 3.2.1
|
||||||
|
pathval: 2.0.1
|
||||||
|
|
||||||
|
check-error@2.1.3: {}
|
||||||
|
|
||||||
|
class-variance-authority@0.7.1:
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
|
||||||
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
copy-anything@4.0.5:
|
copy-anything@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-what: 5.5.0
|
is-what: 5.5.0
|
||||||
@@ -1209,6 +1608,14 @@ snapshots:
|
|||||||
|
|
||||||
de-indent@1.0.2: {}
|
de-indent@1.0.2: {}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
|
defu@6.1.7: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.23.0:
|
enhanced-resolve@5.23.0:
|
||||||
@@ -1218,6 +1625,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@7.0.1: {}
|
entities@7.0.1: {}
|
||||||
|
|
||||||
|
es-module-lexer@1.7.0: {}
|
||||||
|
|
||||||
esbuild@0.25.12:
|
esbuild@0.25.12:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.12
|
'@esbuild/aix-ppc64': 0.25.12
|
||||||
@@ -1249,6 +1658,14 @@ snapshots:
|
|||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
estree-walker@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.4):
|
fdir@6.5.0(picomatch@4.0.4):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
@@ -1266,6 +1683,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1315,6 +1734,8 @@ snapshots:
|
|||||||
lightningcss-win32-arm64-msvc: 1.32.0
|
lightningcss-win32-arm64-msvc: 1.32.0
|
||||||
lightningcss-win32-x64-msvc: 1.32.0
|
lightningcss-win32-x64-msvc: 1.32.0
|
||||||
|
|
||||||
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1325,12 +1746,20 @@ snapshots:
|
|||||||
|
|
||||||
mitt@3.0.1: {}
|
mitt@3.0.1: {}
|
||||||
|
|
||||||
|
ms@2.1.3: {}
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
nanoid@5.1.11: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -1350,6 +1779,23 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
radix-vue@1.9.17(vue@3.5.35(typescript@5.7.3)):
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.6
|
||||||
|
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.7.3))
|
||||||
|
'@internationalized/date': 3.12.2
|
||||||
|
'@internationalized/number': 3.6.7
|
||||||
|
'@tanstack/vue-virtual': 3.13.28(vue@3.5.35(typescript@5.7.3))
|
||||||
|
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||||
|
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
defu: 6.1.7
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
nanoid: 5.1.11
|
||||||
|
vue: 3.5.35(typescript@5.7.3)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@vue/composition-api'
|
||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
rollup@4.61.1:
|
rollup@4.61.1:
|
||||||
@@ -1383,27 +1829,76 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.61.1
|
'@rollup/rollup-win32-x64-msvc': 4.61.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
speakingurl@14.0.1: {}
|
speakingurl@14.0.1: {}
|
||||||
|
|
||||||
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
|
strip-literal@3.1.0:
|
||||||
|
dependencies:
|
||||||
|
js-tokens: 9.0.1
|
||||||
|
|
||||||
superjson@2.2.6:
|
superjson@2.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything: 4.0.5
|
copy-anything: 4.0.5
|
||||||
|
|
||||||
|
tailwind-merge@3.6.0: {}
|
||||||
|
|
||||||
|
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
|
||||||
|
dependencies:
|
||||||
|
tailwindcss: 4.3.0
|
||||||
|
|
||||||
tailwindcss@4.3.0: {}
|
tailwindcss@4.3.0: {}
|
||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
||||||
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
|
tinyexec@0.3.2: {}
|
||||||
|
|
||||||
tinyglobby@0.2.17:
|
tinyglobby@0.2.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
fdir: 6.5.0(picomatch@4.0.4)
|
fdir: 6.5.0(picomatch@4.0.4)
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
tinypool@1.1.1: {}
|
||||||
|
|
||||||
|
tinyrainbow@2.0.0: {}
|
||||||
|
|
||||||
|
tinyspy@4.0.4: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
typescript@5.7.3: {}
|
typescript@5.7.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 2.0.3
|
||||||
|
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
@@ -1418,8 +1913,53 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
|
|
||||||
|
vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
|
||||||
|
dependencies:
|
||||||
|
'@types/chai': 5.2.3
|
||||||
|
'@vitest/expect': 3.2.6
|
||||||
|
'@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
|
||||||
|
'@vitest/pretty-format': 3.2.6
|
||||||
|
'@vitest/runner': 3.2.6
|
||||||
|
'@vitest/snapshot': 3.2.6
|
||||||
|
'@vitest/spy': 3.2.6
|
||||||
|
'@vitest/utils': 3.2.6
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 2.0.3
|
||||||
|
picomatch: 4.0.4
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinyglobby: 0.2.17
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.19.20
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- jiti
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
- tsx
|
||||||
|
- yaml
|
||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.35(typescript@5.7.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.35(typescript@5.7.3)
|
||||||
|
|
||||||
vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)):
|
vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vue/devtools-api': 6.6.4
|
'@vue/devtools-api': 6.6.4
|
||||||
@@ -1440,3 +1980,8 @@ snapshots:
|
|||||||
'@vue/shared': 3.5.35
|
'@vue/shared': 3.5.35
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.7.3
|
typescript: 5.7.3
|
||||||
|
|
||||||
|
why-is-node-running@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
siginfo: 2.0.0
|
||||||
|
stackback: 0.0.2
|
||||||
|
|||||||
+10
-30
@@ -7,6 +7,7 @@ import { useAuthStore } from './stores/auth'
|
|||||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||||
import AppHeader from './components/layout/AppHeader.vue'
|
import AppHeader from './components/layout/AppHeader.vue'
|
||||||
import ModuleView from './components/ModuleView.vue'
|
import ModuleView from './components/ModuleView.vue'
|
||||||
|
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||||
|
|
||||||
const store = useOperationsStore()
|
const store = useOperationsStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -22,7 +23,7 @@ const activeView = computed(() => {
|
|||||||
const routePaths: Record<string, string> = {
|
const routePaths: Record<string, string> = {
|
||||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
||||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
||||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings',
|
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigate = (label: string) => {
|
const navigate = (label: string) => {
|
||||||
@@ -31,7 +32,7 @@ const navigate = (label: string) => {
|
|||||||
}
|
}
|
||||||
const mobileNavOpen = ref(false)
|
const mobileNavOpen = ref(false)
|
||||||
|
|
||||||
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
|
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isAuthenticated) store.refresh()
|
if (auth.isAuthenticated) store.refresh()
|
||||||
@@ -39,7 +40,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView v-if="route.name === 'Login'" />
|
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
||||||
<div v-else class="shell">
|
<div v-else class="shell">
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
@@ -82,32 +83,11 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:root {
|
|
||||||
--bg: #0b0d13;
|
|
||||||
--panel: #11141b;
|
|
||||||
--line: #1f2330;
|
|
||||||
--accent: #7b6ef2;
|
|
||||||
--accent-soft: rgba(123,110,242,.08);
|
|
||||||
--text: #e8eaf0;
|
|
||||||
--text-dim: #6f7889;
|
|
||||||
--green: #27ae60;
|
|
||||||
--red: #e74c3c;
|
|
||||||
--yellow: #f1c40f;
|
|
||||||
--orange: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
html { font-size: 15px; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
@@ -135,12 +115,12 @@ main {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.page-heading h1 { margin: 0; font-size: 18px; }
|
.page-heading h1 { margin: 0; font-size: 18px; }
|
||||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); }
|
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
font-size: 8.5px;
|
font-size: 8.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .12em;
|
letter-spacing: .12em;
|
||||||
color: var(--accent);
|
color: var(--nx-accent);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.refresh {
|
.refresh {
|
||||||
@@ -149,15 +129,15 @@ main {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 6px 11px;
|
padding: 6px 11px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-dim);
|
color: var(--nx-text-dim);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
}
|
}
|
||||||
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; }
|
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
|
||||||
|
|
||||||
.spin { animation: spin 1s linear infinite; }
|
.spin { animation: spin 1s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── Nexus V2 Theme (Tailwind v4 @theme directive) ── */
|
||||||
|
@theme {
|
||||||
|
/* Font families */
|
||||||
|
--font-sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-display: 'Space Grotesk', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Space surfaces */
|
||||||
|
--color-space-0: #050410;
|
||||||
|
--color-space-1: #0a0818;
|
||||||
|
--color-space-2: #0e0c20;
|
||||||
|
--color-space-3: #141130;
|
||||||
|
--color-space-4: #1b1742;
|
||||||
|
|
||||||
|
/* Glass */
|
||||||
|
--color-glass: rgba(20, 17, 48, 0.55);
|
||||||
|
--color-glass-2: rgba(28, 24, 64, 0.55);
|
||||||
|
|
||||||
|
/* Lines */
|
||||||
|
--color-line: rgba(150, 140, 255, 0.10);
|
||||||
|
--color-line-2: rgba(150, 140, 255, 0.18);
|
||||||
|
--color-line-3: rgba(150, 140, 255, 0.30);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-tx: #ece9ff;
|
||||||
|
--color-tx-2: #a8a3d6;
|
||||||
|
--color-tx-3: #6f6aa0;
|
||||||
|
|
||||||
|
/* Accent */
|
||||||
|
--color-a-blue: #4f7cff;
|
||||||
|
--color-a-purple: #b557f6;
|
||||||
|
--color-a-mid: #7c6cff;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--color-st-work: #3ddc97;
|
||||||
|
--color-st-think: #34d6f5;
|
||||||
|
--color-st-queue: #fbbf24;
|
||||||
|
--color-st-block: #fb7185;
|
||||||
|
--color-st-idle: #6b6796;
|
||||||
|
|
||||||
|
/* Border radius */
|
||||||
|
--radius-r: 14px;
|
||||||
|
--radius-r-sm: 10px;
|
||||||
|
--radius-r-lg: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 252 80% 74%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 252 80% 74%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
|
'Segoe UI', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nexus overrides for existing CSS variables used in dashboard */
|
||||||
|
:root {
|
||||||
|
--nx-bg: #080a0f;
|
||||||
|
--nx-panel: #10131a;
|
||||||
|
--nx-panel-soft: #0d1016;
|
||||||
|
--nx-line: #202530;
|
||||||
|
--nx-muted: #7e8799;
|
||||||
|
--nx-accent: #8b7cf6;
|
||||||
|
--nx-accent-soft: rgba(139, 124, 246, 0.12);
|
||||||
|
--nx-green: #51d49a;
|
||||||
|
--nx-text: #e8eaf0;
|
||||||
|
--nx-text-dim: #6f7889;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Existing Nexus layout styles ── */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 224px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 22px 14px 14px;
|
||||||
|
border-right: 1px solid #1a1e27;
|
||||||
|
background: rgba(9, 11, 16, 0.94);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 0 8px 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #443d7c;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(145deg, #241f44, #12121f);
|
||||||
|
color: #b8adff;
|
||||||
|
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand span,
|
||||||
|
.owner span {
|
||||||
|
display: block;
|
||||||
|
color: var(--nx-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav button,
|
||||||
|
.sidebar-bottom > button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #8991a1;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav button:hover,
|
||||||
|
.nav button.active {
|
||||||
|
color: #ececf5;
|
||||||
|
background: var(--nx-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav button.active {
|
||||||
|
box-shadow: inset 2px 0 var(--nx-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav button i {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid #343947;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid #1b1f28;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 31px 1fr auto;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner:hover {
|
||||||
|
background: var(--nx-accent-soft);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owner strong {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 31px;
|
||||||
|
height: 31px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #28243f;
|
||||||
|
color: #bcb3ff;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 30px;
|
||||||
|
border-bottom: 1px solid #191d25;
|
||||||
|
background: rgba(8, 10, 15, 0.68);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: min(390px, 42vw);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #202530;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #6f7889;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid #2c313d;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #606979;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #8c95a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection.live {
|
||||||
|
color: var(--nx-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection.preview {
|
||||||
|
color: #e6b75d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask,
|
||||||
|
.refresh-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1px solid #37315e;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #18152a;
|
||||||
|
color: #c4bbff;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px 16px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.kicker {
|
||||||
|
color: #7065c8;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 7px 0 5px;
|
||||||
|
font-size: 27px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading p,
|
||||||
|
.placeholder p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--nx-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
border-color: var(--nx-line);
|
||||||
|
background: var(--nx-panel);
|
||||||
|
color: #a5adba;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
display: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #aaa4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
||||||
|
.metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics article,
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--nx-line);
|
||||||
|
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics article {
|
||||||
|
padding: 16px 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics span {
|
||||||
|
color: #717a8a;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics strong {
|
||||||
|
display: block;
|
||||||
|
margin: 7px 0 5px;
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics small {
|
||||||
|
color: #687181;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics small.up {
|
||||||
|
color: #55c995;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 18px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2 {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #1d222c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h2 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #8e96a5;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.positive {
|
||||||
|
color: var(--nx-green);
|
||||||
|
background: rgba(81, 212, 154, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.warning {
|
||||||
|
color: #e7b660;
|
||||||
|
background: rgba(231, 182, 96, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.negative {
|
||||||
|
color: #e16e75;
|
||||||
|
background: rgba(225, 110, 117, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-icon {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: #ad9fff;
|
||||||
|
background: var(--nx-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-main strong,
|
||||||
|
.model strong,
|
||||||
|
.project strong,
|
||||||
|
.event strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-main span,
|
||||||
|
.model small,
|
||||||
|
.event small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--nx-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-bars {
|
||||||
|
height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-bars i {
|
||||||
|
width: 3px;
|
||||||
|
min-height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: linear-gradient(#927fff, #443b7c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 12px 2px;
|
||||||
|
border-bottom: 1px solid #1b2029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model > span:last-child {
|
||||||
|
color: #687181;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #657083;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: var(--nx-green);
|
||||||
|
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.offline {
|
||||||
|
background: #e16e75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #1b2029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-letter {
|
||||||
|
width: 31px;
|
||||||
|
height: 31px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #353047;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #a99cf5;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info > div:first-child {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info span {
|
||||||
|
color: var(--nx-muted);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project b {
|
||||||
|
color: #838c9c;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #242936;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #685ac8, #a091ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #1b2029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event > span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #657083;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event > span.runtime {
|
||||||
|
background: var(--nx-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event > span.deploy {
|
||||||
|
background: #8b7cf6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event > span.security {
|
||||||
|
background: #e5ad52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
min-height: 420px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder svg {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #8074d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder h2 {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
left: -240px;
|
||||||
|
width: 224px;
|
||||||
|
transition: left 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
box-shadow: 20px 0 60px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.span-2 {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.content {
|
||||||
|
padding: 26px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading p {
|
||||||
|
max-width: 220px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-bars {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 57px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user