Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df5194651 | |||
| 39aeab62d6 | |||
| 8b1400da17 | |||
| dd17cefa3b | |||
| 2aa41e6366 | |||
| 696e9daf4d | |||
| 701e15ee9b | |||
| fe97d1aaf0 | |||
| df94ed3cd4 | |||
| de1fc198cb | |||
| f33e8c8b58 | |||
| dd38570c7b | |||
| 5dc00c1142 | |||
| cf10ca3ed8 | |||
| 1f8b45a327 | |||
| b1dcf51218 | |||
| 0fdc40a95c | |||
| 071cbe8ce5 | |||
| c750a5abcd | |||
| dd81070afd | |||
| be5c3884a0 | |||
| 7a2d5ded20 | |||
| 784cb4eeee | |||
| 6b2ab04f8d | |||
| ca4bad2ba7 | |||
| ac131f7f53 | |||
| 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 |
@@ -33,7 +33,7 @@ on:
|
||||
host_backup_path:
|
||||
description: 'Host path for backup (only if keep_on_host is true)'
|
||||
required: false
|
||||
default: '/opt/openclaw/backups'
|
||||
default: '/home/projekte_bao/openclaw/backups'
|
||||
type: string
|
||||
|
||||
# Optional: uncomment to enable nightly automatic backups
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
env:
|
||||
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||
|
||||
steps:
|
||||
|
||||
@@ -27,10 +27,10 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore backend/Nexus.Api.csproj
|
||||
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||
|
||||
- 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
|
||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
name: Deploy Now
|
||||
run-name: 🚀 Deploy Now by @${{ gitea.actor }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Nexus
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
ENV_TMPFILE: /tmp/nexus-deploy-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Resolve Version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "ERROR: VERSION file not found"
|
||||
exit 1
|
||||
fi
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "ERROR: Invalid semver in VERSION: $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
GIT_REF=$(git rev-parse --short HEAD)
|
||||
echo "Deploy version: v${VERSION} git:${GIT_REF}"
|
||||
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||
|
||||
- name: Prepare .env
|
||||
run: |
|
||||
set -euo pipefail
|
||||
HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true)
|
||||
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
|
||||
echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
|
||||
exit 1
|
||||
fi
|
||||
printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}"
|
||||
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
|
||||
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
|
||||
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
|
||||
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
|
||||
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
|
||||
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
|
||||
printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}"
|
||||
printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}"
|
||||
printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}"
|
||||
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
|
||||
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}"
|
||||
chmod 600 "${ENV_TMPFILE}"
|
||||
echo "OK .env written to ${ENV_TMPFILE}"
|
||||
|
||||
- name: Sync code to host
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
-v "${DEPLOY_PATH}:/dest" \
|
||||
alpine:latest \
|
||||
sh -c "cd /src && find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -r {} /dest/ \; && DEST_OWNER=\$(stat -c '%u:%g' /dest) && chown -R \"\$DEST_OWNER\" /dest"
|
||||
echo "OK synced to ${DEPLOY_PATH}"
|
||||
|
||||
- name: Build and Deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SCRIPT=/tmp/nexus-deploy-script.sh
|
||||
printf '#!/bin/sh\n' > "$SCRIPT"
|
||||
printf 'set -e\n' >> "$SCRIPT"
|
||||
printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
|
||||
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
|
||||
printf '\n' >> "$SCRIPT"
|
||||
printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT"
|
||||
printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT"
|
||||
printf '\n' >> "$SCRIPT"
|
||||
printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT"
|
||||
printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT"
|
||||
printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT"
|
||||
printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT"
|
||||
printf 'else\n' >> "$SCRIPT"
|
||||
printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT"
|
||||
printf 'fi\n' >> "$SCRIPT"
|
||||
printf '\n' >> "$SCRIPT"
|
||||
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
|
||||
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
|
||||
printf 'docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate\n' >> "$SCRIPT"
|
||||
printf '\n' >> "$SCRIPT"
|
||||
printf 'echo "Waiting for services to become healthy (up to 180s)..."\n' >> "$SCRIPT"
|
||||
printf 'for i in $(seq 1 36); do\n' >> "$SCRIPT"
|
||||
printf ' STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)\n' >> "$SCRIPT"
|
||||
printf ' if echo "$STATUS" | grep -q unhealthy; then\n' >> "$SCRIPT"
|
||||
printf ' echo " [$i/36] Unhealthy containers - failing fast"\n' >> "$SCRIPT"
|
||||
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
||||
printf ' docker compose --env-file /tmp/nexus-deploy-env logs --tail=30\n' >> "$SCRIPT"
|
||||
printf ' exit 1\n' >> "$SCRIPT"
|
||||
printf ' elif echo "$STATUS" | grep -q starting; then\n' >> "$SCRIPT"
|
||||
printf ' echo " [$i/36] Still starting..."\n' >> "$SCRIPT"
|
||||
printf ' sleep 5\n' >> "$SCRIPT"
|
||||
printf ' else\n' >> "$SCRIPT"
|
||||
printf ' echo "All containers healthy"\n' >> "$SCRIPT"
|
||||
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
||||
printf ' exit 0\n' >> "$SCRIPT"
|
||||
printf ' fi\n' >> "$SCRIPT"
|
||||
printf 'done\n' >> "$SCRIPT"
|
||||
printf 'echo "Timeout waiting for services"\n' >> "$SCRIPT"
|
||||
printf 'docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
|
||||
printf 'docker compose --env-file /tmp/nexus-deploy-env logs --tail=20\n' >> "$SCRIPT"
|
||||
printf 'exit 1\n' >> "$SCRIPT"
|
||||
chmod +x "$SCRIPT"
|
||||
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "${SCRIPT}:/deploy.sh:ro" \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh /deploy.sh < "${ENV_TMPFILE}"
|
||||
|
||||
rm -f "$SCRIPT"
|
||||
echo "OK deployed"
|
||||
|
||||
- name: Clean up temp .env
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "${ENV_TMPFILE}" ]; then
|
||||
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||
echo "OK cleaned"
|
||||
fi
|
||||
|
||||
- name: Health Check
|
||||
run: |
|
||||
echo "Health check..."
|
||||
RETRY=0; MAX=6; WAIT=1
|
||||
while [ $RETRY -lt $MAX ]; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||
echo ""
|
||||
echo "OK Health check passed (attempt $RETRY/$MAX)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
NEXT=$((WAIT + RETRY))
|
||||
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||
done
|
||||
echo "ERROR Health check failed after $MAX attempts"
|
||||
exit 1
|
||||
|
||||
- name: Smoke Test
|
||||
run: |
|
||||
PASS=0; FAIL=0; BASE="https://nexus.noveria.net"
|
||||
check() {
|
||||
local path="$1" label="$2" expected="${3:-200}"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
echo " OK"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL (expected $expected)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
check "/dashboard" "Dashboard" 200
|
||||
check "/health" "Health API" 200
|
||||
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "ERROR Smoke test failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "OK Smoke test passed"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "========================================"
|
||||
echo " Deploy Summary"
|
||||
echo "========================================"
|
||||
echo " Version: v${{ steps.version.outputs.version }}"
|
||||
echo " Git ref: main"
|
||||
echo " Service: all"
|
||||
echo " Trigger: Manual"
|
||||
echo " Status: ${{ job.status }}"
|
||||
echo "========================================"
|
||||
+113
-100
@@ -1,5 +1,5 @@
|
||||
name: Deploy to Production
|
||||
run-name: 🚀 Deploy by @${{ gitea.actor }}
|
||||
name: Deploy Nexus v2
|
||||
run-name: 🚀 Deploy v2 by @${{ gitea.actor }}
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Owner: DevOps (Architekt)
|
||||
@@ -15,12 +15,11 @@ run-name: 🚀 Deploy by @${{ gitea.actor }}
|
||||
# Concurrency: one deploy at a time.
|
||||
# Queued deploys wait — no race conditions with parallel builds.
|
||||
#
|
||||
# Version-Bump / CI Loop Prevention:
|
||||
# The version-bump commit includes "[skip ci]" in its message,
|
||||
# which Gitea Actions respects. The auto-trigger additionally
|
||||
# checks for "[skip ci]" as a second safety layer. Together
|
||||
# they guarantee that a version-bump commit does NOT trigger
|
||||
# another CI → Deploy → Bump → CI cycle.
|
||||
# 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:
|
||||
group: deploy-production
|
||||
@@ -35,31 +34,6 @@ on:
|
||||
|
||||
# ── Manual Trigger (full control) ──
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version_bump:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
service:
|
||||
description: 'Service to deploy (empty = all)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
no_cache:
|
||||
description: 'Disable Docker build cache'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
git_ref:
|
||||
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
|
||||
required: false
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -73,12 +47,14 @@ jobs:
|
||||
|
||||
# ── Env for the deploy target path ──
|
||||
env:
|
||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||
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_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||
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:
|
||||
# ═══════════════════════════════════════════════════
|
||||
@@ -87,7 +63,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
@@ -102,76 +78,72 @@ jobs:
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 3: Resolve deploy version
|
||||
#
|
||||
# Deploying main: DevOps may bump VERSION and create a tag.
|
||||
# Deploying any other ref: deploy exactly that ref, but DO NOT
|
||||
# mutate main or create a version-bump commit on another branch.
|
||||
#
|
||||
# For auto-deploys (workflow_run): always "patch" bump on main.
|
||||
# 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: |
|
||||
set -euo pipefail
|
||||
|
||||
# Determine bump type (auto-deploy → patch; manual → user choice)
|
||||
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
|
||||
|
||||
# Read current version
|
||||
# 1. Check VERSION exists
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "❌ VERSION file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT=$(cat VERSION | tr -d '[:space:]')
|
||||
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "❌ Invalid semver in VERSION: '$CURRENT'"
|
||||
# 2. Read and validate semver format
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "❌ Invalid semver in VERSION: '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
# 3. Log version, git ref, and describe
|
||||
GIT_REF=$(git rev-parse --short HEAD)
|
||||
GIT_DESCRIBE=$(git describe --always --dirty)
|
||||
|
||||
case "$BUMP_TYPE" in
|
||||
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
|
||||
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
|
||||
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
|
||||
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
|
||||
esac
|
||||
echo "📦 Deploy version: v${VERSION}"
|
||||
echo "🔖 Git ref: ${GIT_REF}"
|
||||
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
|
||||
|
||||
# Determine git ref — auto-deploy always uses main
|
||||
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
|
||||
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
||||
echo "$NEW_VERSION" > VERSION
|
||||
git add VERSION
|
||||
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
|
||||
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||
git push origin HEAD:main --tags
|
||||
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
|
||||
echo "mutated_main=true" >> "$GITEA_OUTPUT"
|
||||
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
|
||||
else
|
||||
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
|
||||
# 4. Set outputs for downstream steps
|
||||
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 4: Build .env from secrets (SAFE)
|
||||
# Step 4: Build .env from secrets + host .env (SAFE)
|
||||
#
|
||||
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||
# to a file inside the workspace that gets rsync'd to
|
||||
# the host. The temp file is deleted immediately after
|
||||
# compose operations complete.
|
||||
#
|
||||
# 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 → 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"
|
||||
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
|
||||
# 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_USER=nexus
|
||||
@@ -180,7 +152,7 @@ jobs:
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
||||
OWNER_DISPLAY_NAME=
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||
@@ -223,33 +195,77 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Auto-deploy: always use cache. Manual: respect no_cache input.
|
||||
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
||||
BUILD_ARGS=""
|
||||
if [ "$NO_CACHE" = "true" ]; then
|
||||
BUILD_ARGS="--no-cache"
|
||||
SERVICE_ARG=""
|
||||
|
||||
# Write the deploy script to a file to avoid nested quoting issues
|
||||
cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
|
||||
#!/bin/sh
|
||||
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 "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
|
||||
|
||||
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
|
||||
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 "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
||||
-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 -c "
|
||||
set -e
|
||||
if [ -n '${SERVICE_ARG}' ]; then
|
||||
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
||||
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
|
||||
else
|
||||
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 --wait --force-recreate
|
||||
fi
|
||||
"
|
||||
sh /deploy.sh < "${ENV_TMPFILE}"
|
||||
|
||||
rm -f /tmp/nexus-deploy-script.sh
|
||||
|
||||
echo "✅ Docker compose up completed"
|
||||
|
||||
@@ -332,17 +348,14 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " 📦 Deploy Summary"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " Version: v${{ steps.version.outputs.version }}"
|
||||
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
|
||||
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
||||
echo " Git ref: main"
|
||||
echo " Service: all"
|
||||
echo " Trigger: ${TRIGGER}"
|
||||
echo " Bump type: ${VERSION_BUMP}"
|
||||
echo " Actor: @${{ gitea.actor }}"
|
||||
echo " Status: ${{ job.status }}"
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
@@ -39,11 +39,10 @@ jobs:
|
||||
name: Rollback Nexus
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||
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_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
|
||||
steps:
|
||||
@@ -95,12 +94,22 @@ jobs:
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 3: Prepare .env from secrets (safe temp file)
|
||||
# Step 3: Prepare .env from secrets + host .env (safe temp file)
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Prepare .env (secrets → 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
|
||||
@@ -110,7 +119,7 @@ jobs:
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
|
||||
OWNER_DISPLAY_NAME=
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||
@@ -151,15 +160,15 @@ jobs:
|
||||
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
||||
-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/nexus-deploy-env build --no-cache
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
||||
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"
|
||||
@@ -271,7 +280,7 @@ jobs:
|
||||
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||
echo "│ → Manuellen Rollback erwägen: │"
|
||||
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
|
||||
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │"
|
||||
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||
echo "│ │"
|
||||
echo "└─────────────────────────────────────────────────────────────┘"
|
||||
|
||||
+7
-1
@@ -30,7 +30,13 @@ docker-compose.override.yml
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
||||
# Crash artefacts / Core dumps
|
||||
**/core
|
||||
**/core.*
|
||||
|
||||
# pnpm / corepack local caches (lockfile IS committed for reproducible CI builds)
|
||||
frontend/.pnpm-home/
|
||||
frontend/.corepack-home/
|
||||
|
||||
# Claude local config (per-developer, not repo-shared)
|
||||
.claude/
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
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.
|
||||
|
||||
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
|
||||
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
||||
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||
|
||||
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||
> 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
|
||||
@@ -15,10 +19,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||
- ASP.NET Core 10 REST API (Minimal API pattern)
|
||||
- Entity Framework Core and PostgreSQL
|
||||
- JWT owner authentication with rotating refresh sessions
|
||||
- `IAgentRuntime` abstraction with an OpenClaw adapter
|
||||
- `IModelProvider` abstractions for Ollama and NVIDIA
|
||||
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
|
||||
- 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
|
||||
|
||||
@@ -31,12 +34,11 @@ curl http://127.0.0.1:18880/health
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The web service is loopback-only. Public reverse-proxy activation for
|
||||
`nexus.noveria.net` remains a separate infrastructure change and must terminate
|
||||
TLS before forwarding to port `18880`.
|
||||
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||
Health checks, rate limiting, and security headers are active.
|
||||
|
||||
## Workspace mounts
|
||||
|
||||
@@ -45,12 +47,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
|
||||
|
||||
| Host path | Container mount |
|
||||
|---|---|
|
||||
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
@@ -165,13 +167,45 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
| Route | Name | Description |
|
||||
|---|---|---|
|
||||
| `/projects` | Projects | Project portfolio |
|
||||
| `/tasks` | Task Board | Task board |
|
||||
| `/tasks` | Task Board | Task board with visible parent/child agent flow |
|
||||
| `/models` | Models | Provider routing status |
|
||||
| `/activity` | Activity | Audit timeline |
|
||||
| `/chat` | Mobile Chat | Owner-chat preview |
|
||||
|
||||
## API endpoints
|
||||
|
||||
### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
|
||||
|
||||
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
|
||||
Agent-zu-Backend-Kommunikation. Kein Frontend-Code ruft diese Endpunkte auf.
|
||||
|
||||
Auth: `X-Agent-Id` Header, `X-Nexus-Api-Key`, oder JWT. Rate-Limited (30/min).
|
||||
|
||||
| Methode | Pfad | Kommando | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/bridge/health` | — | Bridge-Health-Check |
|
||||
| `POST` | `/api/bridge/tasks` | `create_task` | Neue Top-Level-Task erstellen |
|
||||
| `POST` | `/api/bridge/tasks/{id}/children` | `create_child_task` | Child-Task unter Parent erstellen |
|
||||
| `PATCH` | `/api/bridge/tasks/{id}/status` | `update_status` | Task-Status ändern |
|
||||
| `POST` | `/api/bridge/tasks/{id}/activity` | `append_activity` | Aktivitätseintrag anhängen |
|
||||
| `POST` | `/api/bridge/tasks/{id}/handoff` | `handoff` | Task an anderen Agent übergeben |
|
||||
| `GET` | `/api/bridge/board` | `get_board` | Vollständiges Task-Board |
|
||||
| `GET` | `/api/bridge/tasks/{id}` | `get_task` | Einzelne Task abrufen |
|
||||
| `GET` | `/api/bridge/tasks/{id}/children` | `get_children` | Child-Tasks abrufen |
|
||||
| `GET` | `/api/bridge/tasks/{id}/activity` | `get_activity` | Task-Aktivität abrufen |
|
||||
| `GET` | `/api/bridge/agent-overview` | `get_agent_overview` | Agent-Workflow-Übersicht |
|
||||
|
||||
Response-Format (TaskBridgeCommandResponse<T>):
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"command": "create_task",
|
||||
"data": { ... },
|
||||
"error": null,
|
||||
"timestamp": "2026-06-22T15:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Health & Auth (public or rate-limited)
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -191,6 +225,15 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) |
|
||||
|
||||
### Parent/Child task flow
|
||||
|
||||
The Task Board now models OpenClaw delegation as a visible parent/child flow:
|
||||
- Iris keeps the parent task `In progress` while delegated work is running.
|
||||
- Delegated agent work is represented as visible child tasks linked via `parentTaskId`.
|
||||
- Child tasks use the normal visible states (`Backlog`, `In progress`, `Review`, `Blocked`, `Done`) instead of a separate hidden delegation lane.
|
||||
- Agent progress hints on parent tasks derive from recent activity and child-task status summaries.
|
||||
- Full workflow documentation: [`docs/openclaw-task-board-flow.md`](docs/openclaw-task-board-flow.md)
|
||||
|
||||
### Projects
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -283,11 +326,16 @@ Backlog → Blocked → In progress / Done
|
||||
provider key. Conversation IDs are stable per browser and Iris is the default
|
||||
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
|
||||
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
|
||||
3. `gpt-5.5` through OpenClaw for strategic and critical review
|
||||
| Agent | Model |
|
||||
|-------|-------|
|
||||
| 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
|
||||
credentials.
|
||||
@@ -316,7 +364,7 @@ Deployment can happen automatically or manually:
|
||||
|
||||
#### Manual Deploy (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Deploy to Production` in Gitea Actions
|
||||
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
|
||||
@@ -332,7 +380,7 @@ Deployment can happen automatically or manually:
|
||||
#### Database Backup (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
|
||||
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`)
|
||||
|
||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
@@ -44,18 +36,60 @@ public class AgentServiceTests
|
||||
[Fact]
|
||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
||||
})
|
||||
.Build();
|
||||
var configPath = CreateAgentConfigFile();
|
||||
var config = CreateConfiguration(configPath);
|
||||
var runtime = new FakeRuntime();
|
||||
var service = new AgentService(config, runtime);
|
||||
|
||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||
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
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
|
||||
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using Nexus.Api.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public class TaskBoardTests
|
||||
{
|
||||
// ── TaskStateHelper: BoardGroupKey ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("Backlog", "offen")]
|
||||
[InlineData("In progress", "inProgress")]
|
||||
[InlineData("Review", "review")]
|
||||
[InlineData("Blocked", "blocked")]
|
||||
[InlineData("Done", "done")]
|
||||
[InlineData("backlog", "offen")]
|
||||
[InlineData("in progress", "inProgress")]
|
||||
[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("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 5 entries ──
|
||||
|
||||
[Fact]
|
||||
public void AllStates_ContainsAllFiveStates()
|
||||
{
|
||||
var states = TaskStateHelper.AllStates;
|
||||
Assert.Equal(5, states.Length);
|
||||
Assert.Contains("Backlog", states);
|
||||
Assert.Contains("In progress", states);
|
||||
Assert.Contains("Review", states);
|
||||
Assert.Contains("Blocked", states);
|
||||
Assert.Contains("Done", states);
|
||||
}
|
||||
|
||||
// ── TaskStateHelper: IsValidState ──
|
||||
|
||||
[Theory]
|
||||
[InlineData("Backlog", true)]
|
||||
[InlineData("In progress", 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("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("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("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.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;
|
||||
}
|
||||
@@ -92,9 +92,28 @@ public class AgentsController(
|
||||
if (request.Content.Length > 500 * 1024)
|
||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||
|
||||
try
|
||||
{
|
||||
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
return result is null
|
||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -14,7 +15,8 @@ public class AuthController(
|
||||
IAuthService authService,
|
||||
IAntiforgery antiforgery,
|
||||
IConfiguration config,
|
||||
IHostEnvironment env) : ControllerBase
|
||||
IHostEnvironment env,
|
||||
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||
{
|
||||
[HttpGet("csrf")]
|
||||
public IActionResult GetCsrfToken()
|
||||
@@ -30,11 +32,38 @@ public class AuthController(
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
|
||||
|
||||
var session = await authService.LoginAsync(request, ct);
|
||||
if (session is null) return Results.Unauthorized();
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
|
||||
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);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
@@ -54,6 +83,8 @@ public class AuthController(
|
||||
|
||||
SetRefreshCookie(Response, session.RefreshToken);
|
||||
Response.Headers.CacheControl = "no-store";
|
||||
Response.Headers["X-RateLimit-Remaining"] = "5";
|
||||
Response.Headers["X-RateLimit-Limit"] = "5";
|
||||
return Results.Ok(ToAuthResponse(session));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
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) : ControllerBase
|
||||
public class DashboardController(
|
||||
IDashboardService dashboardService,
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<DashboardStatus> GetStatus()
|
||||
@@ -115,17 +125,24 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
||||
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, ct);
|
||||
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, ct);
|
||||
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." }),
|
||||
@@ -149,6 +166,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
||||
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
|
||||
{
|
||||
@@ -158,6 +189,230 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
|
||||
};
|
||||
}
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
||||
// ── Task Board Endpoints ──
|
||||
|
||||
[HttpGet("tasks/board")]
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
|
||||
[HttpGet("live")]
|
||||
public async Task Live(
|
||||
[FromQuery] string forUser = "bao",
|
||||
[FromQuery] int notificationLimit = 50,
|
||||
[FromQuery] long? afterSequence = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
Response.Headers.Append("Content-Type", "text/event-stream");
|
||||
Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
Response.Headers.Append("Connection", "keep-alive");
|
||||
Response.Headers.Append("X-Accel-Buffering", "no");
|
||||
|
||||
async Task WriteEventAsync(string eventName, object payload)
|
||||
{
|
||||
await Response.WriteAsync($"event: {eventName}\n", ct);
|
||||
await Response.WriteAsync($"data: {System.Text.Json.JsonSerializer.Serialize(payload)}\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
|
||||
var currentSequence = liveUpdateService.CurrentSequence;
|
||||
var initial = new DashboardLiveSnapshotDto(
|
||||
await taskService.GetBoardAsync(ct),
|
||||
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
|
||||
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
await WriteEventAsync("snapshot", initial);
|
||||
|
||||
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
|
||||
if (completed == readTask)
|
||||
{
|
||||
var envelope = await readTask;
|
||||
if (envelope.Type == "notifications.snapshot")
|
||||
{
|
||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
envelope = envelope with { Payload = snapshot };
|
||||
}
|
||||
|
||||
if (envelope.Type == "tasks.board.snapshot")
|
||||
{
|
||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||
}
|
||||
|
||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||
envelope,
|
||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||
}
|
||||
else if (await heartbeatTask)
|
||||
{
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// MCP-style (structured-command) backend bridge for agent-facing operations.
|
||||
///
|
||||
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
|
||||
/// the Nexus task board, activity log, and delegation workflow.
|
||||
///
|
||||
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
|
||||
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
|
||||
///
|
||||
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
|
||||
/// calls these structured HTTP endpoints (same pattern, simpler transport).
|
||||
///
|
||||
/// COMMANDS:
|
||||
/// create_task → POST /api/bridge/tasks
|
||||
/// create_child_task → POST /api/bridge/tasks/{id}/children
|
||||
/// update_status → PATCH /api/bridge/tasks/{id}/status
|
||||
/// append_activity → POST /api/bridge/tasks/{id}/activity
|
||||
/// handoff → POST /api/bridge/tasks/{id}/handoff
|
||||
/// get_board → GET /api/bridge/board
|
||||
/// get_task → GET /api/bridge/tasks/{id}
|
||||
/// get_children → GET /api/bridge/tasks/{id}/children
|
||||
/// get_activity → GET /api/bridge/tasks/{id}/activity
|
||||
/// get_agent_overview → GET /api/bridge/agent-overview
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/bridge")]
|
||||
[EnableRateLimiting("agents")]
|
||||
public class GatewayBridgeController(
|
||||
ITaskBridgeService bridge,
|
||||
IAgentService agentService,
|
||||
ILogger<GatewayBridgeController> logger) : ControllerBase
|
||||
{
|
||||
private const string ApikeyErrorMessage =
|
||||
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
|
||||
|
||||
[HttpGet("health")]
|
||||
public IResult Health()
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
service = "nexus-bridge",
|
||||
version = "1.0.0",
|
||||
commands = new[]
|
||||
{
|
||||
"create_task", "create_child_task", "update_status",
|
||||
"append_activity", "handoff", "get_board", "get_task",
|
||||
"get_children", "get_activity", "get_agent_overview"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("tasks")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
|
||||
[FromBody] BridgeCreateTaskCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.CreateTaskAsync(
|
||||
title: command.Title,
|
||||
detail: command.Detail,
|
||||
source: ResolveSource(agentId),
|
||||
priority: command.Priority ?? "Normal",
|
||||
assignedTo: command.AssignedTo ?? agentId,
|
||||
projectId: command.ProjectId,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "create_task");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{parentTaskId:guid}/children")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
|
||||
Guid parentTaskId,
|
||||
[FromBody] BridgeCreateChildTaskCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.CreateChildTaskAsync(
|
||||
parentTaskId: parentTaskId,
|
||||
title: command.Title,
|
||||
detail: command.Detail,
|
||||
source: ResolveSource(agentId),
|
||||
priority: command.Priority ?? "Normal",
|
||||
assignedTo: command.AssignedTo,
|
||||
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "create_child_task");
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{taskId:guid}/status")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeUpdateStatusCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var agentId = resolution.AgentId;
|
||||
var result = await bridge.UpdateStatusAsync(
|
||||
taskId: taskId,
|
||||
state: command.State,
|
||||
callerAgent: agentId,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "update_status");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{taskId:guid}/activity")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeAppendActivityCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.AppendActivityAsync(
|
||||
taskId: taskId,
|
||||
message: command.Message,
|
||||
type: command.Type ?? "comment",
|
||||
ct: ct);
|
||||
|
||||
return MapActivityResult(result, "append_activity");
|
||||
}
|
||||
|
||||
[HttpPost("tasks/{taskId:guid}/handoff")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
|
||||
Guid taskId,
|
||||
[FromBody] BridgeHandoffCommand command,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.HandoffAsync(
|
||||
taskId: taskId,
|
||||
targetAgent: command.TargetAgent,
|
||||
note: command.Note,
|
||||
ct: ct);
|
||||
|
||||
return MapResult(result, "handoff");
|
||||
}
|
||||
|
||||
[HttpGet("board")]
|
||||
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
return Ok(await bridge.GetBoardAsync(ct));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var result = await bridge.GetTaskAsync(taskId, ct);
|
||||
return MapResult(result, "get_task");
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
|
||||
}
|
||||
|
||||
[HttpGet("tasks/{taskId:guid}/activity")]
|
||||
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
|
||||
Guid taskId, CancellationToken ct)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var events = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
|
||||
|
||||
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
|
||||
{
|
||||
Ok = true,
|
||||
Command = "get_activity",
|
||||
Data = entries
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("agent-overview")]
|
||||
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
|
||||
CancellationToken ct,
|
||||
[FromQuery] int staleHours = 2)
|
||||
{
|
||||
var resolution = await TryResolveAgentAsync(ct);
|
||||
if (!resolution.Success)
|
||||
return resolution.ErrorResult!;
|
||||
|
||||
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
|
||||
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
|
||||
}
|
||||
|
||||
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
|
||||
{
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
|
||||
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
{
|
||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||
if (allowedAgentIds.Contains(normalizedHeader))
|
||||
return (true, normalizedHeader, null);
|
||||
|
||||
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
|
||||
normalizedHeader,
|
||||
HttpContext.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim))
|
||||
return (true, normalizedClaim, null);
|
||||
|
||||
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member"))
|
||||
return (true, "bao", null);
|
||||
}
|
||||
|
||||
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system"))
|
||||
return (true, "nexus-system", null);
|
||||
|
||||
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
|
||||
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
|
||||
return (false, string.Empty, unauthorized);
|
||||
}
|
||||
|
||||
private static string ResolveSource(string agentId) => agentId switch
|
||||
{
|
||||
"bao" or "nexus-system" => "bao",
|
||||
_ => agentId
|
||||
};
|
||||
|
||||
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||
{
|
||||
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||
return new OkObjectResult(new TaskBridgeCommandResponse<T>
|
||||
{
|
||||
Ok = true,
|
||||
Command = command,
|
||||
Data = result.Data
|
||||
});
|
||||
|
||||
var statusCode = result.Outcome switch
|
||||
{
|
||||
TaskBridgeOutcome.NotFound => 404,
|
||||
TaskBridgeOutcome.InvalidState => 422,
|
||||
TaskBridgeOutcome.Unauthorized => 403,
|
||||
TaskBridgeOutcome.ValidationError => 400,
|
||||
_ => 500
|
||||
};
|
||||
|
||||
return new ObjectResult(new TaskBridgeCommandResponse<T>
|
||||
{
|
||||
Ok = false,
|
||||
Command = command,
|
||||
Error = result.Error ?? "Unknown error"
|
||||
}) { StatusCode = statusCode };
|
||||
}
|
||||
|
||||
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
|
||||
{
|
||||
if (result.Outcome == TaskBridgeOutcome.Success)
|
||||
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||
{
|
||||
Ok = true,
|
||||
Command = command,
|
||||
Data = result.Data is null ? null : new ActivityEntryDto(
|
||||
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
|
||||
});
|
||||
|
||||
var statusCode = result.Outcome switch
|
||||
{
|
||||
TaskBridgeOutcome.NotFound => 404,
|
||||
TaskBridgeOutcome.ValidationError => 400,
|
||||
_ => 500
|
||||
};
|
||||
|
||||
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
|
||||
{
|
||||
Ok = false,
|
||||
Command = command,
|
||||
Error = result.Error ?? "Unknown error"
|
||||
}) { StatusCode = statusCode };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TaskBridgeCommandResponse<T>
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
public string Command { get; init; } = string.Empty;
|
||||
public T? Data { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
|
||||
}
|
||||
|
||||
public sealed record BridgeCreateTaskCommand(
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? Priority = null,
|
||||
string? AssignedTo = null,
|
||||
Guid? ProjectId = null
|
||||
);
|
||||
|
||||
public sealed record BridgeCreateChildTaskCommand(
|
||||
string Title,
|
||||
string? Detail = null,
|
||||
string? Priority = null,
|
||||
string? AssignedTo = null,
|
||||
string? ExpectedFrom = null
|
||||
);
|
||||
|
||||
public sealed record BridgeUpdateStatusCommand(string State);
|
||||
|
||||
public sealed record BridgeAppendActivityCommand(
|
||||
string Message,
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record BridgeHandoffCommand(
|
||||
string TargetAgent,
|
||||
string? Note = null
|
||||
);
|
||||
|
||||
public sealed record ActivityEntryDto(
|
||||
long Id,
|
||||
string Type,
|
||||
string Message,
|
||||
DateTimeOffset CreatedAt
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Nexus.Api.Integrations;
|
||||
@@ -7,6 +8,13 @@ namespace Nexus.Api.Controllers;
|
||||
[ApiController]
|
||||
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")]
|
||||
public async Task<IResult> Get(CancellationToken ct)
|
||||
{
|
||||
@@ -26,20 +34,25 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
||||
runtimeDetail = ex.Message;
|
||||
}
|
||||
|
||||
static IReadOnlyDictionary<string, object?> NormalizeData(IReadOnlyDictionary<string, object> source)
|
||||
{
|
||||
return source.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);
|
||||
}
|
||||
|
||||
var entries = report.Entries.ToDictionary(
|
||||
e => e.Key,
|
||||
e => new
|
||||
{
|
||||
status = e.Value.Status.ToString(),
|
||||
description = e.Value.Description,
|
||||
data = e.Value.Data
|
||||
data = NormalizeData(e.Value.Data)
|
||||
});
|
||||
|
||||
entries["runtime"] = new
|
||||
{
|
||||
status = runtimeStatus,
|
||||
description = runtimeDetail ?? "Runtime status checked",
|
||||
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
|
||||
description = runtimeDetail,
|
||||
data = new Dictionary<string, object?>() as IReadOnlyDictionary<string, object?>
|
||||
};
|
||||
|
||||
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[HttpGet("snapshot")]
|
||||
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
|
||||
[FromQuery] string forUser = "bao",
|
||||
[FromQuery] int limit = 50,
|
||||
[FromQuery] bool unreadOnly = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
|
||||
}
|
||||
|
||||
[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,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
@@ -8,6 +9,7 @@ namespace Nexus.Api.Controllers;
|
||||
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||
{
|
||||
[HttpGet("snapshot")]
|
||||
[Authorize]
|
||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/projects")]
|
||||
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskService taskService) : ControllerBase
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
@@ -70,6 +73,10 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||
title: "Action denied",
|
||||
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
|
||||
statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Ok(result.Task)
|
||||
};
|
||||
}
|
||||
@@ -99,4 +106,63 @@ public class TasksController(ITaskService taskService) : ControllerBase
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
|
||||
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
|
||||
|
||||
/// <summary>
|
||||
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
///
|
||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
|
||||
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[HttpGet("board")]
|
||||
public async Task<IResult> GetBoard(CancellationToken ct)
|
||||
{
|
||||
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
|
||||
return Results.Unauthorized();
|
||||
|
||||
return Results.Ok(await taskService.GetBoardAsync(ct));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
|
||||
/// Wird vom Iris Autonomous Worker genutzt.
|
||||
///
|
||||
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
|
||||
/// X-Nexus-Api-Key / JWT-authenticated user.
|
||||
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
|
||||
/// </summary>
|
||||
[HttpPost("reset-stale")]
|
||||
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var agentHeader = await GetAllowedAgentHeaderAsync(ct);
|
||||
var isApiKey = HttpContext.User.IsInRole("Service");
|
||||
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
|
||||
|
||||
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
|
||||
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isIris && !isApiKey && !isAuth)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||
return Results.Ok(new ResetStaleResponse(count));
|
||||
}
|
||||
|
||||
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct)
|
||||
{
|
||||
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue))
|
||||
return null;
|
||||
|
||||
var normalized = headerValue.Trim().ToLowerInvariant();
|
||||
var allowed = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
return allowed.Contains(normalized) ? normalized : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,29 @@ public sealed record UserInfo
|
||||
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
|
||||
{
|
||||
[MaxLength(100)]
|
||||
|
||||
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
|
||||
string? Title,
|
||||
DateTimeOffset? Since
|
||||
);
|
||||
|
||||
|
||||
+120
-4
@@ -19,7 +19,8 @@ public enum TaskState
|
||||
Backlog,
|
||||
InProgress,
|
||||
Blocked,
|
||||
Done
|
||||
Done,
|
||||
Review
|
||||
}
|
||||
|
||||
public static class TaskStateHelper
|
||||
@@ -29,7 +30,8 @@ public static class TaskStateHelper
|
||||
[TaskState.Backlog] = "Backlog",
|
||||
[TaskState.InProgress] = "In progress",
|
||||
[TaskState.Blocked] = "Blocked",
|
||||
[TaskState.Done] = "Done"
|
||||
[TaskState.Done] = "Done",
|
||||
[TaskState.Review] = "Review"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -37,11 +39,22 @@ public static class TaskStateHelper
|
||||
["Backlog"] = TaskState.Backlog,
|
||||
["In progress"] = TaskState.InProgress,
|
||||
["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",
|
||||
["Review"] = "Review",
|
||||
["Blocked"] = "Blockiert",
|
||||
["Done"] = "Erledigt"
|
||||
};
|
||||
|
||||
/// <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", "Blocked", "Done", "Review"];
|
||||
|
||||
/// <summary>Convert a TaskState enum to its API string representation.</summary>
|
||||
public static string ToStateString(this TaskState state) => StateToString[state];
|
||||
@@ -54,6 +67,10 @@ public static class TaskStateHelper
|
||||
public static bool IsValidState(string? 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) =>
|
||||
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -61,6 +78,75 @@ public static class TaskStateHelper
|
||||
public static bool IsDoneOrBacklog(string? state) =>
|
||||
string.Equals(state, "Done", 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, researcher, executor) 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", "researcher", "executor" };
|
||||
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",
|
||||
"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",
|
||||
"review" => "Review",
|
||||
"blocked" => "Blocked",
|
||||
"done" => "Done",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Project
|
||||
@@ -82,15 +168,45 @@ public sealed class WorkTask
|
||||
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 DateTimeOffset? DueDate { get; set; }
|
||||
public DateTimeOffset CreatedAt { 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 long Id { get; init; }
|
||||
public required string Type { get; set; }
|
||||
public required string Message { get; set; }
|
||||
public Guid? TaskId { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Nexus.Api.Data;
|
||||
|
||||
@@ -28,6 +29,21 @@ public class NexusUser
|
||||
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>
|
||||
[Table("SeedAudit")]
|
||||
public class SeedAudit
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(80)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public class RefreshToken
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -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)
|
||||
.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");
|
||||
});
|
||||
|
||||
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
|
||||
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")
|
||||
@@ -166,6 +214,20 @@ namespace Nexus.Api.Migrations
|
||||
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")
|
||||
@@ -183,6 +245,19 @@ namespace Nexus.Api.Migrations
|
||||
.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");
|
||||
@@ -211,6 +286,12 @@ namespace Nexus.Api.Migrations
|
||||
|
||||
b.HasIndex("AssignedTo");
|
||||
|
||||
b.HasIndex("ExpectedFrom");
|
||||
|
||||
b.HasIndex("IsAgentTask");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("Source");
|
||||
|
||||
b.ToTable("Tasks");
|
||||
@@ -227,10 +308,25 @@ namespace Nexus.Api.Migrations
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
||||
{
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<WorkTask> Tasks => Set<WorkTask>();
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
|
||||
public DbSet<NexusUser> Users => Set<NexusUser>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -19,10 +21,30 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
|
||||
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<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
|
||||
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
|
||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
|
||||
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
|
||||
|
||||
@@ -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,253 @@
|
||||
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.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
// ── Backend Bridge (Agent-Command-Service) ──
|
||||
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||
|
||||
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>();
|
||||
}
|
||||
+106
-3
@@ -86,8 +86,18 @@ public sealed record DashboardTaskDto(
|
||||
string State,
|
||||
string Priority,
|
||||
string? AssignedTo,
|
||||
Guid? ParentTaskId,
|
||||
DateTimeOffset? DueDate,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool IsAgentTask = false,
|
||||
string? ExpectedFrom = null,
|
||||
string? LastActivityMessage = null,
|
||||
DateTimeOffset? LastActivityAt = null,
|
||||
List<DashboardTaskDto>? ChildTasks = null,
|
||||
int ChildTaskCount = 0,
|
||||
int OpenChildTaskCount = 0,
|
||||
bool HasVisibleDelegation = false
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -95,7 +105,18 @@ public sealed record CreateDashboardTaskRequest(
|
||||
string? Detail,
|
||||
string? Source,
|
||||
string? Priority,
|
||||
string? AssignedTo
|
||||
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(
|
||||
@@ -103,7 +124,8 @@ public sealed record UpdateDashboardTaskRequest(
|
||||
string? Detail,
|
||||
string? Source,
|
||||
string? Priority,
|
||||
string? AssignedTo
|
||||
string? AssignedTo,
|
||||
DateTimeOffset? DueDate = null
|
||||
);
|
||||
|
||||
public sealed record UpdateDashboardTaskStatusRequest(
|
||||
@@ -114,3 +136,84 @@ public sealed record AgentActivityEntry(
|
||||
string Time,
|
||||
string Text
|
||||
);
|
||||
|
||||
// ── Task Board DTOs ──
|
||||
|
||||
public sealed record BoardResponse(
|
||||
List<DashboardTaskDto> Offen,
|
||||
List<DashboardTaskDto> InProgress,
|
||||
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);
|
||||
|
||||
public sealed record LiveUpdateEnvelope(
|
||||
string Type,
|
||||
DateTimeOffset Timestamp,
|
||||
object Payload,
|
||||
long Sequence,
|
||||
string Channel
|
||||
);
|
||||
|
||||
public sealed record LiveCursorDto(
|
||||
long Sequence,
|
||||
DateTimeOffset Timestamp,
|
||||
string Mode
|
||||
);
|
||||
|
||||
public sealed record NotificationSnapshotDto(
|
||||
List<NotificationDto> Notifications,
|
||||
int UnreadCount,
|
||||
string ForUser
|
||||
);
|
||||
|
||||
public sealed record DashboardLiveSnapshotDto(
|
||||
BoardResponse Board,
|
||||
NotificationSnapshotDto Notifications,
|
||||
LiveCursorDto Cursor
|
||||
);
|
||||
|
||||
public sealed record DashboardLiveEventDto(
|
||||
LiveUpdateEnvelope Envelope,
|
||||
LiveCursorDto Cursor
|
||||
);
|
||||
|
||||
@@ -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
-222
@@ -1,234 +1,26 @@
|
||||
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.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;
|
||||
using Nexus.Api.Extensions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- JWT Configuration ---
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
||||
var jwtAudience = builder.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.");
|
||||
|
||||
builder.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)
|
||||
};
|
||||
});
|
||||
|
||||
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(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
// --- Application Services ---
|
||||
builder.Services.AddTransient<ModelRoutingService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<ITaskService, TaskService>();
|
||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||
builder.Services.AddScoped<ITeamService, TeamService>();
|
||||
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
||||
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
||||
builder.Services.AddSingleton<IDocService, DocService>();
|
||||
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
// --- Repositories ---
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
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 ---
|
||||
// --- Service Registration ---
|
||||
builder.Services.AddNexusAuth(builder.Configuration);
|
||||
builder.Services.AddNexusRateLimiting();
|
||||
builder.Services.AddNexusForwardedHeaders();
|
||||
builder.Services.AddNexusSwagger();
|
||||
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||
builder.Services.AddNexusApplicationServices();
|
||||
builder.Services.AddNexusRepositories();
|
||||
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// --- Database Migration & Owner Seeding ---
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Database Migration & Seeding ---
|
||||
await app.EnsureDatabaseAsync();
|
||||
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseSecurityHeaders();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapControllers();
|
||||
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)
|
||||
=> 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(
|
||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
|
||||
public interface IActivityRepository
|
||||
{
|
||||
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(
|
||||
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
|
||||
|
||||
@@ -7,8 +7,10 @@ public interface IUserRepository
|
||||
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, 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 UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
|
||||
|
||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
||||
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
db.Tasks.Update(task);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
|
||||
=> 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)
|
||||
=> db.Users.AnyAsync(ct);
|
||||
|
||||
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
||||
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
|
||||
=> 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)
|
||||
=> db.RefreshTokens
|
||||
.Include(r => r.User)
|
||||
|
||||
@@ -73,6 +73,7 @@ public interface IAgentService
|
||||
{
|
||||
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
|
||||
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
|
||||
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
|
||||
@@ -151,6 +152,16 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configs = await LoadAgentConfigsAsync(cancellationToken);
|
||||
return configs
|
||||
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
||||
.Select(config => config.Id.Trim().ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||
{
|
||||
"iris" => "Orchestrator",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Threading.Channels;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface ILiveUpdateService
|
||||
{
|
||||
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
|
||||
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
|
||||
long CurrentSequence { get; }
|
||||
}
|
||||
|
||||
public sealed class LiveUpdateSubscription
|
||||
{
|
||||
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
|
||||
public long StartingSequence { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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);
|
||||
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Structured backend bridge for agent/task commands.
|
||||
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
|
||||
/// with the task board, activity log, and delegation workflow.
|
||||
///
|
||||
/// This is the internal service layer — never exposed directly to the browser.
|
||||
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
|
||||
/// </summary>
|
||||
public interface ITaskBridgeService
|
||||
{
|
||||
// ── Task CRUD (Agent-Commands) ──
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new top-level task (parent or standalone).
|
||||
/// Returns the created task DTO.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
Guid? projectId = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a child task linked to an existing parent.
|
||||
/// This is the primary delegation command: iris creates a child task,
|
||||
/// assigns it to a sub-agent, and tracks it on the board.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the status/state of a task.
|
||||
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||
Guid taskId,
|
||||
string state,
|
||||
string? callerAgent = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Appends an activity entry to a task (comment, status note, agent note).
|
||||
/// Used by agents to annotate their progress on the board.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Handles a task handoff: sets ExpectedFrom to the target agent,
|
||||
/// appends a handoff activity entry, and optionally updates assigned-to.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ── Query (Read) ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full task board state (grouped by status column).
|
||||
/// </summary>
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a single task by ID.
|
||||
/// </summary>
|
||||
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||
Guid taskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all child tasks for a given parent task.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns task activity history.
|
||||
/// </summary>
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId,
|
||||
CancellationToken ct = default);
|
||||
|
||||
// ── Agent Workflow ──
|
||||
|
||||
/// <summary>
|
||||
/// Returns the agent-workflow overview: who is expected to respond,
|
||||
/// stale tasks, workload distribution.
|
||||
/// </summary>
|
||||
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||
TimeSpan? staleThreshold = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result pattern for task-bridge operations.
|
||||
/// WorkTask? is null on NotFound; state is stored in the Outcome.
|
||||
/// </summary>
|
||||
public sealed record TaskBridgeResult<T>(
|
||||
TaskBridgeOutcome Outcome,
|
||||
T? Data = default,
|
||||
string? Error = null
|
||||
);
|
||||
|
||||
public enum TaskBridgeOutcome
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
InvalidState,
|
||||
Unauthorized,
|
||||
ValidationError
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
@@ -21,9 +22,23 @@ public interface ITaskService
|
||||
|
||||
// Dashboard-facing task operations
|
||||
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||
Task<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,87 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class LiveUpdateService : ILiveUpdateService
|
||||
{
|
||||
private const int ReplayLimit = 256;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
|
||||
private readonly object _historyLock = new();
|
||||
private readonly Queue<LiveUpdateEnvelope> _history = new();
|
||||
private long _sequence;
|
||||
|
||||
public long CurrentSequence => Interlocked.Read(ref _sequence);
|
||||
|
||||
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
AllowSynchronousContinuations = false
|
||||
});
|
||||
|
||||
var id = Guid.NewGuid();
|
||||
_subscribers[id] = channel;
|
||||
|
||||
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
|
||||
foreach (var envelope in replay)
|
||||
{
|
||||
channel.Writer.TryWrite(envelope);
|
||||
}
|
||||
|
||||
ct.Register(() =>
|
||||
{
|
||||
if (_subscribers.TryRemove(id, out var removed))
|
||||
{
|
||||
removed.Writer.TryComplete();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.FromResult(new LiveUpdateSubscription
|
||||
{
|
||||
Reader = channel.Reader,
|
||||
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
|
||||
});
|
||||
}
|
||||
|
||||
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||
{
|
||||
var envelope = new LiveUpdateEnvelope(
|
||||
type,
|
||||
DateTimeOffset.UtcNow,
|
||||
payload,
|
||||
Interlocked.Increment(ref _sequence),
|
||||
channel);
|
||||
|
||||
lock (_historyLock)
|
||||
{
|
||||
_history.Enqueue(envelope);
|
||||
while (_history.Count > ReplayLimit)
|
||||
{
|
||||
_history.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (id, subscriber) in _subscribers)
|
||||
{
|
||||
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
|
||||
{
|
||||
removed.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
|
||||
{
|
||||
lock (_historyLock)
|
||||
{
|
||||
return _history.Where(item => item.Sequence > afterSequence).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : 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);
|
||||
await PublishSnapshotAsync(notification.ForUser, 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);
|
||||
await PublishSnapshotAsync(notification.ForUser, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedUser = forUser.ToLowerInvariant();
|
||||
var count = await db.Notifications
|
||||
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
|
||||
await PublishSnapshotAsync(normalizedUser, 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);
|
||||
}
|
||||
|
||||
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedUser = forUser.ToLowerInvariant();
|
||||
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
|
||||
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
|
||||
return new NotificationSnapshotDto(
|
||||
notifications.Select(MapToDto).ToList(),
|
||||
unreadCount,
|
||||
normalizedUser);
|
||||
}
|
||||
|
||||
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
|
||||
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
|
||||
}
|
||||
|
||||
private static NotificationDto MapToDto(Notification n) => new(
|
||||
n.Id, n.Type, n.Title, n.Message,
|
||||
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
|
||||
}
|
||||
@@ -15,16 +15,13 @@ public sealed class OperationsService(
|
||||
{
|
||||
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;
|
||||
// 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 = runtimeTask.Result;
|
||||
var runtimeStatus = await runtimeTask;
|
||||
|
||||
var lastIncident = tasks
|
||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||
@@ -56,7 +53,7 @@ public sealed class OperationsService(
|
||||
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 })
|
||||
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete implementation of ITaskBridgeService.
|
||||
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
|
||||
/// into structured, predictable commands for agent-facing usage.
|
||||
///
|
||||
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
|
||||
/// making agent consumption safe and debuggable.
|
||||
/// </summary>
|
||||
public sealed class TaskBridgeService(
|
||||
ITaskService taskService,
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService) : ITaskBridgeService
|
||||
{
|
||||
private static readonly HashSet<string> ValidStates =
|
||||
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// ──────────────────────────────── Create Task ────────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
Guid? projectId = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||
|
||||
var normalizedSource = NormalizeSource(source);
|
||||
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
|
||||
|
||||
var task = await taskService.CreateDashboardTaskAsync(
|
||||
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct);
|
||||
|
||||
var dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Create Child Task ──────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? source = "iris",
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
|
||||
|
||||
// Verify parent exists
|
||||
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
|
||||
if (parent is null)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
|
||||
|
||||
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
|
||||
|
||||
var task = await taskService.CreateAgentTaskAsync(
|
||||
title.Trim(), detail?.Trim(), NormalizeSource(source),
|
||||
priority, normalizedAssignee, expectedFrom, parentTaskId, ct);
|
||||
|
||||
// If parent was in Backlog, move it to InProgress (coordination starts)
|
||||
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct);
|
||||
}
|
||||
|
||||
var dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Update Status ──────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
|
||||
Guid taskId,
|
||||
string state,
|
||||
string? callerAgent = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!ValidStates.Contains(state))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
|
||||
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
// Check authorization
|
||||
if (!TaskStateHelper.CanChangeState(callerAgent, task))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
|
||||
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
|
||||
|
||||
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
|
||||
if (result.Outcome != TaskOperationOutcome.Success)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
|
||||
|
||||
var dto = MapToDto(result.Task!);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Append Activity ────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
var ev = new ActivityEvent
|
||||
{
|
||||
Type = type ?? "comment",
|
||||
Message = message.Trim(),
|
||||
TaskId = taskId
|
||||
};
|
||||
|
||||
await activityRepo.AddAsync(ev, ct);
|
||||
|
||||
// Trigger live update so the board refreshes
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
liveUpdateService.Publish("tasks.board.snapshot", board);
|
||||
|
||||
return Success(ev);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Handoff ────────────────────────────────────
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetAgent))
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
|
||||
|
||||
var task = await taskService.GetByIdAsync(taskId, ct);
|
||||
if (task is null)
|
||||
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
|
||||
|
||||
var normalizedTarget = targetAgent.Trim().ToLowerInvariant();
|
||||
var handoffNote = string.IsNullOrWhiteSpace(note)
|
||||
? $"Handoff → {normalizedTarget}"
|
||||
: $"Handoff → {normalizedTarget}: {note.Trim()}";
|
||||
|
||||
// Update expected-from and optionally assigned-to
|
||||
task.ExpectedFrom = normalizedTarget;
|
||||
|
||||
// If this is a child task (has parent), keep assigned-to on the child
|
||||
// If standalone, set assigned-to to the target
|
||||
if (!task.ParentTaskId.HasValue)
|
||||
task.AssignedTo = normalizedTarget;
|
||||
|
||||
await taskService.UpdateDashboardTaskAsync(
|
||||
taskId, title: null, detail: null, source: null,
|
||||
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
|
||||
|
||||
// Append handoff activity
|
||||
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
|
||||
|
||||
// Notify the target
|
||||
await notificationService.CreateAsync(
|
||||
"task_assigned",
|
||||
$"Handoff: {task.Title}",
|
||||
handoffNote,
|
||||
normalizedTarget,
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
var dto = MapToDto(task);
|
||||
return Success(dto);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Query ──────────────────────────────────────
|
||||
|
||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
=> await taskService.GetBoardAsync(ct);
|
||||
|
||||
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
|
||||
return dto is null
|
||||
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
|
||||
: Success(dto);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
|
||||
return children.Select(MapToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
=> await taskService.GetTaskActivityAsync(taskId, ct);
|
||||
|
||||
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
|
||||
TimeSpan? staleThreshold = null, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
|
||||
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
|
||||
}
|
||||
|
||||
// ──────────────────────────────── Helpers ────────────────────────────────────
|
||||
|
||||
private static TaskBridgeResult<T> Success<T>(T data) =>
|
||||
new(TaskBridgeOutcome.Success, data);
|
||||
|
||||
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
|
||||
new(outcome, Data: default, Error: error);
|
||||
|
||||
private static string NormalizeSource(string? source) =>
|
||||
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
|
||||
|
||||
private static string? NormalizeAssignedTo(string? assignedTo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" };
|
||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
||||
return valid.Contains(lower) ? lower : null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
+484
-26
@@ -1,19 +1,36 @@
|
||||
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) : ITaskService
|
||||
IActivityRepository activityRepo,
|
||||
INotificationService notificationService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILiveUpdateService liveUpdateService) : ITaskService
|
||||
{
|
||||
private static readonly HashSet<string> ValidAssignees =
|
||||
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> 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 allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var task = allTasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task is null) return null;
|
||||
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
|
||||
return MapToDtoWithChildren(task, allTasks, activity);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||
|
||||
@@ -26,7 +43,8 @@ public sealed class TaskService(
|
||||
ProjectId = request.ProjectId
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -40,7 +58,8 @@ public sealed class TaskService(
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -54,7 +73,8 @@ public sealed class TaskService(
|
||||
|
||||
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);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -66,9 +86,15 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
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}" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -77,15 +103,28 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||
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))
|
||||
}
|
||||
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);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, 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);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -97,13 +136,12 @@ public sealed class TaskService(
|
||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
|
||||
await taskRepo.DeleteAsync(task, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||
}
|
||||
|
||||
// ── Dashboard-facing operations ──
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
@@ -112,36 +150,213 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||
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();
|
||||
}
|
||||
|
||||
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
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 => MapToDtoWithChildren(task, all, 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) && 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)
|
||||
{
|
||||
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 normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
|
||||
var normalizedAssignee = ValidateAssignedTo(assignedTo);
|
||||
var isVisibleDelegation = parentTaskId.HasValue;
|
||||
|
||||
var task = new WorkTask
|
||||
{
|
||||
Title = title.Trim(),
|
||||
Detail = detail?.Trim(),
|
||||
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
|
||||
Source = normalizedSource,
|
||||
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||
AssignedTo = assignedTo?.Trim()
|
||||
AssignedTo = normalizedAssignee,
|
||||
ParentTaskId = parentTaskId,
|
||||
IsAgentTask = isVisibleDelegation
|
||||
};
|
||||
await taskRepo.AddAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
|
||||
|
||||
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
|
||||
if (parentTaskId.HasValue)
|
||||
{
|
||||
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "delegation",
|
||||
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
|
||||
TaskId = parentTaskId.Value
|
||||
}, ct);
|
||||
}
|
||||
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
|
||||
|
||||
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
"task_assigned",
|
||||
$"Neue Aufgabe: {task.Title}",
|
||||
detail,
|
||||
"bao",
|
||||
task.Id,
|
||||
ct);
|
||||
}
|
||||
|
||||
await PublishBoardSnapshotAsync(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 normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
|
||||
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
|
||||
|
||||
task.IsAgentTask = true;
|
||||
task.ExpectedFrom = normalizedExpectedFrom;
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
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);
|
||||
|
||||
if (parentTaskId.HasValue)
|
||||
{
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "delegation",
|
||||
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
|
||||
TaskId = parentTaskId.Value
|
||||
}, ct);
|
||||
}
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"agent_task_created",
|
||||
$"Neuer Agent-Task: {task.Title}",
|
||||
detail,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||
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);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
||||
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
||||
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
|
||||
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 && 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);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, 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);
|
||||
|
||||
if (changes.Count > 0 && caller == "bao")
|
||||
{
|
||||
await notificationService.CreateAsync(
|
||||
"task_content_changed",
|
||||
$"Bao hat \"{task.Title}\" geändert",
|
||||
changeSummary,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
}
|
||||
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -153,10 +368,16 @@ public sealed class TaskService(
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
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}" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
|
||||
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -167,7 +388,8 @@ public sealed class TaskService(
|
||||
|
||||
task.State = "Done";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
@@ -185,7 +407,243 @@ public sealed class TaskService(
|
||||
};
|
||||
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||
}
|
||||
|
||||
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = 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 = MapToDtoWithChildren(task, all, activity);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": offen.Add(dto); break;
|
||||
case "in progress": inProgress.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);
|
||||
review.Sort(SortByPriorityThenCreatedAt);
|
||||
blocked.Sort(SortByPriorityThenCreatedAt);
|
||||
done.Sort(SortByPriorityThenCreatedAt);
|
||||
|
||||
return new BoardResponse(offen, inProgress, review, blocked, done);
|
||||
}
|
||||
|
||||
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
|
||||
{
|
||||
var board = await GetBoardAsync(ct);
|
||||
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
|
||||
?? 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);
|
||||
|
||||
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);
|
||||
await PublishBoardSnapshotAsync(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) && 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);
|
||||
}
|
||||
|
||||
if (staleTasks.Count > 0)
|
||||
await PublishBoardSnapshotAsync(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 DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
|
||||
{
|
||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
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, IReadOnlyList<WorkTask>? _allTasks = null)
|
||||
{
|
||||
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,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
t.ParentTaskId.HasValue || t.IsAgentTask);
|
||||
}
|
||||
|
||||
private static string? ValidateAssignedTo(string? assignedTo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
|
||||
var lower = assignedTo.Trim().ToLowerInvariant();
|
||||
return ValidAssignees.Contains(lower) ? lower : null;
|
||||
}
|
||||
|
||||
private string ResolveCaller()
|
||||
{
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null) return "nexus-system";
|
||||
|
||||
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() ?? "";
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-11
@@ -4,7 +4,14 @@ services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384M
|
||||
reservations:
|
||||
memory: 96M
|
||||
environment:
|
||||
POSTGRES_INITDB_ARGS: --data-checksums
|
||||
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||
@@ -28,6 +35,11 @@ services:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
@@ -41,31 +53,36 @@ services:
|
||||
Jwt__Issuer: ${JWT_ISSUER:-nexus}
|
||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||
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__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||
NexusApiKey: ${NEXUS_API_KEY:-}
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
restart: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
|
||||
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:
|
||||
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
||||
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
||||
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
||||
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default
|
||||
@@ -80,23 +97,37 @@ services:
|
||||
context: ./frontend
|
||||
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:
|
||||
- "127.0.0.1:18880:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
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]
|
||||
networks:
|
||||
- nexus
|
||||
- proxy
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@@ -107,6 +138,8 @@ networks:
|
||||
nexus:
|
||||
openclaw_default:
|
||||
external: true
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
nexus-postgres:
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
# Nexus Board-First Orchestration & Sichere OpenClaw-Integration
|
||||
|
||||
> Gegenprüfung: Architekt, 2026-06-22
|
||||
> Gegenstand: Sicherster Pfad für Board-first-Agent-Orchestrierung und MCP-artige/strukturierte
|
||||
> OpenClaw-Integration im Nexus-Backend
|
||||
> Kein Frontend-direkter MCP-Pfad. Kein Deploy.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### 1.1 Prüfergebnis
|
||||
|
||||
Der eingeschlagene Pfad ist **architektonisch korrekt und sicher**. Das Board-first-Modell mit
|
||||
Nexus-Backend als zentraler Brücke zwischen Benutzer, Board und OpenClaw-Gateway ist der richtige
|
||||
Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren.
|
||||
|
||||
### 1.2 Kernbewertung
|
||||
|
||||
| Aspekt | Status | Bewertung |
|
||||
|--------|--------|-----------|
|
||||
| Board-first Architektur | ✅ Umsetzung läuft | Parent/Child-Modell korrekt implementiert |
|
||||
| Kein Frontend-direkter Gateway-Zugriff | ✅ Eingehalten | Frontend spricht NUR mit Nexus-Backend |
|
||||
| Backend als sichere Brücke | ✅ Eingehalten | API-Container proxyt alle Gateway-Calls |
|
||||
| Auth-/Rechte-Modell | ✅ Solide | JWT + ApiKey + X-Agent-Id Enforcement |
|
||||
| Gateway-Security | ⚠️ Verbesserbar | `loopback`-Bind muss auf `lan` für Docker |
|
||||
| Migration-Reihenfolge | 📋 Vorgeschlagen | (siehe Abschnitt 8) |
|
||||
|
||||
## 2. Ist-Architektur: Wer spricht mit wem?
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Browser (Bao/Iris) │
|
||||
│ auth.ts → JWT Access Token (15m) + HttpOnly Refresh Cookie │
|
||||
│ api.ts → fetch /api/v1/* → Authorization: Bearer <JWT> │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTPS :443 (Traefik/npm)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Nexus web (nginx container) │
|
||||
│ location /api/ → proxy_pass http://api:8080 │
|
||||
│ location / → SPA (index.html) │
|
||||
│ CSP: connect-src 'self' — kein externer Gateway-Call möglich │
|
||||
└───────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTP :8080 (internal network)
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Nexus API (.NET 10 Container) ← SICHERE BRÜCKE │
|
||||
│ │
|
||||
│ Auth-Middleware (JWT → Controller) │
|
||||
│ ApiKey-Middleware (X-Nexus-Api-Key → Role=Service) │
|
||||
│ SecurityHeaders-Middleware (HSTS, CSP, XFO) │
|
||||
│ Rate Limiter (auth: 5/min/caller, agents: 30/min/caller) │
|
||||
│ │
|
||||
│ IOpenClawGatewayClient │
|
||||
│ ├─ InvokeToolAsync("session_status", ...) │
|
||||
│ ├─ InvokeToolAsync("sessions_list", ...) │
|
||||
│ ├─ InvokeToolAsync("sessions_history", ...) │
|
||||
│ ├─ InvokeToolAsync("memory_search", ...) │
|
||||
│ ├─ InvokeToolAsync("sessions_send", ...) │
|
||||
│ └─ InvokeToolAsync("cron", ...) │
|
||||
│ │
|
||||
│ GatewayBridgeController (/api/bridge/*) <- NEU (2026-06-22) │
|
||||
│ └─ ITaskBridgeService │
|
||||
│ create_task / create_child_task / update_status / │
|
||||
│ append_activity / handoff / get_board / get_agent_overview │
|
||||
│ │
|
||||
│ IAgentRuntime (OpenClawRuntime) │
|
||||
│ └─ POST /v1/chat/completions (OpenAI-compat) │
|
||||
│ │
|
||||
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||
│ host.docker.internal:18789 │
|
||||
│ (Gateway loopback/lan) │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ OpenClaw Gateway Container │ │
|
||||
│ Port 18789 │ │
|
||||
│ Auth: password │ │
|
||||
│ Bind: loopback (127.0.0.1) │ │
|
||||
│ │ │
|
||||
│ HTTP Deny-List (default): │ │
|
||||
│ exec, spawn, shell, │ │
|
||||
│ fs_write, fs_delete, │ │
|
||||
│ fs_move, apply_patch, │ │
|
||||
│ sessions_spawn, sessions_send│ │
|
||||
│ cron, gateway, nodes, │ │
|
||||
│ whatsapp_login │ │
|
||||
└───────────────────────────────┴──────────────┘
|
||||
```
|
||||
|
||||
### 2.1 Wichtig: Das Frontend sieht den Gateway NICHT
|
||||
|
||||
```
|
||||
Browser ─── [Nexus API] ─── Gateway
|
||||
│
|
||||
+─ Gateway-Passwort lebt NUR im Backend
|
||||
+─ CSP: connect-src 'self' blockiert jeden Direktzugriff
|
||||
+─ Gateway HTTP Deny-List blockiert alle RCE-Tools
|
||||
```
|
||||
|
||||
**Das ist die korrekte Architektur.** Kein Frontend-komponenten-direkter MCP-Pfad existiert
|
||||
und keiner sollte eingeführt werden.
|
||||
|
||||
## 3. Board-First Orchestrierung: Ist-Stand & Bewertung
|
||||
|
||||
### 3.1 Das Parent/Child-Task-Modell (Phase 3)
|
||||
|
||||
```
|
||||
Parent-Task (Owner: Iris)
|
||||
├── Child-Task A (AssignedTo: programmer)
|
||||
├── Child-Task B (AssignedTo: reviewer)
|
||||
└── Child-Task C (AssignedTo: architekt)
|
||||
```
|
||||
|
||||
**Status:** ✅ Implementiert (2026-06-21)
|
||||
|
||||
**Datenmodell (WorkTask):**
|
||||
- `ParentTaskId` (Guid?) — verknüpft Child mit Parent
|
||||
- `IsAgentTask` (bool) — markiert programmatisch erstellte Agent-Tasks
|
||||
- `ExpectedFrom` (string?) — wer als nächstes antworten soll
|
||||
- `AssignedTo` (string?) — operativer Owner der Task
|
||||
|
||||
**State Machine:**
|
||||
```
|
||||
Parent: Backlog → InProgress → Review → Done
|
||||
↘ Blocked → Backlog
|
||||
|
||||
Child: Backlog → InProgress → Done
|
||||
↘ Blocked → Backlog
|
||||
```
|
||||
|
||||
**Rules (aus TaskStateHelper.CanChangeState):**
|
||||
- **Nur Iris & Bao** dürfen State-Änderungen vornehmen
|
||||
- Sub-Agenten (programmer, reviewer, architekt, researcher, executor) **niemals**
|
||||
- `nexus-system` als technischer Fallback für Cron/Reset-Stale
|
||||
|
||||
### 3.2 Bewertung: Architektonisch korrekt
|
||||
|
||||
| Kriterium | Bewertung | Begründung |
|
||||
|-----------|-----------|------------|
|
||||
| Board als Single Source of Truth | ✅ | Task Board = sichtbare Aufgabenwahrheit |
|
||||
| Delegation sichtbar | ✅ | Child-Tasks statt unsichtbarem Delegations-Status |
|
||||
| Feingliedrige Berechtigung | ✅ | State-Change nur durch Iris/Bao |
|
||||
| Agenten arbeiten gegen Child-Tasks | ✅ | Klare Ownership durch `AssignedTo` |
|
||||
| Parent bleibt bei Iris | ✅ | Parent in `InProgress` während Koordination |
|
||||
| Review-Gate für Bao | ✅ | Parent erst in `Review`, dann Bao-Entscheidung |
|
||||
|
||||
### 3.3 Offene Punkte im Board-Modell
|
||||
|
||||
1. **Keine automatische Child-Task-Erstellung** — Das Board-Modell setzt voraus, dass Iris manuell
|
||||
Child-Tasks anlegt. Ein strukturierter Workflow für automatische Child-Task-Erstellung bei
|
||||
`spawn`/Subagent-Aufrufen fehlt.
|
||||
|
||||
2. **Keine Task→Session-Verknüpfung** — Es gibt keine direkte Verknüpfung zwischen einer
|
||||
Child-Task und der OpenClaw-Subagent-Session, die sie bearbeitet. Der `AgentService` kennt
|
||||
Sessions, `TaskService` kennt Tasks — aber sie sind nicht verknüpft.
|
||||
|
||||
3. **Reset-Stale ist ungeschützt** — `POST /api/v1/tasks/reset-stale` hat `[AllowAnonymous]`
|
||||
und kann von jedem aufgerufen werden (siehe Risiko #1).
|
||||
|
||||
## 4. Auth- und Rechte-Modell
|
||||
|
||||
### 4.1 Authentifizierungsebenen
|
||||
|
||||
```
|
||||
Ebene 1: Browser-JWT (Access Token 15m, Refresh Token HttpOnly Cookie)
|
||||
Ebene 2: X-Nexus-Api-Key (Service-zu-Service, Role=Service)
|
||||
Ebene 3: Gateway-Password (Backend → Gateway, Bearer Authorization)
|
||||
Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
||||
```
|
||||
|
||||
### 4.2 Berechtigungsmatrix
|
||||
|
||||
| Aktion | Bao | Iris | Sub-Agent | nexus-system | Service (ApiKey) |
|
||||
|--------|-----|------|-----------|--------------|-------------------|
|
||||
| Task State ändern | ✅ | ✅ | ❌ | ✅ (intern) | ❌ |
|
||||
| Task Inhalt editieren | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Agent-Config lesen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Gateway-Tool aufrufen | ❌ | ❌ | ❌ | ❌ | ✅ (intern) |
|
||||
| Dashboard-Metriken sehen | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
|
||||
### 4.3 Bewertung
|
||||
|
||||
**Positiv:**
|
||||
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
|
||||
- Refresh-Token-Reuse-Detection verhindert Token-Theft
|
||||
- Rate-Limiting auf Login und Refresh
|
||||
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
|
||||
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
|
||||
|
||||
**Kritisch:**
|
||||
- `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` — siehe Risiko-Analyse
|
||||
- Kein Scoped-ApiKey — der `X-Nexus-Api-Key` gibt volle Service-Rechte
|
||||
- Keine Audit-Protokollierung für ApiKey-Nutzung
|
||||
|
||||
## 5. Gateway-Integration: Sicherheitsanalyse
|
||||
|
||||
### 5.1 Tool-Invoke-Pfad (Ist-Stand)
|
||||
|
||||
```
|
||||
POST /api/v1/operations/snapshot
|
||||
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||
→ POST http://host.docker.internal:18789/tools/invoke
|
||||
Authorization: Bearer <Gateway-Password>
|
||||
```
|
||||
|
||||
**Aufgerufene Tools (durch Nexus-Backend):**
|
||||
- `session_status` — Agent-Status abfragen (read-only)
|
||||
- `sessions_list` — Session-Liste (read-only)
|
||||
- `sessions_history` — Chat-Verlauf (read-only)
|
||||
- `memory_search` — Memory-Suche (read-only)
|
||||
- `sessions_send` — Chat-Nachricht senden (write, aber kontrolliert)
|
||||
- `cron` — Cron-Jobs verwalten (write)
|
||||
|
||||
**Gateway HTTP Deny-List blockiert:**
|
||||
- Alle Exec-Tools (exec, spawn, shell)
|
||||
- Alle Filesystem-Tools (fs_write, fs_delete, fs_move, apply_patch)
|
||||
- Gateway-Control-Plane (gateway)
|
||||
- Node-Relay (nodes)
|
||||
- Session-Orchestrierung (sessions_spawn, sessions_send)
|
||||
|
||||
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||
|
||||
**Aktuelles Problem:**
|
||||
```
|
||||
compose.yaml:
|
||||
api:
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default ← API-Container ist im Gateway-Netzwerk
|
||||
|
||||
Gateway-Konfiguration:
|
||||
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
|
||||
```
|
||||
|
||||
**Ergebnis:**
|
||||
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
|
||||
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
|
||||
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
|
||||
|
||||
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
|
||||
```json5
|
||||
// openclaw.json
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan" // war "loopback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
|
||||
```yaml
|
||||
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
|
||||
```
|
||||
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
|
||||
|
||||
### 5.3 MCP-artige Integration: Bewertung
|
||||
|
||||
**Das Gateway `/tools/invoke` ist bereits MCP-artig:**
|
||||
- JSON-RPC-ähnliche Aufrufe mit `tool` + `args` + `sessionKey`
|
||||
- Strukturierte Responses mit `{ ok, result, error }`
|
||||
- Tool-Discovery via Policy (Deny/Allow-List)
|
||||
- Request/Response mit eindeutiger Fehlersemantik
|
||||
|
||||
**Was fehlt für ein vollständiges MCP-Interface:**
|
||||
- Keine Tool-Listing/Discovery über API (kein `tools/list`)
|
||||
- Keine Schema-Validierung für Tool-Arguments
|
||||
- Keine Structured Outputs (function-calling-ähnliches Format)
|
||||
|
||||
**Empfehlung: NICHT ein MCP-Protokoll zwischen Nexus und Gateway einführen.**
|
||||
Stattdessen den bestehenden `/tools/invoke`-Pfad weiter nutzen und strukturieren.
|
||||
|
||||
### 5.4 Neue Backend-Bridge (Implementiert 2026-06-22)
|
||||
|
||||
Der Nexus-eigene strukturierte Kommando-Adapter wurde eingeführt:
|
||||
|
||||
```
|
||||
Nexus Backend
|
||||
├─ GatewayToolClient (bestehender OpenClawGatewayClient)
|
||||
│ └─ POST /tools/invoke (Gateway)
|
||||
│
|
||||
├─ GatewayBridgeController (NEU — /api/bridge/)
|
||||
│ ├─ POST /api/bridge/tasks (create_task)
|
||||
│ ├─ POST /api/bridge/tasks/{id}/children (create_child_task)
|
||||
│ ├─ PATCH /api/bridge/tasks/{id}/status (update_status)
|
||||
│ ├─ POST /api/bridge/tasks/{id}/activity (append_activity)
|
||||
│ ├─ POST /api/bridge/tasks/{id}/handoff (handoff)
|
||||
│ ├─ GET /api/bridge/board (get_board)
|
||||
│ ├─ GET /api/bridge/tasks/{id} (get_task)
|
||||
│ ├─ GET /api/bridge/tasks/{id}/children (get_children)
|
||||
│ ├─ GET /api/bridge/tasks/{id}/activity (get_activity)
|
||||
│ └─ GET /api/bridge/agent-overview (get_agent_overview)
|
||||
│
|
||||
├─ ITaskBridgeService / TaskBridgeService (NEU)
|
||||
│ └─ Typisierte TaskBridgeResult<T> mit Outcome: Success/NotFound/InvalidState/Unauthorized/ValidationError
|
||||
│
|
||||
├─ BoardOrchestrationService (offen)
|
||||
│ ├─ Tasks erstellen/aktualisieren
|
||||
│ ├─ Session-Status abfragen
|
||||
│ └─ Agent-Progress berechnen
|
||||
│
|
||||
└─ AgentDelegationService (offen)
|
||||
├─ Subagent-Task anlegen
|
||||
├─ Session verfolgen
|
||||
└─ Ergebnis integrieren
|
||||
```
|
||||
|
||||
**Auth-Modell für /api/bridge:**
|
||||
- Primär: `X-Agent-Id` Header (Agent-Identität vom Gateway)
|
||||
- Fallback: JWT (Browser-authenticated user → bao)
|
||||
- Fallback: `X-Nexus-Api-Key` (Backend-zu-Backend Service-Identität)
|
||||
- Rate-Limiting: 30 Requests/Minute (agents-Policy)
|
||||
|
||||
**Das Frontend sieht /api/bridge NICHT.** Der Pfad ist ausschließlich für Agent-zu-Backend-Kommunikation.
|
||||
|
||||
## 6. Risikoanalyse
|
||||
|
||||
### 6.1 KRITISCH — `[AllowAnonymous]` auf Board-Endpunkten ✅ BEHOBEN (2026-06-22)
|
||||
|
||||
**Betroffen (vorher):**
|
||||
```csharp
|
||||
// TasksController.cs
|
||||
[AllowAnonymous]
|
||||
[HttpGet("board")] // Gab ALLE Tasks zurück — inkl. Detail-Texte
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("reset-stale")] // Konnte Tasks zurücksetzen — datenändernd
|
||||
```
|
||||
|
||||
**Fix angewandt:**
|
||||
- `[AllowAnonymous]` entfernt
|
||||
- Inline-Auth-Check: `X-Agent-Id` Header, ApiKey-Rolle, oder JWT erforderlich
|
||||
- `reset-stale` erfordert zusätzlich `X-Agent-Id: iris` Identität (nur Iris darf)
|
||||
- Neuer `/api/bridge/` Pfad als sauberer Agent-zu-Backend-Adapter
|
||||
- Rate-Limiting (agents-Policy: 30/min) auf Bridge-Endpunkte
|
||||
|
||||
### 6.2 MITTEL — Keine Task→Session-Verknüpfung
|
||||
|
||||
Wenn ein Subagent eine Child-Task bearbeitet, gibt es keine technische Verknüpfung zwischen
|
||||
der Child-Task und der OpenClaw-Session. Das bedeutet:
|
||||
- Keine automatische Status-Aktualisierung bei Session-Abschluss
|
||||
- Keine Sitzungs-Historie direkt von der Task aus erreichbar
|
||||
- Iris muss manuell prüfen, ob ein Agent fertig ist
|
||||
|
||||
**Empfehlung:**
|
||||
- `WorkTask` um `SessionKey` (string?) erweitern
|
||||
- Bei Child-Task-Erstellung Session-Key speichern
|
||||
- Status-Polling: Wenn Session inaktiv, Child-Task auf Done/Blocked prüfen
|
||||
|
||||
### 6.3 MITTEL — Gateway-Passwort in Config-Dateien
|
||||
|
||||
Das Gateway-Passwort `ieDmOjBiVfbbDM0ibrEebPAg` ist:
|
||||
- In `.env` auf dem Host (OK)
|
||||
- In `gateway-api-research.md` (maskiert: `ieDm...PAg`)
|
||||
- Im `openclaw.json` auf dem Host (OK)
|
||||
- In der API-Container-Umgebungsvariable (notwendig)
|
||||
|
||||
**Empfehlung:**
|
||||
- Gateway-Rate-Limiting aktiv halten (10 attempts/60s → 5min lockout)
|
||||
- Gateway-Bind auf `lan` ändern (nicht öffentlich exponiert)
|
||||
- `gateway-api-research.md` aus dem öffentlichen Repo entfernen oder Passwort entfernen
|
||||
|
||||
### 6.4 NIEDRIG — Keine Scoped API-Keys
|
||||
|
||||
Der `X-Nexus-Api-Key` gibt volle Service-Rechte. Es gibt keine Möglichkeit, verschiedene
|
||||
API-Keys mit unterschiedlichen Rechten zu vergeben.
|
||||
|
||||
**Empfehlung (spätere Phase):**
|
||||
- API-Key-Scopes einführen (read, write, admin)
|
||||
- Rate-Limiting pro API-Key
|
||||
- Audit-Log für ApiKey-Nutzung
|
||||
|
||||
### 6.5 NIEDRIG — Kein Structured Output vom Gateway
|
||||
|
||||
Die `/tools/invoke`-Responses sind JSON, aber ohne Schema-Garantie. Die Backend-Logik
|
||||
extrahiert Felder mit vielen Fallbacks (`??`-Kettenantworten).
|
||||
|
||||
**Empfehlung:**
|
||||
- Response-Typen für jedes Tool definieren (DTOs)
|
||||
- Deserialisierung mit Schema-Validierung
|
||||
- Fallback-Logik zentralisieren
|
||||
|
||||
## 7. Migrationsreihenfolge (Vorschlag)
|
||||
|
||||
### Phase 3b — Sichere Board-Grundlage (JETZT)
|
||||
```
|
||||
1. [AllowAnonymous] auf /tasks/board und /tasks/reset-stale fixen
|
||||
→ ApiKey-Auth plus X-Agent-Id-Validierung
|
||||
→ README/Iris-Doku aktualisieren
|
||||
|
||||
2. Gateway-Bind von loopback auf lan ändern
|
||||
→ API-Container über openclaw_default Netzwerk ansprechen
|
||||
→ host.docker.internal-Fallback entfernen
|
||||
```
|
||||
|
||||
### Phase 4 — Strukturierte Orchestrierung
|
||||
```
|
||||
3. Task→Session-Verknüpfung einführen
|
||||
→ WorkTask.SessionKey (string?)
|
||||
→ Bei Child-Task-Erstellung Session speichern
|
||||
|
||||
4. AgentDelegationService
|
||||
→ Zentralisierte Subagent-Task-Erstellung
|
||||
→ Session-Status-Monitoring
|
||||
→ Automatische Status-Propagation (Session done → Task done)
|
||||
|
||||
5. BoardOrchestrationService
|
||||
→ Refresh-Intervall für Agent-Progress
|
||||
→ Stale-Erkennung mit Session-Status
|
||||
→ Priorisierung nach Workload
|
||||
```
|
||||
|
||||
### Phase 5 — Erweiterte Integration
|
||||
```
|
||||
6. Structured Tool Responses
|
||||
→ DTOs für jedes Gateway-Tool
|
||||
→ Schema-Validierung
|
||||
→ Caching für häufige Abfragen
|
||||
|
||||
7. API-Key-Scopes
|
||||
→ read/write/admin Scopes
|
||||
→ Audit-Log für ApiKey-Nutzung
|
||||
|
||||
8. Automatische Child-Task-Erstellung
|
||||
→ Bei spawn/subagent-Aufrufen automatisch Child-Task anlegen
|
||||
→ Session-Key verknüpfen
|
||||
→ Activity-Feed erweitern
|
||||
```
|
||||
|
||||
## 8. Sichere Brücke: Architekturprinzipien
|
||||
|
||||
### 8.1 Das Backend ist die einzige Brücke
|
||||
|
||||
```
|
||||
Browser ←→ Nexus API ←→ OpenClaw Gateway
|
||||
↑ ↑
|
||||
JWT Auth Gateway Password
|
||||
(pro User) (nur im Backend)
|
||||
```
|
||||
|
||||
**Niemals:**
|
||||
- Gateway-Passwort im Frontend
|
||||
- Direkter Browser→Gateway API-Call
|
||||
- MCP-Protokoll zwischen Frontend und Gateway
|
||||
- Agent-Sessions direkt aus dem Frontend steuern
|
||||
|
||||
### 8.2 Prinzipien für jede neue Integration
|
||||
|
||||
1. **Neue Endpunkte immer im Nexus-Backend**
|
||||
2. **Auth über bestehendes JWT/ApiKey-System**
|
||||
3. **Gateway-Calls immer serverseitig mit Gateway-Passwort**
|
||||
4. **Kein Gateway-Tool direkt aus dem Frontend aufrufen**
|
||||
5. **State-Änderungen nur durch Iris/Bao (via Backend-Enforcement)**
|
||||
6. **Activity/Audit für jede State-Änderung**
|
||||
|
||||
### 8.3 Strukturierte OpenClaw-Integration (MCP-artig)
|
||||
|
||||
Der Gateway `/tools/invoke`-Endpunkt ist bereits strukturell MCP-artig. Eine formale
|
||||
MCP-Implementierung zwischen Nexus und Gateway ist **nicht notwendig**. Stattdessen:
|
||||
|
||||
```
|
||||
Nexus.Backend.Services
|
||||
├── IOpenClawGatewayClient (Gateway-Tool-Abstraktion)
|
||||
│ └── InvokeToolAsync(tool, args) → JsonNode?
|
||||
│
|
||||
├── IBoardOrchestrationService (NEU)
|
||||
│ ├── CreateDelegateTask(agentId, title, detail) → WorkTask
|
||||
│ ├── WatchAgentSession(agentId) → SessionWatcher
|
||||
│ └── SyncAgentProgress() → Progress[]
|
||||
│
|
||||
└── IAgentDelegationService (NEU)
|
||||
├── DelegateToAgent(parentTaskId, agentId, instruction)
|
||||
├── CollectResult(subTaskId) → AgentResult
|
||||
└── HandleBlocker(subTaskId, reason) → void
|
||||
```
|
||||
|
||||
## 9. Zusammenfassung
|
||||
|
||||
### Was gut ist (nicht ändern):
|
||||
- Board-first-Ansatz mit Parent/Child-Tasks
|
||||
- Backend als einzige Gateway-Brücke
|
||||
- JWT + Gateway-Password-Trennung
|
||||
- State-Change-Restriktion auf Iris/Bao
|
||||
- HTTP Deny-List des Gateways
|
||||
- CSP im Frontend (kein externer Connect)
|
||||
|
||||
### Was verbessert werden muss:
|
||||
- `[AllowAnonymous]` auf Board-Endpunkten → ApiKey-Auth
|
||||
- Gateway-Bind loopback → lan (oder Netzwerk-Routing korrigieren)
|
||||
- Task→Session-Verknüpfung fehlt
|
||||
|
||||
### Was später kommen kann:
|
||||
- Automatische Child-Task-Erstellung bei Subagent-Aufrufen
|
||||
- Structured Gateway Responses mit Schema-Validierung
|
||||
- API-Key-Scopes und Audit
|
||||
- Session-gesteuertes Progress-Tracking
|
||||
|
||||
### Was niemals kommen darf:
|
||||
- Gateway-Passwort im Frontend
|
||||
- Direkter MCP-Pfad Browser→Gateway
|
||||
- Agent-Session-Steuerung aus dem Frontend
|
||||
- Task-State-Änderung durch Sub-Agenten
|
||||
@@ -1,7 +1,12 @@
|
||||
# Gateway API Research
|
||||
|
||||
> Generated: 2026-06-10
|
||||
> Generated: 2026-06-10 | Updated: 2026-06-22
|
||||
> Auth mode: password (not token)
|
||||
>
|
||||
> ⚠️ **Security note:** Diese Datei enthält Infrastruktur-Details zur Gateway-Integration.
|
||||
> Sie gehört nicht ins öffentliche Repository. Bis zur Bereinigung: Gateway-Passwort
|
||||
> maskiert als `ieDm...PAg`. Vollständige Architektur-Analyse in
|
||||
> [`architecture-board-first-orchestration.md`](architecture-board-first-orchestration.md).
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
# OpenClaw ↔ Nexus Task Board Flow
|
||||
|
||||
> Letzte Aktualisierung: 2026-06-21
|
||||
> Status: kanonische Arbeitsbeschreibung für Iris, Sub-Agenten und das Nexus Task Board
|
||||
|
||||
Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
|
||||
|
||||
- **Bao** als Auftraggeber
|
||||
- **Iris** als Chief of Staff / Koordinatorin
|
||||
- **Sub-Agenten** als ausführende Spezialisten
|
||||
- **OpenClaw** als Agent-Runtime
|
||||
- **Nexus Task Board** als sichtbare Aufgabenquelle
|
||||
|
||||
---
|
||||
|
||||
## 1. Kurzfassung
|
||||
|
||||
**Eine Hauptaufgabe gehört Iris.**
|
||||
Wenn Iris Arbeit delegiert, wird diese Delegation **nicht unsichtbar im Chat** geführt, sondern als **sichtbare Child-Task** im Nexus Task Board angelegt.
|
||||
|
||||
Das bedeutet:
|
||||
|
||||
- **Parent-Task** = Verantwortung von Iris
|
||||
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
|
||||
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
|
||||
- **OpenClaw** = Ausführungspfad für Agentenarbeit
|
||||
|
||||
---
|
||||
|
||||
## 2. Die Hauptidee
|
||||
|
||||
Früher war Delegation leicht unsichtbar oder lief über einen separaten `Delegated`-Status.
|
||||
|
||||
Der neue Flow ersetzt das durch:
|
||||
|
||||
1. **Iris übernimmt eine Parent-Task**
|
||||
2. **Iris zerlegt die Arbeit bei Bedarf in Subtasks**
|
||||
3. **Jede echte Delegation wird als Child-Task auf dem Board angelegt**
|
||||
4. **Der zuständige Agent arbeitet gegen diese Child-Task**
|
||||
5. **Iris integriert die Ergebnisse zurück in die Parent-Task**
|
||||
6. **Erst wenn alles fertig ist, geht die Parent-Task in Review**
|
||||
|
||||
---
|
||||
|
||||
## 3. Systembild
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Bao[Bao\nAuftraggeber]
|
||||
Iris[Iris\nChief of Staff]
|
||||
Board[Nexus Task Board\nParent + Child Tasks]
|
||||
OC[OpenClaw Runtime]
|
||||
Agents[Sub-Agenten\nDeveloper / Reviewer / Architekt / ...]
|
||||
|
||||
Bao -->|Auftrag / Priorisierung| Iris
|
||||
Iris -->|legt Parent-Task an / übernimmt Task| Board
|
||||
Iris -->|delegiert konkrete Arbeit| OC
|
||||
OC -->|führt Agenten-Task aus| Agents
|
||||
Iris -->|legt Child-Tasks an| Board
|
||||
Agents -->|arbeiten gegen Child-Tasks| Board
|
||||
Agents -->|liefern Ergebnis / melden Blocker| Iris
|
||||
Iris -->|integriert Ergebnis| Board
|
||||
Board -->|Review für Bao| Bao
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollen und Verantwortlichkeiten
|
||||
|
||||
### Bao
|
||||
- gibt Aufgaben inhaltlich vor
|
||||
- priorisiert und nimmt fertige Arbeit ab
|
||||
- verschiebt fertige Hauptaufgaben aus **Review** nach **Done** oder zurück
|
||||
|
||||
### Iris
|
||||
- übernimmt die Parent-Task
|
||||
- analysiert, zerlegt, delegiert und reviewed
|
||||
- hält die Hauptaufgabe auf dem Board aktuell
|
||||
- erstellt sichtbare Child-Tasks für delegierte Arbeit
|
||||
- entscheidet, ob etwas **In Progress**, **Blocked** oder **Review** ist
|
||||
|
||||
### Sub-Agenten
|
||||
- arbeiten **nicht** direkt gegen eine diffuse Hauptaufgabe
|
||||
- arbeiten gegen eine **konkret zugewiesene Child-Task**
|
||||
- melden Fortschritt, Ergebnisse und Blocker an Iris
|
||||
|
||||
### OpenClaw
|
||||
- führt die Agentenarbeit technisch aus
|
||||
- liefert Nachrichten, Status und Arbeitsergebnisse zurück
|
||||
- ersetzt nicht das Board als Aufgabenwahrheit
|
||||
|
||||
### Nexus Task Board
|
||||
- ist die **sichtbare operative Quelle** für Aufgaben
|
||||
- zeigt Parent-Task, Child-Tasks, Ownership und Status
|
||||
- dokumentiert den tatsächlichen Arbeitsfluss
|
||||
|
||||
---
|
||||
|
||||
## 5. Parent-Task vs. Child-Task
|
||||
|
||||
| Ebene | Zweck | Owner | Sichtbarkeit |
|
||||
|---|---|---|---|
|
||||
| Parent-Task | Hauptauftrag / Koordination | Iris | Board |
|
||||
| Child-Task | Delegierter Arbeitsblock | zuständiger Agent | Board |
|
||||
|
||||
### Parent-Task-Regeln
|
||||
- bleibt bei Iris
|
||||
- bleibt in der Regel **In Progress**, solange Koordination läuft
|
||||
- geht erst auf **Review**, wenn alle nötigen Child-Tasks erledigt und integriert sind
|
||||
- geht nur auf **Blocked**, wenn Iris insgesamt nicht weiterkommt
|
||||
|
||||
### Child-Task-Regeln
|
||||
- repräsentiert eine echte delegierte Teilaufgabe
|
||||
- hat klare Ownership (`AssignedTo`)
|
||||
- zeigt sichtbar, welcher Agent woran arbeitet
|
||||
- wird nicht für triviale Mini-Schritte missbraucht
|
||||
|
||||
---
|
||||
|
||||
## 6. Zustandsmodell
|
||||
|
||||
### Parent-Task-Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Backlog
|
||||
Backlog --> InProgress: Iris übernimmt
|
||||
InProgress --> InProgress: Child-Tasks anlegen / koordinieren
|
||||
InProgress --> Blocked: Gesamtblocker
|
||||
InProgress --> Review: alles integriert
|
||||
Review --> Done: Bao nimmt ab
|
||||
Review --> Backlog: Bao gibt zurück
|
||||
Blocked --> Backlog: Blocker gelöst
|
||||
```
|
||||
|
||||
### Child-Task-Lifecycle
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Backlog
|
||||
Backlog --> InProgress: Agent startet
|
||||
InProgress --> Done: Ergebnis geliefert
|
||||
InProgress --> Blocked: Agent kommt nicht weiter
|
||||
Blocked --> Backlog: neu geplant / entsperrt
|
||||
Blocked --> InProgress: Iris stößt Weiterarbeit an
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Der konkrete Arbeitsablauf
|
||||
|
||||
### Fall A: Bao gibt Iris einen neuen Auftrag
|
||||
|
||||
1. Bao formuliert einen Auftrag
|
||||
2. Iris prüft Ziel, Scope, Risiko und Umgebung
|
||||
3. Iris übernimmt oder erstellt die **Parent-Task**
|
||||
4. Parent-Task geht auf **In Progress**
|
||||
5. Wenn nötig zerlegt Iris die Arbeit in **Child-Tasks**
|
||||
6. Child-Tasks werden passenden Agenten zugewiesen
|
||||
7. Agenten arbeiten die Child-Tasks ab
|
||||
8. Iris sammelt Ergebnisse ein und integriert sie
|
||||
9. Parent-Task geht auf **Review**
|
||||
10. Bao entscheidet: **Done** oder zurück nach **Backlog**
|
||||
|
||||
### Fall B: Agent meldet einen Blocker
|
||||
|
||||
1. Agent meldet Blocker an Iris
|
||||
2. Iris prüft, ob der Blocker lokal lösbar ist
|
||||
3. Wenn nein: die betroffene **Child-Task** geht auf **Blocked**
|
||||
4. Falls nötig entsteht eine neue Ursachen-Task / neue Child-Task
|
||||
5. Parent-Task bleibt **In Progress**, solange der Gesamtauftrag noch koordiniert wird
|
||||
6. Nur wenn die Hauptaufgabe insgesamt feststeckt, geht die **Parent-Task** auf **Blocked**
|
||||
|
||||
---
|
||||
|
||||
## 8. OpenClaw- und Board-Interaktion
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Bao
|
||||
participant Iris
|
||||
participant Board as Nexus Task Board
|
||||
participant OpenClaw
|
||||
participant Agent as Sub-Agent
|
||||
|
||||
Bao->>Iris: Auftrag
|
||||
Iris->>Board: Parent-Task übernehmen / anlegen
|
||||
Iris->>Board: Child-Task anlegen
|
||||
Iris->>OpenClaw: Agentenauftrag starten
|
||||
OpenClaw->>Agent: Task ausführen
|
||||
Agent-->>Iris: Ergebnis / Rückfrage / Blocker
|
||||
Iris->>Board: Child-Task aktualisieren
|
||||
Iris->>Board: Parent-Task integrieren
|
||||
Iris->>Board: Parent auf Review setzen
|
||||
Board-->>Bao: Review sichtbar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Regeln für gutes Schneiden von Child-Tasks
|
||||
|
||||
Eine Child-Task ist sinnvoll, wenn sie:
|
||||
|
||||
- einen **klaren Arbeitsblock** darstellt
|
||||
- einen **eigenen Verantwortlichen** hat
|
||||
- ein **eigenes Ergebnis** liefern soll
|
||||
- unabhängig als **Done** oder **Blocked** sichtbar sein kann
|
||||
|
||||
Keine gute Child-Task ist:
|
||||
|
||||
- „Datei öffnen"
|
||||
- „kurz nachschauen"
|
||||
- „eine Kleinigkeit prüfen"
|
||||
|
||||
Faustregel:
|
||||
|
||||
> **Eine Child-Task soll ein echter delegierbarer Arbeitsauftrag sein, kein Mikro-Schritt.**
|
||||
|
||||
---
|
||||
|
||||
## 10. Board-Sicht: was sichtbar sein soll
|
||||
|
||||
Im Board soll erkennbar sein:
|
||||
|
||||
- welche Parent-Task Iris gerade steuert
|
||||
- welche Child-Tasks darunter existieren
|
||||
- welcher Agent welche Child-Task besitzt
|
||||
- welche Child-Task blockiert ist
|
||||
- welche Parent-Task in Review auf Bao wartet
|
||||
|
||||
Im Task-Detail sollen sichtbar sein:
|
||||
|
||||
- Parent/Child-Beziehung
|
||||
- `AssignedTo`
|
||||
- Status
|
||||
- erwarteter nächster Beitrag / letzter Aktivitätshinweis
|
||||
- Child-Task-Liste direkt unter der Parent-Task
|
||||
|
||||
---
|
||||
|
||||
## 11. Kanonische Regeln
|
||||
|
||||
### Regel 1 — Das Board ist die sichtbare Aufgabenwahrheit
|
||||
Chat und Agentenläufe ergänzen das Board, ersetzen es aber nicht.
|
||||
|
||||
### Regel 2 — Iris bleibt Ownerin der Hauptaufgabe
|
||||
Delegation verschiebt Verantwortung nicht automatisch auf den Agenten.
|
||||
|
||||
### Regel 3 — Delegation ist sichtbar
|
||||
Jede echte delegierte Arbeit wird als Child-Task abgebildet.
|
||||
|
||||
### Regel 4 — Kein künstlicher Wartezustand auf Parent-Ebene
|
||||
Die Parent-Task bleibt **In Progress**, solange Iris aktiv koordiniert.
|
||||
|
||||
### Regel 5 — Blocker präzise markieren
|
||||
Wenn nur ein Arbeitspaket hängt, blockiert zuerst die **Child-Task**, nicht automatisch die ganze Parent-Task.
|
||||
|
||||
### Regel 6 — Review ist Bao-Gate
|
||||
Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Done** oder zurück.
|
||||
|
||||
---
|
||||
|
||||
## 12. Beispiel
|
||||
|
||||
### Parent-Task
|
||||
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
|
||||
|
||||
### Mögliche Child-Tasks
|
||||
- **Backend-State-Handling anpassen** — Owner: `developer`
|
||||
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer`
|
||||
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
|
||||
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
|
||||
|
||||
So sieht Bao später nicht nur „Iris arbeitet daran“, sondern konkret:
|
||||
|
||||
- welcher Teil erledigt ist
|
||||
- welcher Teil noch läuft
|
||||
- welcher Teil blockiert ist
|
||||
- worauf Iris gerade wartet
|
||||
|
||||
---
|
||||
|
||||
## 13. Anti-Patterns
|
||||
|
||||
Diese Muster sollen vermieden werden:
|
||||
|
||||
- Parent-Task auf einen bloßen **Delegated**-Wartestatus schieben
|
||||
- Delegation nur im Chat sichtbar machen
|
||||
- Child-Tasks ohne klare Ownership anlegen
|
||||
- Blocker nur mündlich erwähnen, aber nicht im Board markieren
|
||||
- zehn Mikro-Subtasks für einen Mini-Arbeitsschritt erzeugen
|
||||
|
||||
---
|
||||
|
||||
## 14. Entscheidungsregel für Iris
|
||||
|
||||
Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
|
||||
|
||||
**Child-Task anlegen**, wenn mindestens einer der Punkte zutrifft:
|
||||
|
||||
- anderer Agent übernimmt echte Arbeit
|
||||
- eigener Status muss sichtbar verfolgt werden
|
||||
- eigener Blocker ist möglich
|
||||
- Bao soll Transparenz über diesen Teil sehen
|
||||
|
||||
---
|
||||
|
||||
## 15. Technische Leitplanken
|
||||
|
||||
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
|
||||
- `AssignedTo` zeigt den operativen Owner
|
||||
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
|
||||
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
|
||||
- UI und Doku müssen dieselbe Sprache sprechen
|
||||
|
||||
---
|
||||
|
||||
## 16. Merksatz
|
||||
|
||||
> **Iris koordiniert die Hauptaufgabe. Agenten erledigen sichtbare Child-Tasks. Das Board zeigt die Wahrheit. OpenClaw führt aus.**
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,37 @@ server {
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# Bridge-Endpunkte (Agent-zu-Backend): separater Pfad ohne CSP-Einschränkungen
|
||||
# für Gateway-Calls. Kein Caching, kein Buffering.
|
||||
location /api/bridge/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Agent-Id $http_x_agent_id;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# Dashboard SSE stream: single dedicated non-buffered block.
|
||||
location = /api/dashboard/live {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 1h;
|
||||
proxy_send_timeout 1h;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Accel-Buffering no always;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
@@ -23,7 +23,7 @@ const activeView = computed(() => {
|
||||
const routePaths: Record<string, string> = {
|
||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
||||
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) => {
|
||||
@@ -32,7 +32,7 @@ const navigate = (label: string) => {
|
||||
}
|
||||
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(() => {
|
||||
if (auth.isAuthenticated) store.refresh()
|
||||
|
||||
@@ -87,6 +87,12 @@ const statusColors: Record<string, string> = {
|
||||
idle: 'var(--st-idle)',
|
||||
block: 'var(--st-block)',
|
||||
}
|
||||
|
||||
function avatarLabel() {
|
||||
if (props.agent.id === 'iris') return 'IR'
|
||||
if (props.agent.name === 'Full-Stack Developer') return '</>'
|
||||
return props.agent.name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -99,7 +105,7 @@ const statusColors: Record<string, string> = {
|
||||
<!-- Header -->
|
||||
<div class="m-head">
|
||||
<div :class="['m-av', { iris: agent.id === 'iris' }]">
|
||||
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}
|
||||
{{ avatarLabel() }}
|
||||
</div>
|
||||
<div style="flex:1; min-width:0">
|
||||
<div class="m-name">{{ agent.name }}</div>
|
||||
@@ -160,6 +166,16 @@ const statusColors: Record<string, string> = {
|
||||
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="agent.activity.length" class="m-sec">
|
||||
<h4>Agent Activity</h4>
|
||||
<div class="m-activity">
|
||||
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
|
||||
<span class="m-activity-time">{{ entry.time }}</span>
|
||||
<span class="m-activity-text">{{ entry.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modell wählen -->
|
||||
<div class="m-sec">
|
||||
<h4>Modell wählen</h4>
|
||||
@@ -519,6 +535,35 @@ const statusColors: Record<string, string> = {
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.m-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.m-activity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(124,108,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.m-activity-time {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.m-activity-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
/* ── Models ──────────────────────────────────── */
|
||||
.m-models {
|
||||
display: flex;
|
||||
|
||||
@@ -33,7 +33,11 @@ defineEmits<{
|
||||
{ entering }
|
||||
]"
|
||||
:style="{ left: left + '%', top: top + '%' }"
|
||||
@click="$emit('select', agent.id)"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="`${agent.name} öffnen`"
|
||||
@keydown.enter.prevent="$emit('select', agent.id)"
|
||||
@keydown.space.prevent="$emit('select', agent.id)"
|
||||
>
|
||||
<div class="ncard">
|
||||
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
||||
|
||||
@@ -19,6 +19,7 @@ defineProps<{
|
||||
blockerCount: number
|
||||
todayCost: string
|
||||
todayTokens: string
|
||||
blockerLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -62,7 +63,7 @@ defineEmits<{
|
||||
@click="$emit('blockerClick')"
|
||||
>
|
||||
<span class="dot block"></span>
|
||||
{{ blockerCount }} Blocker
|
||||
{{ blockerLabel || `${blockerCount} Blocker` }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -168,4 +169,24 @@ defineEmits<{
|
||||
.blk:hover {
|
||||
background: rgba(251,113,133,.22);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.alertbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
flex: 0 0 calc(50% - 4px);
|
||||
}
|
||||
|
||||
.sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blk {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
||||
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
||||
import { icons } from '../../../composables/icons'
|
||||
import AgentNode from './AgentNode.vue'
|
||||
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
|
||||
|
||||
const props = defineProps<{
|
||||
agents: AgentNodeData[]
|
||||
@@ -113,8 +114,8 @@ function renderEdges() {
|
||||
} else {
|
||||
// Orchestration (Iris → Agent)
|
||||
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
|
||||
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
|
||||
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -172,93 +173,19 @@ watch(
|
||||
)
|
||||
|
||||
/* ── Drag & Drop ──────────────────────────────── */
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
ox: number
|
||||
oy: number
|
||||
moved: boolean
|
||||
raf: number | null
|
||||
}
|
||||
|
||||
let drag: DragState | null = null
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
|
||||
if (!node) return
|
||||
|
||||
e.preventDefault()
|
||||
const nr = node.getBoundingClientRect()
|
||||
|
||||
drag = {
|
||||
id: node.dataset.id || '',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
ox: e.clientX - (nr.left + nr.width / 2),
|
||||
oy: e.clientY - (nr.top + nr.height / 2),
|
||||
moved: false,
|
||||
raf: null,
|
||||
}
|
||||
|
||||
node.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!drag) return
|
||||
|
||||
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
|
||||
if (!drag.moved && dist < DRAG_THRESHOLD) return
|
||||
|
||||
if (!drag.moved) {
|
||||
drag.moved = true
|
||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.add('dragging')
|
||||
}
|
||||
|
||||
const flow = flowRef.value
|
||||
if (!flow) return
|
||||
|
||||
const fr = flow.getBoundingClientRect()
|
||||
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
|
||||
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
|
||||
|
||||
// Direct DOM manipulation for responsiveness
|
||||
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) {
|
||||
node.style.left = x + '%'
|
||||
node.style.top = y + '%'
|
||||
}
|
||||
|
||||
// Update positions state
|
||||
const newPos = { ...props.positions }
|
||||
newPos[drag.id] = { x, y }
|
||||
emit('updatePositions', newPos)
|
||||
|
||||
// Debounced edge re-render
|
||||
if (!drag.raf) {
|
||||
drag.raf = requestAnimationFrame(() => {
|
||||
renderEdges()
|
||||
if (drag) drag.raf = null
|
||||
const {
|
||||
onClick,
|
||||
onClickCapture,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
} = useFlowCanvasInteractions({
|
||||
flowRef,
|
||||
renderEdges,
|
||||
updatePositions: positions => emit('updatePositions', positions),
|
||||
selectAgent: id => emit('select', id),
|
||||
getPositions: () => props.positions,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!drag) return
|
||||
|
||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.remove('dragging')
|
||||
|
||||
if (!drag.moved) {
|
||||
// Was a click — emit select
|
||||
emit('select', drag.id)
|
||||
}
|
||||
|
||||
drag = null
|
||||
}
|
||||
|
||||
/* ── Keyboard handler for Enter key on buttons ── */
|
||||
function handleReset() {
|
||||
@@ -271,6 +198,8 @@ function handleReset() {
|
||||
<div
|
||||
ref="flowRef"
|
||||
class="flow"
|
||||
@click="onClick"
|
||||
@click.capture="onClickCapture"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@@ -288,12 +217,12 @@ function handleReset() {
|
||||
@click="handleReset"
|
||||
>
|
||||
<span class="btn-icon" v-html="icons.flow || ''"></span>
|
||||
Reset
|
||||
<span class="reset-label">Reset</span>
|
||||
</button>
|
||||
|
||||
<button class="add-btn" @click="emit('add')">
|
||||
<button class="add-btn" @click="emit('add')" title="Agent hinzufügen">
|
||||
<span class="btn-icon" v-html="icons.plus || ''"></span>
|
||||
Agent hinzufügen
|
||||
<span class="add-label">Agent hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -481,4 +410,28 @@ function handleReset() {
|
||||
:deep(.node.dragging) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.add-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reset-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 34px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -251,6 +251,22 @@ watch(
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.iris-panel {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
max-height: 45vh;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Input ───────────────────────────────────── */
|
||||
.chat-in {
|
||||
padding: 12px;
|
||||
|
||||
@@ -146,4 +146,20 @@ function statusLabel(s: TaskItem['status']): string {
|
||||
padding: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.tstrip {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.tstrip::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tcard {
|
||||
flex: 0 0 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface TaskItem {
|
||||
priority: 'high' | 'medium' | 'low'
|
||||
status: 'active' | 'pending' | 'blocked'
|
||||
progress: number // 0–100
|
||||
detail?: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
/* ── Agent Detail Modal Types ─────────────────── */
|
||||
@@ -26,6 +28,11 @@ export interface ThinkingItem {
|
||||
ts: string
|
||||
}
|
||||
|
||||
export interface AgentActivityItem {
|
||||
time: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Dashboard view-model for an agent detail modal */
|
||||
export interface AgentDetailData {
|
||||
id: string
|
||||
@@ -51,5 +58,6 @@ export interface AgentDetailData {
|
||||
lastActive: string
|
||||
activeTaskCount: number
|
||||
thinking: ThinkingItem[]
|
||||
activity: AgentActivityItem[]
|
||||
availableModels: { id: string; alias: string }[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
const CLICK_SUPPRESSION_MS = 400
|
||||
|
||||
export interface FlowPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string
|
||||
startX: number
|
||||
startY: number
|
||||
ox: number
|
||||
oy: number
|
||||
moved: boolean
|
||||
raf: number | null
|
||||
}
|
||||
|
||||
interface UseFlowCanvasInteractionsOptions {
|
||||
flowRef: { value: HTMLElement | null }
|
||||
renderEdges: () => void
|
||||
updatePositions: (positions: Record<string, FlowPosition>) => void
|
||||
selectAgent: (id: string) => void
|
||||
getPositions: () => Record<string, FlowPosition>
|
||||
}
|
||||
|
||||
function findNode(target: EventTarget | null) {
|
||||
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
|
||||
}
|
||||
|
||||
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
|
||||
const drag = ref<DragState | null>(null)
|
||||
const suppressClickUntil = ref(0)
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const node = findNode(e.target)
|
||||
if (!node) return
|
||||
|
||||
e.preventDefault()
|
||||
const nr = node.getBoundingClientRect()
|
||||
|
||||
drag.value = {
|
||||
id: node.dataset.id || '',
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
ox: e.clientX - (nr.left + nr.width / 2),
|
||||
oy: e.clientY - (nr.top + nr.height / 2),
|
||||
moved: false,
|
||||
raf: null,
|
||||
}
|
||||
|
||||
node.setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!drag.value) return
|
||||
|
||||
const currentDrag = drag.value
|
||||
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
|
||||
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
|
||||
|
||||
if (!currentDrag.moved) {
|
||||
currentDrag.moved = true
|
||||
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.add('dragging')
|
||||
}
|
||||
|
||||
const flow = options.flowRef.value
|
||||
if (!flow) return
|
||||
|
||||
const fr = flow.getBoundingClientRect()
|
||||
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
|
||||
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
|
||||
|
||||
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||
if (node) {
|
||||
node.style.left = x + '%'
|
||||
node.style.top = y + '%'
|
||||
}
|
||||
|
||||
options.updatePositions({
|
||||
...options.getPositions(),
|
||||
[currentDrag.id]: { x, y },
|
||||
})
|
||||
|
||||
if (!currentDrag.raf) {
|
||||
currentDrag.raf = requestAnimationFrame(() => {
|
||||
options.renderEdges()
|
||||
if (drag.value) drag.value.raf = null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (!drag.value) return
|
||||
|
||||
const currentDrag = drag.value
|
||||
const endDistance = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
|
||||
const wasDragged = currentDrag.moved || endDistance >= DRAG_THRESHOLD
|
||||
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||
if (node) node.classList.remove('dragging')
|
||||
|
||||
if (wasDragged) {
|
||||
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
|
||||
}
|
||||
|
||||
drag.value = null
|
||||
}
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
const node = findNode(e.target)
|
||||
if (!node) return
|
||||
if (performance.now() < suppressClickUntil.value) return
|
||||
|
||||
const id = node.dataset.id
|
||||
if (id) options.selectAgent(id)
|
||||
}
|
||||
|
||||
function onClickCapture(e: MouseEvent) {
|
||||
if (performance.now() >= suppressClickUntil.value) return
|
||||
if (!findNode(e.target)) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return {
|
||||
onClick,
|
||||
onClickCapture,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
Activity, Bot, Boxes, Command, FileText,
|
||||
Activity, Bell, Bot, Boxes, Command, FileText,
|
||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||
Shield, SlidersHorizontal, Sparkles, BookOpen,
|
||||
AlertTriangle, Calendar,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useNotificationStore } from '../../stores/notifications'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,6 +24,11 @@ const emit = defineEmits<{
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
onMounted(() => {
|
||||
notificationStore.startPolling()
|
||||
})
|
||||
|
||||
const ownerInitials = computed(() =>
|
||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||
@@ -37,6 +43,7 @@ const navigation = [
|
||||
{ label: 'Task Board', icon: ListTodo },
|
||||
{ label: 'Incidents', icon: AlertTriangle },
|
||||
{ separator: true },
|
||||
{ label: 'Notifications', icon: Bell },
|
||||
{ label: 'Calendar', icon: Calendar },
|
||||
{ label: 'Agents', icon: Bot },
|
||||
{ label: 'Models', icon: SlidersHorizontal },
|
||||
@@ -76,6 +83,7 @@ async function logout() {
|
||||
<span>{{ item.label }}</span>
|
||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
@@ -154,6 +162,9 @@ async function logout() {
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav button i.badge-red {
|
||||
background: #e16e75;
|
||||
}
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
|
||||
@@ -6,6 +6,14 @@ import { useAgentStore } from '../../stores/agents'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { navigation, icons } from '../../composables/icons'
|
||||
import type { NavGroupDef } from '../../composables/icons'
|
||||
|
||||
defineProps<{
|
||||
mobileOpen?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
import NavGroup from './NavGroup.vue'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
@@ -63,7 +71,8 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<aside :class="['sidebar', { open: mobileOpen }]">
|
||||
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
|
||||
<!-- Brand -->
|
||||
<div class="side-top">
|
||||
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||
@@ -171,6 +180,54 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
background: rgba(124,108,255,.06);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 100vh;
|
||||
width: 280px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: rgba(124,108,255,.1);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.sidebar-close :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
|
||||
@@ -3,11 +3,19 @@ import { icons } from '../../composables/icons'
|
||||
|
||||
defineProps<{
|
||||
connected?: boolean
|
||||
statusLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-sidebar': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<!-- Hamburger (mobile only) -->
|
||||
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search">
|
||||
<span class="search-icon" v-html="icons.search || ''"></span>
|
||||
@@ -20,13 +28,13 @@ defineProps<{
|
||||
<!-- Status Pill -->
|
||||
<span :class="['pill', connected ? 'live' : 'preview']">
|
||||
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
||||
{{ connected ? 'OpenClaw verbunden' : 'Preview' }}
|
||||
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
|
||||
</span>
|
||||
|
||||
<!-- Ask Iris Button -->
|
||||
<button class="btn btn-primary">
|
||||
<button class="btn btn-primary ask-iris-btn">
|
||||
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
||||
Ask Iris
|
||||
<span class="ask-label">Ask Iris</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
@@ -138,4 +146,65 @@ defineProps<{
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
background: rgba(124,108,255,.1);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.hamburger :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ask-iris-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ask-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ask-iris-btn .btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ref } from 'vue'
|
||||
import { extraAgentPool } from './useFlowLayout'
|
||||
import type { AgentNodeData } from './useFlowLayout'
|
||||
|
||||
interface FlowBoardAgentStore {
|
||||
agents: AgentNodeData[]
|
||||
models: Array<{ id: string; alias: string }>
|
||||
changeModel: (agentId: string, modelId: string) => void
|
||||
selectAgent: (id: string | null) => void
|
||||
}
|
||||
|
||||
interface FlowBoardChatStore {
|
||||
sendMessage: (text: string) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'nexus-flow-positions'
|
||||
|
||||
function readStoredPositions() {
|
||||
if (typeof window === 'undefined') return {}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) as Record<string, { x: number; y: number }> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
|
||||
const agentPositions = ref<Record<string, { x: number; y: number }>>(readStoredPositions())
|
||||
const enteringIds = ref<string[]>([])
|
||||
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
||||
|
||||
function selectAgent(id: string) {
|
||||
agentStore.selectAgent(id)
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
agentStore.selectAgent(null)
|
||||
}
|
||||
|
||||
function changeModel(agentId: string, modelAlias: string) {
|
||||
const model = agentStore.models.find(m => m.alias === modelAlias)
|
||||
const modelId = model?.id ?? modelAlias
|
||||
agentStore.changeModel(agentId, modelId)
|
||||
}
|
||||
|
||||
function addAgent() {
|
||||
const next = localAgentPool.value.shift()
|
||||
if (!next) return
|
||||
|
||||
enteringIds.value = [...enteringIds.value, next.id]
|
||||
agentStore.agents.push(next)
|
||||
|
||||
window.setTimeout(() => {
|
||||
enteringIds.value = enteringIds.value.filter(id => id !== next.id)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
agentPositions.value = {}
|
||||
if (typeof window !== 'undefined') window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
function updatePositions(positions: Record<string, { x: number; y: number }>) {
|
||||
agentPositions.value = { ...positions }
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(agentPositions.value))
|
||||
}
|
||||
}
|
||||
|
||||
function sendChatMessage(text: string) {
|
||||
chatStore.sendMessage(text)
|
||||
}
|
||||
|
||||
return {
|
||||
addAgent,
|
||||
agentPositions,
|
||||
changeModel,
|
||||
closeAgent,
|
||||
enteringIds,
|
||||
resetLayout,
|
||||
selectAgent,
|
||||
sendChatMessage,
|
||||
updatePositions,
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,46 @@
|
||||
* NexusLayout — V2 Dashboard Shell
|
||||
* Flex row, 100vh, overflow hidden.
|
||||
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useAgentStore } from '../stores/agents'
|
||||
import { useDashboardStore } from '../stores/dashboard'
|
||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||
import Sidebar from '../components/layout/Sidebar.vue'
|
||||
import Topbar from '../components/layout/Topbar.vue'
|
||||
|
||||
const agentStore = useAgentStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
/* ── Mobile Sidebar State ───────────────────────── */
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nexus-layout">
|
||||
<GalaxyBackground />
|
||||
<Sidebar />
|
||||
<Sidebar
|
||||
:mobile-open="mobileMenuOpen"
|
||||
@close="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<!-- Mobile Backdrop -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="mobile-backdrop"
|
||||
@click="closeMobileMenu"
|
||||
></div>
|
||||
|
||||
<main class="nexus-main">
|
||||
<Topbar :connected="agentStore.isConnected" />
|
||||
<Topbar
|
||||
:connected="dashboardStore.isGatewayConnected"
|
||||
:status-label="dashboardStore.irisStatusLabel"
|
||||
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
||||
/>
|
||||
<div class="nexus-content">
|
||||
<RouterView />
|
||||
</div>
|
||||
@@ -49,4 +73,22 @@ const agentStore = useAgentStore()
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.nexus-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,9 @@ import IncidentsView from './views/IncidentsView.vue'
|
||||
import CalendarView from './views/CalendarView.vue'
|
||||
import NexusLayout from './layouts/NexusLayout.vue'
|
||||
import FlowBoard from './views/Dashboard/FlowBoard.vue'
|
||||
import TaskBoardView from './views/TaskBoardView.vue'
|
||||
import TaskDetailView from './views/TaskDetailView.vue'
|
||||
import NotificationsView from './views/NotificationsView.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||
@@ -33,11 +36,13 @@ const routes = [
|
||||
{ path: '/calendar', name: 'Calendar', component: CalendarView },
|
||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||
{ path: '/tasks', name: 'Task Board', component: { template: '' } },
|
||||
{ path: '/tasks', name: 'Task Board', component: TaskBoardView },
|
||||
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView },
|
||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
||||
{ path: '/notifications', name: 'Notifications', component: NotificationsView },
|
||||
{ path: '/settings', name: 'Settings', component: SettingsView },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
@@ -7,6 +7,13 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
|
||||
const send = () => {
|
||||
const headers = new Headers(init.headers)
|
||||
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
|
||||
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
|
||||
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
|
||||
// Set Content-Type for JSON body requests — needed because fetch() defaults
|
||||
// to text/plain for string bodies, which ASP.NET rejects for [FromBody] binding.
|
||||
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
return fetch(input, { ...init, headers, credentials: 'include' })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { apiFetch } from './api'
|
||||
|
||||
export type LiveEventName = 'snapshot' | 'update' | 'heartbeat'
|
||||
export type LiveMode = 'live' | 'polling'
|
||||
|
||||
export interface LiveCursorDto {
|
||||
sequence: number
|
||||
timestamp: string
|
||||
mode: LiveMode
|
||||
}
|
||||
|
||||
export interface LiveUpdateEnvelope {
|
||||
type: string
|
||||
timestamp: string
|
||||
payload: unknown
|
||||
sequence: number
|
||||
channel: string
|
||||
}
|
||||
|
||||
export interface DashboardLiveEventDto {
|
||||
envelope: LiveUpdateEnvelope
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
export interface OpenDashboardLiveStreamResult {
|
||||
closed: Promise<void>
|
||||
}
|
||||
|
||||
export async function openDashboardLiveStream(
|
||||
onEvent: (event: LiveEventName, data: unknown) => void,
|
||||
options: { forUser?: string; notificationLimit?: number; afterSequence?: number | null; signal?: AbortSignal } = {},
|
||||
): Promise<OpenDashboardLiveStreamResult> {
|
||||
const params = new URLSearchParams({
|
||||
forUser: options.forUser ?? 'bao',
|
||||
notificationLimit: String(options.notificationLimit ?? 50),
|
||||
})
|
||||
if (typeof options.afterSequence === 'number' && Number.isFinite(options.afterSequence) && options.afterSequence > 0) {
|
||||
params.set('afterSequence', String(options.afterSequence))
|
||||
}
|
||||
|
||||
const response = await apiFetch(`/api/dashboard/live?${params}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' },
|
||||
signal: options.signal,
|
||||
})
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Live stream unavailable: HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const flushBlock = (block: string) => {
|
||||
const lines = block.split('\n')
|
||||
let eventName: LiveEventName = 'update'
|
||||
const dataLines: string[] = []
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd()
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim() as LiveEventName
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
|
||||
if (!dataLines.length) return
|
||||
try {
|
||||
onEvent(eventName, JSON.parse(dataLines.join('\n')))
|
||||
} catch (error) {
|
||||
console.warn('[live] failed to parse event payload', error)
|
||||
}
|
||||
}
|
||||
|
||||
const closed = (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const parts = buffer.split('\n\n')
|
||||
buffer = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
if (part.trim()) flushBlock(part)
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
flushBlock(buffer)
|
||||
buffer = ''
|
||||
}
|
||||
})()
|
||||
|
||||
return { closed }
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiFetch } from '../services/api'
|
||||
import type { AgentNodeData } from '../composables/useFlowLayout'
|
||||
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
|
||||
import type { AgentActivityItem, AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
|
||||
|
||||
/* ── API Response Shapes ──────────────────────────── */
|
||||
|
||||
@@ -40,6 +40,11 @@ interface ModelOption {
|
||||
provider: string
|
||||
}
|
||||
|
||||
interface AgentActivityEntry {
|
||||
time: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/* ── Status Mapping ───────────────────────────────── */
|
||||
|
||||
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
|
||||
@@ -148,6 +153,7 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
|
||||
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
|
||||
activeTaskCount: data.task ? 1 : 0,
|
||||
thinking: buildThinkingItems(data),
|
||||
activity: [],
|
||||
availableModels: models,
|
||||
}
|
||||
}
|
||||
@@ -159,6 +165,7 @@ export const useAgentStore = defineStore('agents', {
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
selectedAgentId: null as string | null,
|
||||
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
|
||||
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
isConnected: false,
|
||||
}),
|
||||
@@ -179,7 +186,10 @@ export const useAgentStore = defineStore('agents', {
|
||||
if (!state.selectedAgentId) return null
|
||||
const data = state.agents.find(a => a.id === state.selectedAgentId)
|
||||
if (!data) return null
|
||||
return buildAgentDetail(data, state.models)
|
||||
return {
|
||||
...buildAgentDetail(data, state.models),
|
||||
activity: state.activityByAgentId[data.id] ?? [],
|
||||
}
|
||||
},
|
||||
|
||||
/** Is the modal open? */
|
||||
@@ -249,9 +259,24 @@ export const useAgentStore = defineStore('agents', {
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAgentActivity(agentId: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/activity?limit=5`)
|
||||
if (!res.ok) return
|
||||
const data: AgentActivityEntry[] = await res.json()
|
||||
this.activityByAgentId[agentId] = data.map(entry => ({
|
||||
time: entry.time,
|
||||
text: entry.text,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('[AgentStore] fetchAgentActivity failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Selection ───────────────────────────────── */
|
||||
selectAgent(id: string | null) {
|
||||
this.selectedAgentId = id
|
||||
if (id) void this.fetchAgentActivity(id)
|
||||
},
|
||||
|
||||
/* ── Polling ─────────────────────────────────── */
|
||||
|
||||
@@ -13,6 +13,12 @@ interface AuthPayload {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
interface LoginErrorInfo {
|
||||
message: string
|
||||
remaining: number
|
||||
retryAfterSeconds: number
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<boolean> | null = null
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -22,28 +28,51 @@ export const useAuthStore = defineStore('auth', {
|
||||
user: null as AuthUser | null,
|
||||
initialized: false,
|
||||
loading: false,
|
||||
/** Remaining login attempts in the current window (null = unknown) */
|
||||
remainingAttempts: null as number | null,
|
||||
/** Seconds until rate-limit reset (0 = not rate-limited) */
|
||||
retryAfterSeconds: 0,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: state => Boolean(state.accessToken && state.user),
|
||||
isRateLimited: state => state.remainingAttempts === 0 && state.retryAfterSeconds > 0,
|
||||
/** Returns true if the current web-ui user is Iris (JWT user identity matches "iris"). */
|
||||
isIris: state => {
|
||||
if (!state.user) return false
|
||||
const lower = state.user.email.toLowerCase()
|
||||
return lower.includes('iris') || state.user.displayName.toLowerCase().includes('iris')
|
||||
},
|
||||
/** Returns true if the current web-ui user is Bao (JWT user identity matches "bao"). */
|
||||
isBao: state => {
|
||||
if (!state.user) return false
|
||||
const lower = state.user.email.toLowerCase()
|
||||
return lower.includes('bao') || state.user.displayName.toLowerCase().includes('bao')
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
applySession(payload: AuthPayload) {
|
||||
this.accessToken = payload.accessToken
|
||||
this.expiresAt = payload.expiresAt
|
||||
this.user = payload.user
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
},
|
||||
clearSession() {
|
||||
this.accessToken = null
|
||||
this.expiresAt = null
|
||||
this.user = null
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
},
|
||||
async initialize() {
|
||||
if (this.initialized) return this.isAuthenticated
|
||||
this.initialized = true
|
||||
return this.refresh()
|
||||
},
|
||||
async login(email: string, password: string) {
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
this.loading = true
|
||||
this.remainingAttempts = null
|
||||
this.retryAfterSeconds = 0
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
@@ -52,9 +81,50 @@ export const useAuthStore = defineStore('auth', {
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
// Try to parse remaining from headers
|
||||
const remainingHeader = response.headers.get('X-RateLimit-Remaining')
|
||||
if (remainingHeader !== null) {
|
||||
this.remainingAttempts = parseInt(remainingHeader, 10)
|
||||
}
|
||||
|
||||
const resetHeader = response.headers.get('X-RateLimit-Reset')
|
||||
if (resetHeader !== null) {
|
||||
const resetTs = parseInt(resetHeader, 10) * 1000
|
||||
this.retryAfterSeconds = Math.max(0, Math.ceil((resetTs - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.')
|
||||
throw new Error('Invalid email or password.')
|
||||
// Try to parse structured JSON body for rate-limit info
|
||||
let remaining = this.remainingAttempts
|
||||
let retryAfter = this.retryAfterSeconds
|
||||
|
||||
try {
|
||||
const body = await response.json() as Record<string, unknown>
|
||||
if (typeof body.remaining === 'number') remaining = body.remaining
|
||||
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
|
||||
|
||||
if (response.status === 429) {
|
||||
this.remainingAttempts = 0
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter)
|
||||
} else if (response.status === 401) {
|
||||
this.remainingAttempts = remaining
|
||||
this.retryAfterSeconds = retryAfter
|
||||
throw new LoginError(body.message as string || 'Invalid email or password.', remaining, retryAfter)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LoginError) throw error
|
||||
// Fallback for non-JSON error responses
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
this.remainingAttempts = 0
|
||||
const retryAfterSec = this.retryAfterSeconds || 60
|
||||
this.retryAfterSeconds = retryAfterSec
|
||||
throw new LoginError('Too many attempts. Please wait.', 0, retryAfterSec)
|
||||
}
|
||||
|
||||
throw new LoginError('Invalid email or password.', this.remainingAttempts ?? 4, this.retryAfterSeconds)
|
||||
}
|
||||
|
||||
this.applySession(await response.json() as AuthPayload)
|
||||
@@ -101,3 +171,16 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/** Custom error carrying rate-limit metadata. */
|
||||
class LoginError extends Error {
|
||||
remaining: number
|
||||
retryAfterSeconds: number
|
||||
|
||||
constructor(message: string, remaining: number, retryAfterSeconds: number) {
|
||||
super(message)
|
||||
this.name = 'LoginError'
|
||||
this.remaining = remaining
|
||||
this.retryAfterSeconds = retryAfterSeconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
interface DashboardStatusDto {
|
||||
gatewayOk: boolean
|
||||
irisStatus: string
|
||||
activeAgents: number
|
||||
pendingTasks: number
|
||||
}
|
||||
|
||||
interface FeedEntryDto {
|
||||
agent: string
|
||||
action: string
|
||||
timestamp: string
|
||||
time: string
|
||||
agentId?: string | null
|
||||
type?: string | null
|
||||
}
|
||||
|
||||
interface QueueItemDto {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
priority: string
|
||||
source: string
|
||||
waitTime: string
|
||||
}
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', {
|
||||
state: () => ({
|
||||
status: null as DashboardStatusDto | null,
|
||||
operations: [] as FeedEntryDto[],
|
||||
queue: [] as QueueItemDto[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isGatewayConnected: state => state.status?.gatewayOk ?? false,
|
||||
irisStatusLabel: state => state.status?.irisStatus ?? 'Offline',
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchStatus() {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/status')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.status = await res.json()
|
||||
} catch (err) {
|
||||
console.warn('[DashboardStore] fetchStatus failed', err)
|
||||
this.status = null
|
||||
}
|
||||
},
|
||||
|
||||
async fetchOperations() {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/operations?limit=20')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.operations = await res.json()
|
||||
} catch (err) {
|
||||
console.warn('[DashboardStore] fetchOperations failed', err)
|
||||
this.operations = []
|
||||
}
|
||||
},
|
||||
|
||||
async fetchQueue() {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/queue')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.queue = await res.json()
|
||||
} catch (err) {
|
||||
console.warn('[DashboardStore] fetchQueue failed', err)
|
||||
this.queue = []
|
||||
}
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
try {
|
||||
await Promise.all([
|
||||
this.fetchStatus(),
|
||||
this.fetchOperations(),
|
||||
this.fetchQueue(),
|
||||
])
|
||||
this.error = null
|
||||
} catch (err) {
|
||||
console.warn('[DashboardStore] refresh failed', err)
|
||||
this.error = 'Dashboard metadata could not be loaded'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
startPolling() {
|
||||
if (this.refreshInterval) return
|
||||
this.refresh()
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.refresh()
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval)
|
||||
this.refreshInterval = null
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||
import type { NotificationItem } from './notifications'
|
||||
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||
import { useTaskStore } from './tasks'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||
|
||||
interface NotificationSnapshotDto {
|
||||
notifications: NotificationItem[]
|
||||
unreadCount: number
|
||||
forUser: string
|
||||
}
|
||||
|
||||
interface DashboardLiveSnapshotDto {
|
||||
board: BoardGroup
|
||||
notifications: NotificationSnapshotDto
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||
const v = value as BoardGroup
|
||||
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||
}
|
||||
|
||||
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||
}
|
||||
|
||||
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
agent: t.assignedTo ?? '—',
|
||||
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||
detail: t.detail,
|
||||
source: t.source,
|
||||
}
|
||||
}
|
||||
|
||||
export const useLiveSyncStore = defineStore('liveSync', {
|
||||
state: () => ({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
lastEventAt: null as string | null,
|
||||
lastHeartbeatAt: null as string | null,
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
liveIndicatorLabel: (state) => {
|
||||
if (state.connecting) return 'Verbinde…'
|
||||
if (state.connected) return `Live · #${state.lastSequence}`
|
||||
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||
},
|
||||
connectionHealth: (state) => {
|
||||
if (state.connected) return 'healthy'
|
||||
if (state.connecting) return 'connecting'
|
||||
return 'degraded'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async connect(forUser = 'bao') {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.controller = new AbortController()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
this.lastEventAt = new Date().toISOString()
|
||||
|
||||
if (event === 'heartbeat') {
|
||||
const cursor = data as LiveCursorDto
|
||||
this.lastHeartbeatAt = cursor.timestamp
|
||||
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'snapshot') {
|
||||
const snapshot = data as DashboardLiveSnapshotDto
|
||||
taskStore.board = snapshot.board
|
||||
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||
notificationStore.notifications = snapshot.notifications.notifications
|
||||
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||
this.lastSequence = snapshot.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const eventDto = data as DashboardLiveEventDto
|
||||
this.applyEnvelope(eventDto.envelope, forUser)
|
||||
this.lastSequence = eventDto.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||
taskStore.board = envelope.payload
|
||||
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'notifications.snapshot') {
|
||||
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||
if (snapshot.forUser !== forUser) return
|
||||
notificationStore.notifications = snapshot.notifications
|
||||
notificationStore.unreadCount = snapshot.unreadCount
|
||||
}
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.mode = 'polling'
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(forUser)
|
||||
}, delay)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||
import type { NotificationItem } from './notifications'
|
||||
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||
import { useTaskStore } from './tasks'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||
|
||||
interface NotificationSnapshotDto {
|
||||
notifications: NotificationItem[]
|
||||
unreadCount: number
|
||||
forUser: string
|
||||
}
|
||||
|
||||
interface DashboardLiveSnapshotDto {
|
||||
board: BoardGroup
|
||||
notifications: NotificationSnapshotDto
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||
const v = value as BoardGroup
|
||||
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||
}
|
||||
|
||||
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||
}
|
||||
|
||||
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
agent: t.assignedTo ?? '—',
|
||||
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||
detail: t.detail,
|
||||
source: t.source,
|
||||
}
|
||||
}
|
||||
|
||||
export const useLiveSyncStore = defineStore('liveSync', {
|
||||
state: () => ({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
lastEventAt: null as string | null,
|
||||
lastHeartbeatAt: null as string | null,
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
liveIndicatorLabel: (state) => {
|
||||
if (state.connecting) return 'Verbinde…'
|
||||
if (state.connected) return `Live · #${state.lastSequence}`
|
||||
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||
},
|
||||
connectionHealth: (state) => {
|
||||
if (state.connected) return 'healthy'
|
||||
if (state.connecting) return 'connecting'
|
||||
return 'degraded'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async connect(forUser = 'bao') {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.controller = new AbortController()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
this.lastEventAt = new Date().toISOString()
|
||||
|
||||
if (event === 'heartbeat') {
|
||||
const cursor = data as LiveCursorDto
|
||||
this.lastHeartbeatAt = cursor.timestamp
|
||||
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'snapshot') {
|
||||
const snapshot = data as DashboardLiveSnapshotDto
|
||||
taskStore.board = snapshot.board
|
||||
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||
notificationStore.notifications = snapshot.notifications.notifications
|
||||
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||
this.lastSequence = snapshot.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const eventDto = data as DashboardLiveEventDto
|
||||
this.applyEnvelope(eventDto.envelope, forUser)
|
||||
this.lastSequence = eventDto.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||
taskStore.board = envelope.payload
|
||||
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'notifications.snapshot') {
|
||||
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||
if (snapshot.forUser !== forUser) return
|
||||
notificationStore.notifications = snapshot.notifications
|
||||
notificationStore.unreadCount = snapshot.unreadCount
|
||||
}
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.mode = 'polling'
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(forUser)
|
||||
}, delay)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Notification Store – Polls unread count and notifications from the API.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string
|
||||
type: string // "task_assigned", "task_review", "task_blocked"
|
||||
title: string
|
||||
message: string | null
|
||||
forUser: string
|
||||
taskId: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
count: number
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notifications', {
|
||||
state: () => ({
|
||||
notifications: [] as NotificationItem[],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
countRefreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
listRefreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchNotifications(forUser = 'bao', limit = 50, unreadOnly = false) {
|
||||
this.loading = true
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser, limit: String(limit), unreadOnly: String(unreadOnly) })
|
||||
const res = await apiFetch(`/api/dashboard/notifications?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.notifications = await res.json()
|
||||
this.error = null
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] fetchNotifications failed', err)
|
||||
this.error = 'Notifications could not be loaded'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUnreadCount(forUser = 'bao') {
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser })
|
||||
const res = await apiFetch(`/api/dashboard/notifications/unread-count?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: UnreadCount = await res.json()
|
||||
this.unreadCount = data.count
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] fetchUnreadCount failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
async markAsRead(id: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/notifications/${id}/read`, { method: 'PATCH' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
// Update local state
|
||||
const n = this.notifications.find(n => n.id === id)
|
||||
if (n) n.isRead = true
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] markAsRead failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
async markAllAsRead(forUser = 'bao') {
|
||||
try {
|
||||
const params = new URLSearchParams({ forUser })
|
||||
const res = await apiFetch(`/api/dashboard/notifications/read-all?${params}`, { method: 'PATCH' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
this.notifications.forEach(n => { n.isRead = true })
|
||||
this.unreadCount = 0
|
||||
} catch (err) {
|
||||
console.warn('[NotificationStore] markAllAsRead failed', err)
|
||||
}
|
||||
},
|
||||
|
||||
startPolling(forUser = 'bao') {
|
||||
if (!this.countRefreshInterval) {
|
||||
this.fetchUnreadCount(forUser)
|
||||
this.countRefreshInterval = setInterval(() => {
|
||||
this.fetchUnreadCount(forUser)
|
||||
}, 30000)
|
||||
}
|
||||
},
|
||||
|
||||
stopPolling() {
|
||||
if (this.countRefreshInterval) {
|
||||
clearInterval(this.countRefreshInterval)
|
||||
this.countRefreshInterval = null
|
||||
}
|
||||
if (this.listRefreshInterval) {
|
||||
clearInterval(this.listRefreshInterval)
|
||||
this.listRefreshInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
startListPolling(forUser = 'bao') {
|
||||
if (!this.listRefreshInterval) {
|
||||
this.fetchNotifications(forUser)
|
||||
this.listRefreshInterval = setInterval(() => {
|
||||
this.fetchNotifications(forUser)
|
||||
}, 30000)
|
||||
}
|
||||
},
|
||||
|
||||
stopListPolling() {
|
||||
if (this.listRefreshInterval) {
|
||||
clearInterval(this.listRefreshInterval)
|
||||
this.listRefreshInterval = null
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Task Store – V2 Dashboard
|
||||
* Task Store – V2 Dashboard + Task Board
|
||||
*
|
||||
* Fetches tasks from /api/dashboard/tasks and maps them into
|
||||
* TaskItem[] format for the TaskStrip component.
|
||||
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
|
||||
* and maps them into TaskItem[] format for the TaskStrip component.
|
||||
*
|
||||
* Board state: grouped by column (offen, inProgress, review, blocked, done)
|
||||
* Auto-refresh: every 30 seconds.
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -12,7 +13,7 @@ import type { TaskItem } from '../components/dashboard/v2/types'
|
||||
|
||||
/* ── API Response Shapes ──────────────────────────── */
|
||||
|
||||
interface DashboardTaskDto {
|
||||
export interface DashboardTaskDto {
|
||||
id: string
|
||||
title: string
|
||||
detail: string | null
|
||||
@@ -20,8 +21,30 @@ interface DashboardTaskDto {
|
||||
state: string
|
||||
priority: string
|
||||
assignedTo: string | null
|
||||
parentTaskId?: string | null
|
||||
dueDate?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
isAgentTask?: boolean
|
||||
expectedFrom?: string | null
|
||||
lastActivityMessage?: string | null
|
||||
lastActivityAt?: string | null
|
||||
}
|
||||
|
||||
export interface BoardGroup {
|
||||
offen: DashboardTaskDto[]
|
||||
inProgress: DashboardTaskDto[]
|
||||
review: DashboardTaskDto[]
|
||||
blocked: DashboardTaskDto[]
|
||||
done: DashboardTaskDto[]
|
||||
}
|
||||
|
||||
export interface AgentWorkflowOverview {
|
||||
waitingForBao: DashboardTaskDto[]
|
||||
waitingForIris: DashboardTaskDto[]
|
||||
waitingForOthers: DashboardTaskDto[]
|
||||
staleTasks: DashboardTaskDto[]
|
||||
staleThreshold: string
|
||||
}
|
||||
|
||||
/* ── State Mapping ────────────────────────────────── */
|
||||
@@ -56,6 +79,8 @@ function mapTask(t: DashboardTaskDto): TaskItem {
|
||||
priority: mapPriority(t.priority),
|
||||
status: mapState(t.state),
|
||||
progress: mapProgress(t.state),
|
||||
detail: t.detail,
|
||||
source: t.source,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,14 +90,44 @@ export const useTaskStore = defineStore('tasks', {
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
boardRefreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||
|
||||
// Board state
|
||||
board: {
|
||||
offen: [] as DashboardTaskDto[],
|
||||
inProgress: [] as DashboardTaskDto[],
|
||||
review: [] as DashboardTaskDto[],
|
||||
blocked: [] as DashboardTaskDto[],
|
||||
done: [] as DashboardTaskDto[],
|
||||
} as BoardGroup,
|
||||
boardLoading: false,
|
||||
boardError: null as string | null,
|
||||
|
||||
// Agent Workflow Overview (for Iris)
|
||||
agentOverview: null as AgentWorkflowOverview | null,
|
||||
agentOverviewLoading: false,
|
||||
agentOverviewError: null as string | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
taskList: (state) => state.tasks,
|
||||
|
||||
// Iris helpers
|
||||
waitingForIrisTasks: (state) => state.agentOverview?.waitingForIris ?? [],
|
||||
waitingForBaoTasks: (state) => state.agentOverview?.waitingForBao ?? [],
|
||||
waitingForOthersTasks: (state) => state.agentOverview?.waitingForOthers ?? [],
|
||||
staleTasksList: (state) => state.agentOverview?.staleTasks ?? [],
|
||||
agentTaskCount: (state) => {
|
||||
if (!state.agentOverview) return 0
|
||||
return state.agentOverview.waitingForBao.length +
|
||||
state.agentOverview.waitingForIris.length +
|
||||
state.agentOverview.waitingForOthers.length +
|
||||
state.agentOverview.staleTasks.length
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/* ── API: Fetch tasks ───────────────────────── */
|
||||
/* ── API: Fetch tasks (for TaskStrip) ─────────── */
|
||||
async fetchTasks() {
|
||||
this.loading = true
|
||||
try {
|
||||
@@ -89,7 +144,97 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Add task ──────────────────────────── */
|
||||
/* ── API: Fetch board (for TaskBoardView) ─────── */
|
||||
async fetchBoard() {
|
||||
this.boardLoading = true
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks/board')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: BoardGroup = await res.json()
|
||||
this.board = data
|
||||
this.boardError = null
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchBoard failed', err)
|
||||
this.boardError = 'Board could not be loaded'
|
||||
} finally {
|
||||
this.boardLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Move task (Drag & Drop) ─────────────── */
|
||||
async moveTask(id: string, newState: string) {
|
||||
// Map board group key to canonical state string for the API payload
|
||||
const canonicalMap: Record<string, string> = {
|
||||
offen: 'Backlog',
|
||||
inProgress: 'In progress',
|
||||
review: 'Review',
|
||||
blocked: 'Blocked',
|
||||
done: 'Done',
|
||||
}
|
||||
|
||||
// Save previous state for rollback
|
||||
const prevBoard = JSON.parse(JSON.stringify(this.board)) as BoardGroup
|
||||
|
||||
// Optimistic: find the task in current board and move it
|
||||
const findAndRemove = (arr: DashboardTaskDto[]): DashboardTaskDto | null => {
|
||||
const idx = arr.findIndex(t => t.id === id)
|
||||
if (idx === -1) return null
|
||||
return arr.splice(idx, 1)[0]
|
||||
}
|
||||
|
||||
const task =
|
||||
findAndRemove(this.board.offen) ??
|
||||
findAndRemove(this.board.inProgress) ??
|
||||
findAndRemove(this.board.review) ??
|
||||
findAndRemove(this.board.blocked) ??
|
||||
findAndRemove(this.board.done)
|
||||
|
||||
if (task) {
|
||||
const canonicalState = canonicalMap[newState] ?? newState
|
||||
task.state = canonicalState
|
||||
const targetKey = newState as keyof BoardGroup
|
||||
if (this.board[targetKey]) {
|
||||
this.board[targetKey].push(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Actually call API with the board group key (backend handles mapping)
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/move`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ state: newState }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] moveTask failed, rolling back', err)
|
||||
this.board = prevBoard
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Create task ─────────────────────────── */
|
||||
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
detail: data.detail ?? null,
|
||||
priority: data.priority ?? 'Medium',
|
||||
assignedTo: data.assignedTo ?? 'bao',
|
||||
source: 'bao',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
// Refresh board + task list
|
||||
await this.fetchBoard()
|
||||
await this.fetchTasks()
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] createTask failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Add task (for TaskStrip) ────────────── */
|
||||
async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks', {
|
||||
@@ -110,8 +255,8 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Update task ───────────────────────── */
|
||||
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) {
|
||||
/* ── API: Update task ─────────────────────────── */
|
||||
async updateTask(id: string, updates: { title?: string; detail?: string | null; priority?: string; assignedTo?: string | null; dueDate?: string | null }) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
|
||||
method: 'PUT',
|
||||
@@ -119,12 +264,86 @@ export const useTaskStore = defineStore('tasks', {
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
await this.fetchTasks()
|
||||
await this.fetchBoard()
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] updateTask failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Polling ─────────────────────────────────── */
|
||||
async fetchTaskChildren(id: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/children`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json() as DashboardTaskDto[]
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchTaskChildren failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTaskActivity(id: string) {
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json() as Array<{ id?: string; message?: string; type?: string; createdAt?: string; timestamp?: string }>
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchTaskActivity failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Fetch agent workflow overview ──────── */
|
||||
async fetchAgentOverview(staleHours = 2) {
|
||||
this.agentOverviewLoading = true
|
||||
try {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/agent-overview?staleHours=${staleHours}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data: AgentWorkflowOverview = await res.json()
|
||||
this.agentOverview = data
|
||||
this.agentOverviewError = null
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] fetchAgentOverview failed', err)
|
||||
this.agentOverviewError = 'Agent overview could not be loaded'
|
||||
} finally {
|
||||
this.agentOverviewLoading = false
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Create agent task ───────────────────── */
|
||||
async createAgentTask(data: {
|
||||
title: string
|
||||
detail?: string | null
|
||||
source?: string
|
||||
priority?: string
|
||||
assignedTo?: string
|
||||
expectedFrom?: string
|
||||
parentTaskId?: string | null
|
||||
}) {
|
||||
try {
|
||||
const res = await apiFetch('/api/dashboard/tasks/agent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
detail: data.detail ?? null,
|
||||
source: data.source ?? 'iris',
|
||||
priority: data.priority ?? 'Medium',
|
||||
assignedTo: data.assignedTo ?? null,
|
||||
expectedFrom: data.expectedFrom ?? null,
|
||||
parentTaskId: data.parentTaskId ?? null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
await this.fetchBoard()
|
||||
await this.fetchAgentOverview()
|
||||
return await res.json() as DashboardTaskDto
|
||||
} catch (err) {
|
||||
console.warn('[TaskStore] createAgentTask failed', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
/* ── Polling ──────────────────────────────────── */
|
||||
startPolling() {
|
||||
if (this.refreshInterval) return
|
||||
this.fetchTasks()
|
||||
@@ -139,5 +358,24 @@ export const useTaskStore = defineStore('tasks', {
|
||||
this.refreshInterval = null
|
||||
}
|
||||
},
|
||||
|
||||
startBoardPolling(force = false) {
|
||||
if (this.boardRefreshInterval && !force) return
|
||||
if (this.boardRefreshInterval && force) {
|
||||
clearInterval(this.boardRefreshInterval)
|
||||
this.boardRefreshInterval = null
|
||||
}
|
||||
this.fetchBoard()
|
||||
this.boardRefreshInterval = setInterval(() => {
|
||||
this.fetchBoard()
|
||||
}, 30000)
|
||||
},
|
||||
|
||||
stopBoardPolling() {
|
||||
if (this.boardRefreshInterval) {
|
||||
clearInterval(this.boardRefreshInterval)
|
||||
this.boardRefreshInterval = null
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -37,8 +37,8 @@ export interface RoutingTarget {
|
||||
detail: string
|
||||
}
|
||||
|
||||
export type TaskState = 'Backlog' | 'In progress' | 'Blocked' | 'Done'
|
||||
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Blocked', 'Done']
|
||||
export type TaskState = 'Backlog' | 'In progress' | 'Review' | 'Blocked' | 'Done'
|
||||
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Review', 'Blocked', 'Done']
|
||||
|
||||
export interface OperationsSnapshot {
|
||||
generatedAt: string
|
||||
|
||||
@@ -12,85 +12,74 @@
|
||||
*
|
||||
* Polling startet bei Mount, stoppt bei Unmount.
|
||||
*/
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAgentStore } from '../../stores/agents'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useDashboardStore } from '../../stores/dashboard'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||
import type { AgentNodeData } from '../../composables/useFlowLayout'
|
||||
import { extraAgentPool } from '../../composables/useFlowLayout'
|
||||
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
||||
|
||||
/* ── Stores ──────────────────────────────────────── */
|
||||
const agentStore = useAgentStore()
|
||||
const chatStore = useChatStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
const taskStore = useTaskStore()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
const router = useRouter()
|
||||
|
||||
/* ── Agent Layout State ───────────────────────────── */
|
||||
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
|
||||
const enteringIds = ref<string[]>([])
|
||||
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
||||
const {
|
||||
addAgent,
|
||||
agentPositions,
|
||||
changeModel,
|
||||
closeAgent,
|
||||
enteringIds,
|
||||
resetLayout,
|
||||
selectAgent,
|
||||
sendChatMessage,
|
||||
updatePositions,
|
||||
} = useFlowBoardState(agentStore, chatStore)
|
||||
|
||||
/* ── Event Handlers ───────────────────────────────── */
|
||||
|
||||
function handleSelect(id: string) {
|
||||
agentStore.selectAgent(id)
|
||||
}
|
||||
|
||||
function handleCloseModal() {
|
||||
agentStore.selectAgent(null)
|
||||
}
|
||||
|
||||
function handleChangeModel(agentId: string, modelAlias: string) {
|
||||
// Modal emits the alias (display name); resolve to model ID for the API
|
||||
const model = agentStore.models.find(m => m.alias === modelAlias)
|
||||
const modelId = model?.id ?? modelAlias
|
||||
agentStore.changeModel(agentId, modelId)
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const pool = localAgentPool.value
|
||||
if (pool.length === 0) return
|
||||
const next = pool.shift()!
|
||||
enteringIds.value.push(next.id)
|
||||
agentStore.agents.push(next)
|
||||
|
||||
setTimeout(() => {
|
||||
const idx = enteringIds.value.indexOf(next.id)
|
||||
if (idx !== -1) enteringIds.value.splice(idx, 1)
|
||||
}, 600)
|
||||
}
|
||||
|
||||
function handleResetLayout() {
|
||||
agentPositions.value = {}
|
||||
}
|
||||
|
||||
function handleUpdatePositions(pos: Record<string, { x: number; y: number }>) {
|
||||
agentPositions.value = { ...pos }
|
||||
}
|
||||
const blockedTasks = computed(() => taskStore.taskList.filter(task => task.status === 'blocked'))
|
||||
|
||||
function handleBlockerClick() {
|
||||
console.log('[FlowBoard] blocker clicked')
|
||||
if (!blockedTasks.value.length) return
|
||||
router.push('/tasks')
|
||||
}
|
||||
|
||||
function handleChatSend(text: string) {
|
||||
chatStore.sendMessage(text)
|
||||
function blockerLabel() {
|
||||
const blockedTask = blockedTasks.value[0]
|
||||
if (!blockedTask) return undefined
|
||||
return `${blockedTasks.value.length} Blocker — ${blockedTask.title}`
|
||||
}
|
||||
|
||||
function blockerCount() {
|
||||
return blockedTasks.value.length
|
||||
}
|
||||
|
||||
/* ── Lifecycle ────────────────────────────────────── */
|
||||
onMounted(() => {
|
||||
agentStore.startPolling()
|
||||
chatStore.startPolling()
|
||||
dashboardStore.startPolling()
|
||||
taskStore.startPolling()
|
||||
taskStore.startBoardPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
agentStore.stopPolling()
|
||||
chatStore.stopPolling()
|
||||
dashboardStore.stopPolling()
|
||||
taskStore.stopPolling()
|
||||
taskStore.stopBoardPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -104,9 +93,10 @@ onUnmounted(() => {
|
||||
:active-count="agentStore.activeCount"
|
||||
:think-count="agentStore.thinkCount"
|
||||
:idle-count="agentStore.idleCount"
|
||||
:blocker-count="agentStore.blockerCount"
|
||||
:blocker-count="blockerCount()"
|
||||
:today-cost="agentStore.todayCost"
|
||||
:today-tokens="agentStore.todayTokens"
|
||||
:blocker-label="blockerLabel()"
|
||||
@blocker-click="handleBlockerClick"
|
||||
/>
|
||||
|
||||
@@ -114,10 +104,10 @@ onUnmounted(() => {
|
||||
:agents="agentStore.agentList"
|
||||
:positions="agentPositions"
|
||||
:entering-ids="enteringIds"
|
||||
@select="handleSelect"
|
||||
@add="handleAdd"
|
||||
@reset-layout="handleResetLayout"
|
||||
@update-positions="handleUpdatePositions"
|
||||
@select="selectAgent"
|
||||
@add="addAgent"
|
||||
@reset-layout="resetLayout"
|
||||
@update-positions="updatePositions"
|
||||
/>
|
||||
|
||||
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
||||
@@ -128,7 +118,7 @@ onUnmounted(() => {
|
||||
:messages="chatStore.messageList"
|
||||
:is-thinking="chatStore.isThinking"
|
||||
:error="chatStore.error"
|
||||
@send="handleChatSend"
|
||||
@send="sendChatMessage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,9 +127,9 @@ onUnmounted(() => {
|
||||
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
||||
:agent="agentStore.selectedAgent"
|
||||
:agent-order="agentStore.agentOrder"
|
||||
@close="handleCloseModal"
|
||||
@select="handleSelect"
|
||||
@change-model="handleChangeModel"
|
||||
@close="closeAgent"
|
||||
@select="selectAgent"
|
||||
@change-model="changeModel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -177,4 +167,16 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.board-body {
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Command, LockKeyhole } from '@lucide/vue'
|
||||
/**
|
||||
* LoginView – Nexus Mission Control V2 Galaxy Theme
|
||||
*
|
||||
* Vollbild-Login mit GalaxyBackground, Glassmorphismus,
|
||||
* und Consistent Branding.
|
||||
* Zeigt verbleibende Login-Versuche und Rate-Limit-Countdown.
|
||||
*/
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { Mail, LockKeyhole, Command, Eye, EyeOff, Clock, AlertTriangle } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const showPassword = ref(false)
|
||||
|
||||
// Rate-limit countdown timer
|
||||
const countdown = ref(0)
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const countdownText = computed(() => {
|
||||
if (countdown.value <= 0) return ''
|
||||
const m = Math.floor(countdown.value / 60)
|
||||
const s = countdown.value % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
function startCountdown(seconds: number) {
|
||||
stopCountdown()
|
||||
countdown.value = seconds
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown.value = Math.max(0, countdown.value - 1)
|
||||
if (countdown.value <= 0 && countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval)
|
||||
countdownInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => stopCountdown())
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
@@ -20,42 +62,420 @@ async function submit() {
|
||||
: '/dashboard'
|
||||
await router.replace(target)
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : 'Login failed.'
|
||||
if (reason instanceof Error) {
|
||||
error.value = reason.message
|
||||
|
||||
// Start countdown if rate-limited
|
||||
if (auth.retryAfterSeconds > 0) {
|
||||
startCountdown(auth.retryAfterSeconds)
|
||||
}
|
||||
} else {
|
||||
error.value = 'Login fehlgeschlagen.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-card">
|
||||
<div class="login-container">
|
||||
<GalaxyBackground />
|
||||
|
||||
<div class="login-content">
|
||||
<!-- Branding -->
|
||||
<div class="login-brand">
|
||||
<div class="brand-mark"><Command :size="20" /></div>
|
||||
<div><strong>NEXUS</strong><span>Noveria Operations</span></div>
|
||||
<div class="brand-icon">
|
||||
<Command :size="22" />
|
||||
</div>
|
||||
<span class="brand-name">NEXUS</span>
|
||||
<span class="brand-sub">Mission Control · Noveria</span>
|
||||
</div>
|
||||
|
||||
<div class="login-heading">
|
||||
<span class="eyebrow">OWNER ACCESS</span>
|
||||
<h1>Sign in to mission control</h1>
|
||||
<p>Use your private owner credentials to continue.</p>
|
||||
<!-- Login Card -->
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
|
||||
<h1>Anmelden</h1>
|
||||
<p>Gib deine Zugangsdaten ein, um auf das Mission Control zuzugreifen.</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<label>
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="username" required maxlength="120" />
|
||||
<form @submit.prevent="submit" class="login-form">
|
||||
<div class="field">
|
||||
<label for="email">
|
||||
<Mail :size="14" />
|
||||
<span>E-Mail</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input v-model="password" type="password" autocomplete="current-password" required minlength="10" maxlength="200" />
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
maxlength="120"
|
||||
placeholder="name@noveria.net"
|
||||
class="field-input"
|
||||
:disabled="auth.isRateLimited"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">
|
||||
<LockKeyhole :size="14" />
|
||||
<span>Passwort</span>
|
||||
</label>
|
||||
<p v-if="error" class="login-error" role="alert">{{ error }}</p>
|
||||
<button type="submit" :disabled="auth.loading">
|
||||
<div class="password-wrap">
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="200"
|
||||
placeholder="••••••••••"
|
||||
class="field-input"
|
||||
:disabled="auth.isRateLimited"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-pw"
|
||||
@click="showPassword = !showPassword"
|
||||
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
|
||||
tabindex="-1"
|
||||
:disabled="auth.isRateLimited"
|
||||
>
|
||||
<Eye v-if="!showPassword" :size="16" />
|
||||
<EyeOff v-else :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error display with remaining attempts -->
|
||||
<div v-if="error" class="error-box" role="alert">
|
||||
<div class="error-main">
|
||||
<AlertTriangle v-if="countdown > 0" :size="16" class="error-icon" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-if="auth.remainingAttempts !== null && auth.remainingAttempts > 0" class="attempts-remaining">
|
||||
<LockKeyhole :size="12" />
|
||||
<span>{{ auth.remainingAttempts }} {{ auth.remainingAttempts === 1 ? 'Versuch verbleibend' : 'Versuche verbleibend' }}</span>
|
||||
</div>
|
||||
<div v-if="countdown > 0" class="countdown-bar">
|
||||
<Clock :size="12" />
|
||||
<span>Entsperrt in {{ countdownText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="auth.loading || !email || !password || auth.isRateLimited">
|
||||
<LockKeyhole :size="15" />
|
||||
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
|
||||
<template v-if="auth.loading">Anmelden…</template>
|
||||
<template v-else-if="countdown > 0">Gesperrt ({{ countdownText }})</template>
|
||||
<template v-else>Anmelden</template>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<footer>Protected owner session · Refresh token stored in a secure HTTP-only cookie</footer>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="card-footer">
|
||||
<span class="lock-icon">🔒</span>
|
||||
<span>Gesicherte Sitzung · Refresh-Token im HTTP-only Cookie</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
max-width: 420px;
|
||||
width: 90%;
|
||||
animation: login-fade-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes login-fade-in {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Branding ─────────────────────── */
|
||||
.login-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #4f7cff, #b557f6);
|
||||
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.6);
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
color: #ece9ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: #6f6aa0;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Login Card ────────────────────── */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
background: linear-gradient(160deg, rgba(20, 17, 48, 0.88), rgba(14, 12, 32, 0.88));
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 20px;
|
||||
padding: 32px 28px;
|
||||
backdrop-filter: blur(16px);
|
||||
box-shadow: 0 0 0 1px rgba(124, 108, 255, 0.06), 0 24px 80px -12px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: #7c6cff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: #ece9ff;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #6f6aa0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Form ──────────────────────────── */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #a8a3d6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 9, 24, 0.55);
|
||||
color: #ece9ff;
|
||||
font-size: 14px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-input::placeholder {
|
||||
color: #4a4680;
|
||||
}
|
||||
|
||||
.field-input:focus {
|
||||
border-color: rgba(124, 108, 255, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
|
||||
}
|
||||
|
||||
.field-input:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-wrap .field-input {
|
||||
padding-right: 44px;
|
||||
}
|
||||
|
||||
.toggle-pw {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6f6aa0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-pw:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: #a8a3d6;
|
||||
}
|
||||
|
||||
.toggle-pw:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Error Box ─────────────────────── */
|
||||
.error-box {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
color: #fda4af;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.attempts-remaining {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
color: #f9a8d4;
|
||||
padding: 4px 8px;
|
||||
background: rgba(244, 63, 94, 0.06);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.countdown-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11.5px;
|
||||
color: #fb923c;
|
||||
padding: 4px 8px;
|
||||
background: rgba(251, 146, 60, 0.08);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
/* ── Submit Button ─────────────────── */
|
||||
.submit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 13px 20px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s, box-shadow 0.2s;
|
||||
box-shadow: 0 0 24px -6px rgba(124, 108, 255, 0.5);
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
opacity: 0.92;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 32px -4px rgba(124, 108, 255, 0.65);
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Footer ────────────────────────── */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(150, 140, 255, 0.08);
|
||||
font-size: 10.5px;
|
||||
color: #6f6aa0;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
|
||||
const sortedNotifications = computed(() => {
|
||||
return [...store.notifications].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
function typeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'task_assigned': return '👤'
|
||||
case 'task_review': return '✅'
|
||||
case 'task_blocked': return '🚫'
|
||||
default: return '🔔'
|
||||
}
|
||||
}
|
||||
|
||||
function typeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'task_assigned': return '#4d8cf6'
|
||||
case 'task_review': return '#f6a84d'
|
||||
case 'task_blocked': return '#e16e75'
|
||||
default: return '#7b6ef2'
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
const diffSec = Math.floor((now - then) / 1000)
|
||||
if (diffSec < 60) return 'vor ' + diffSec + ' Sek'
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
if (diffMin < 60) return 'vor ' + diffMin + ' Min'
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return 'vor ' + diffHr + ' Std'
|
||||
const diffDay = Math.floor(diffHr / 24)
|
||||
return 'vor ' + diffDay + ' Tag' + (diffDay > 1 ? 'en' : '')
|
||||
}
|
||||
|
||||
function onNotificationClick(n: { id: string, taskId: string | null }) {
|
||||
if (n.taskId) {
|
||||
router.push('/tasks')
|
||||
}
|
||||
store.markAsRead(n.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.startListPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopListPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notifications-page">
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<Bell :size="22" />
|
||||
Benachrichtigungen
|
||||
</h1>
|
||||
<button
|
||||
v-if="store.unreadCount > 0"
|
||||
class="mark-all-btn"
|
||||
@click="store.markAllAsRead()"
|
||||
>
|
||||
<CheckCheck :size="15" />
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="sortedNotifications.length === 0" class="empty-state">
|
||||
<BellOff :size="48" />
|
||||
<p>Keine Benachrichtigungen</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="notification-list">
|
||||
<div
|
||||
v-for="n in sortedNotifications"
|
||||
:key="n.id"
|
||||
:class="['notification-card', { unread: !n.isRead }]"
|
||||
@click="onNotificationClick(n)"
|
||||
>
|
||||
<div class="icon-wrapper" :style="{ background: typeColor(n.type) + '20' }">
|
||||
<span class="type-icon">{{ typeIcon(n.type) }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div :class="['card-title', { bold: !n.isRead }]">{{ n.title }}</div>
|
||||
<div v-if="n.message" class="card-message">{{ n.message }}</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span class="timestamp">{{ timeAgo(n.createdAt) }}</span>
|
||||
<ChevronRight v-if="n.taskId" :size="14" class="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notifications-page {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mark-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--nx-line, #1f2330);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.mark-all-btn:hover {
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
|
||||
color: #d8dbe3;
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Notification List ── */
|
||||
.notification-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.notification-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
background: rgba(77, 140, 246, .06);
|
||||
border-color: rgba(77, 140, 246, .12);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 12.5px;
|
||||
color: #d8dbe3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-title.bold {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-message {
|
||||
font-size: 10.5px;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
margin-top: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 9px;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
+763
-147
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user