Compare commits

...

38 Commits

Author SHA1 Message Date
devops b89289989a docs: document owner password persistence fix in deployment.md and changelog
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-21 10:28:53 +02:00
devops f95463ef50 fix: permanent owner password persistence with SeedAudit guard
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
Root cause: Dual-source architecture for owner password (Gitea secret
ENV_OWNER_PASSWORD vs host .env OWNER_PASSWORD) caused drift when
the DB was ever re-seeded or the volume recreated.

Changes:
- Add SeedAudit entity + migration to track one-time seed operations
- EnsureDatabaseAsync checks SeedAudit BEFORE seeding — owner is never
  re-created even if the Users table is wiped
- Deploy and rollback workflows now read OWNER_PASSWORD from the host's
  persistent .env (single source of truth) instead of Gitea secrets
- compose.yaml documented: OWNER_PASSWORD only used during initial seed
- Cleanup: .gitignore extended for core dumps, changelog/deployment.md
  updated with 2026-06-20 session notes

After this fix the DB is the single source of truth for the owner
password after initial seed. The host .env is the single reference
for the initial value.
2026-06-21 10:15:36 +02:00
devops 2d218853a5 Fix activity repository test double
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-20 20:25:42 +02:00
devops adae7ba26d feat: ship agent progress visibility
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-20 20:22:54 +02:00
devops 3dd745586b retrigger: force deploy pipeline via push
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-20 19:05:33 +02:00
devops f0023ac033 fix: use external deploy script to avoid nested quoting errors
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 4s
The inner shell script run via docker:cli had complex escaping
that caused 'unterminated quoted string' errors at runtime.
Moved the deploy logic to an external script file (heredoc in
the workflow YAML), mounted read-only into the docker:cli
container. Pass BUILD_ARGS and SERVICE via environment
variables instead of shell interpolation.
2026-06-20 19:00:53 +02:00
devops 73c5eb69d7 fix: ensure zombie container cleanup before deploy + verbose pg_resetwal
CI - Build & Test / Backend (.NET) (push) Successful in 34s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-20 18:57:54 +02:00
devops 06eac66baa fix: postgres WAL corruption recovery + memory bump + researcher/executor
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
- Postgres memory: 256M→384M limits, 64M→96M reservations
- Added pg_resetwal -f pre-deploy step to recover from corrupt WAL
  ('PANIC: could not locate a valid checkpoint record' caused by
  force-killed postgres during --force-recreate)
- Added data-checksums initdb arg for future corruption detection
- api→postgres and web→api depends_on: service_healthy→service_started
- Deploy wait loop: fail fast on unhealthy, wait on starting (180s)
- Added researcher/executor to ValidAssignees and frontend dropdowns
2026-06-20 18:56:11 +02:00
devops b95bec7915 fix: relax web→api dependency + smarter wait loop
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 4s
- web's depends_on on api: change from service_healthy to
  service_started+restart (same as api→postgres fix)
- deploy wait loop: fail fast on unhealthy, wait on starting,
  increased timeout to 180s (36×5s)
2026-06-20 18:50:29 +02:00
devops 071be50977 fix: relax api→postgres dependency to service_started+restart
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
depends_on: condition: service_healthy on the api service was
failing during docker compose up because postgres hasn't completed
its healthcheck yet (start_period=30s). Changed to
condition: service_started with restart: true so the API
starts as soon as postgres is running and retries if the
DB isn't ready yet. The .NET backend already handles
transient DB connection failures.
2026-06-20 18:48:34 +02:00
devops baf4008d97 fix: remove --wait flag causing premature deploy failure, use manual health loop
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 4s
The docker compose --wait flag times out before postgres can
become healthy (start_period=30s). Replaced with explicit
poll loop (5s interval, up to 120s) that checks ps output
for unhealthy/starting states.
2026-06-20 18:46:27 +02:00
devops 83e072bc27 feat: Bao/Iris-Statusrechte + Bao→Iris-Notifications + Agent-Workflow-Übersicht
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
- Bao darf jetzt Status ändern (neben Iris), Sub-Agents weiterhin nicht
- CanEditContent für Inhaltsbearbeitung durch alle bekannten Caller
- Bao-Content-Änderungen triggern task_content_changed-Notification an Iris
- Bao-Status-Änderungen triggern task_status_changed-Notification an Iris
- Iris-Status-Änderungen triggern task_status_changed-Notification an Bao
- Neue WorkTask-Felder: IsAgentTask (bool), ExpectedFrom (string)
- Agent-Workflow-API: CreateAgentTask, WaitingTasks, AgentOverview
- Frontend: Agent-Task-Badge, Iris-Overview-Panel, isBao-Getter
- Login-Rate-Limiter mit strukturiertem JSON-Fehlermeldungs-Body
- Volume-Name: nexus-postgres → postgres-data (Standardisierung)
2026-06-20 18:43:05 +02:00
devops a516353ae8 fix: SettingsView owner→canManageUsers (owner || admin)
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 4s
Vorher war isOwner (= nur owner) gesetzt, was admins die User-Verwaltung
verweigerte. Jetzt: canManageUsers = role===owner || role===admin.

Delta: 1 Datei, 4 Zeilen (2 Logic, 1 Kommentar, 1 v-if).
Builds: Backend 0 Errors, Frontend 0 Errors.
2026-06-20 14:29:34 +02:00
devops 1df663f57c fix: AdminController roles hardened (owner+admin) + SettingsView visibility
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 5s
- [Authorize(Roles = "owner,admin")] statt nur owner – admin darf jetzt
  ebenfalls User verwalten
- CreateUser erlaubt nur Rollen admin|user|viewer; owner ist blockiert
- UpdateUserRole erlaubt nur admin|user|viewer; owner kann weder gesetzt
  noch überschrieben werden; admin darf andere admins nicht ändern
  und sich nicht selbst herabstufen
- SettingsView: canManageUsers = role owner || admin statt nur owner
- UI-Dropdown zeigt nur admin|user|viewer (owner als Kommentar notiert)
2026-06-20 14:27:24 +02:00
devops e4091eee80 feat: Multi-User/Admin usermanagement + Galaxy Login/Settings + Task detail improvements
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 4s
- Backend: NEW AdminController with user CRUD (GET/POST/DELETE /api/v1/admin/users)
- Backend: NEW GET /api/dashboard/tasks/{id} single task endpoint
- Backend: NEW POST /api/dashboard/tasks/{id}/activity comment endpoint
- Backend: IUserRepository + UserRepository extended with GetAllAsync, DeleteAsync
- Backend: Admin DTOs (AdminUserInfo, AdminCreateUserRequest, AdminUpdateRoleRequest)
- Frontend: NEW TaskDetailView.vue — URL-based (/tasks/:id) Galaxy-themed task detail
  with subtask create/edit/delete, activity with comments, property sidebar
- Frontend: LoginView.vue — полностью Galaxy theme redesign with GalaxyBackground,
  glass-morphism card, password toggle, consistent brand
- Frontend: SettingsView.vue — Galaxy theme redesign with glass cards,
  admin user management section (create/list users, visible only to owner role)
- Frontend: TaskBoardView.vue — added "Full View" link to URL-based detail page
- Frontend: Router — added /tasks/:id route for TaskDetailView
- Frontend: App.vue — added TaskDetail to standaloneViews whitelist
- Frontend: tasks store — stable

Auth: Admin creates accounts, users log in with existing /api/v1/auth/login.
Login/Settings deliver visible Galaxy-consistent design with nexus-tokens.css tokens.
2026-06-20 14:24:40 +02:00
devops dcc8450c62 feat: Phase 2 — Delegated State, Auth, Review-Gate, Notifications, Zombie-Reset
CI - Build & Test / Backend (.NET) (push) Successful in 37s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 24s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-18 23:47:41 +02:00
devops 12998170e3 fix: update DEPLOY_PATH in all workflows from /opt/openclaw to /home/projekte_bao/openclaw
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:44:33 +02:00
devops 691152f889 fix: volume paths from /opt/openclaw to /home/projekte_bao/openclaw
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:41:32 +02:00
devops 74ef58d274 fix: add Traefik labels and proxy network for nexus.noveria.net routing
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-18 21:40:17 +02:00
devops 5e7d074593 feat: Linear-style Task Board mit Drag&Drop
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:34:07 +02:00
iris c496608c86 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris c040696d91 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris 7ba0bd26fa docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
iris 4b1d140b53 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
developer e0c88238da refactor: extract DI, helpers from Program.cs into extension classes
CI - Build & Test / Backend (.NET) (push) Successful in 1m18s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 48s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-16 16:52:17 +02:00
AzuTear b0e65e3980 style: strengthen flow lines and tighten modal demo parity
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:57:12 +02:00
devops 648a5d2151 refactor: move landingpage to separate repo bao/noveria-landing
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:53:00 +02:00
devops 1a024eef96 feat: noveria.net landingpage template
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:45:23 +02:00
devops 6280e87078 infra: landingpage compose + nginx config
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:51 +02:00
AzuTear 64459ccdb3 feat: wire dashboard v2 to backend data
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:05 +02:00
devops 38dc2efc6c docs: devops deploy-actor documentation
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:41:38 +02:00
AzuTear 390bffa208 fix: detect drag state on pointer release
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 15:33:51 +02:00
AzuTear e034883abd fix: open agent cards only on click
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:23:05 +02:00
AzuTear 6d4e8e7927 refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:11:05 +02:00
reviewer 0f8939306d feat: mobile-responsive dashboard v2
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 12:16:06 +02:00
reviewer 58675f0c69 ops: enhanced deploy verification with web-recovery + incident docs
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 11:31:46 +02:00
reviewer 88cafc7b8e review: remove version-bump from deploy workflow — VERSION is read-only source of truth
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 11:31:04 +02:00
reviewer 485357c6dc review: error-handling for config file write + compose resource limits
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
- AgentsController.SaveConfigFile: catch UnauthorizedAccessException and IOException
  instead of letting them bubble up unhandled; return clean 500 with logged message
- compose.yaml: add deploy.resources.limits.memory and reservations.memory for
  api (512M/128M), web (128M/32M), postgres (256M/64M)
2026-06-14 11:30:25 +02:00
85 changed files with 9705 additions and 775 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ on:
host_backup_path: host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)' description: 'Host path for backup (only if keep_on_host is true)'
required: false required: false
default: '/opt/openclaw/backups' default: '/home/projekte_bao/openclaw/backups'
type: string type: string
# Optional: uncomment to enable nightly automatic backups # Optional: uncomment to enable nightly automatic backups
@@ -47,7 +47,7 @@ jobs:
env: env:
ENV_TMPFILE: /tmp/nexus-backup-env ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} 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 BACKUP_CONTAINER_NAME: nexus-postgres-1
steps: steps:
+112 -75
View File
@@ -15,12 +15,11 @@ run-name: 🚀 Deploy by @${{ gitea.actor }}
# Concurrency: one deploy at a time. # Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds. # Queued deploys wait — no race conditions with parallel builds.
# #
# Version-Bump / CI Loop Prevention: # Version Management:
# The version-bump commit includes "[skip ci]" in its message, # The VERSION file in the repo root is the single source of truth.
# which Gitea Actions respects. The auto-trigger additionally # Version bumps happen in the Dev workflow BEFORE merge to main.
# checks for "[skip ci]" as a second safety layer. Together # The deploy workflow only reads, validates, and logs the version.
# they guarantee that a version-bump commit does NOT trigger # The [skip ci] filter remains as a safety layer for auto-triggers.
# another CI → Deploy → Bump → CI cycle.
# ─────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────
concurrency: concurrency:
group: deploy-production group: deploy-production
@@ -36,15 +35,6 @@ on:
# ── Manual Trigger (full control) ── # ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version_bump:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
service: service:
description: 'Service to deploy (empty = all)' description: 'Service to deploy (empty = all)'
required: false required: false
@@ -73,12 +63,14 @@ jobs:
# ── Env for the deploy target path ── # ── Env for the deploy target path ──
env: 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_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }} ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
# This ensures the password stays consistent across deploys and the DB is the
# single source of truth after initial seed (enforced by SeedAudit guard).
steps: steps:
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
@@ -102,76 +94,72 @@ jobs:
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version # Step 3: Resolve deploy version
# #
# Deploying main: DevOps may bump VERSION and create a tag. # Reads VERSION from repo root — the single source of truth.
# Deploying any other ref: deploy exactly that ref, but DO NOT # Validates semver format, logs version + git metadata.
# mutate main or create a version-bump commit on another branch. # No git mutation: version bumps happen in the Dev workflow.
#
# For auto-deploys (workflow_run): always "patch" bump on main.
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
- name: Resolve Version - name: Resolve Version
id: version id: version
run: | run: |
set -euo pipefail set -euo pipefail
# Determine bump type (auto-deploy → patch; manual → user choice) # 1. Check VERSION exists
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
# Read current version
if [ ! -f VERSION ]; then if [ ! -f VERSION ]; then
echo "❌ VERSION file not found" echo "❌ VERSION file not found"
exit 1 exit 1
fi fi
CURRENT=$(cat VERSION | tr -d '[:space:]') # 2. Read and validate semver format
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then VERSION=$(cat VERSION | tr -d '[:space:]')
echo "❌ Invalid semver in VERSION: '$CURRENT'" if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1 exit 1
fi fi
MAJOR=$(echo "$CURRENT" | cut -d. -f1) # 3. Log version, git ref, and describe
MINOR=$(echo "$CURRENT" | cut -d. -f2) GIT_REF=$(git rev-parse --short HEAD)
PATCH=$(echo "$CURRENT" | cut -d. -f3) GIT_DESCRIBE=$(git describe --always --dirty)
case "$BUMP_TYPE" in echo "📦 Deploy version: v${VERSION}"
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;; echo "🔖 Git ref: ${GIT_REF}"
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;; echo "🏷️ Git describe: ${GIT_DESCRIBE}"
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
esac
# Determine git ref — auto-deploy always uses main # 4. Set outputs for downstream steps
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" echo "version=${VERSION}" >> "$GITEA_OUTPUT"
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
echo "$NEW_VERSION" > VERSION
git add VERSION
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin HEAD:main --tags
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
echo "mutated_main=true" >> "$GITEA_OUTPUT"
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
else
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT" echo "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 # Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to # to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after # the host. The temp file is deleted immediately after
# compose operations complete. # 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: | run: |
set -euo pipefail 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 cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline # Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea Secrets → do NOT edit manually on the host. # Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes. # This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus POSTGRES_DB=nexus
POSTGRES_USER=nexus POSTGRES_USER=nexus
@@ -180,7 +168,7 @@ jobs:
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD} OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME= OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
@@ -232,26 +220,78 @@ jobs:
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}" SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
# 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 "
echo 'Resetting WAL...'
pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'
" 2>&1 || echo 'pg_resetwal failed (may be benign)'
else
echo 'Postgres volume not found - will be created fresh'
fi
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
SERVICE="${DEPLOY_SERVICE:-}"
if [ -n "$SERVICE" ]; then
echo "Deploying service: $SERVICE"
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS $SERVICE
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate $SERVICE
else
echo 'Deploying all services'
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate
fi
echo 'Waiting for services to become healthy (up to 180s)...'
for i in $(seq 1 36); do
STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)
if echo "$STATUS" | grep -q 'unhealthy'; then
echo " [$i/36] Unhealthy containers - failing fast"
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=30
exit 1
elif echo "$STATUS" | grep -q 'starting'; then
echo " [$i/36] Still starting..."
sleep 5
else
echo 'All containers healthy'
docker compose --env-file /tmp/nexus-deploy-env ps -a
exit 0
fi
done
echo 'Timeout waiting for services'
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=20
exit 1
DEPLOYSCRIPT
docker run --rm \ docker run --rm \
-e "DEPLOY_BUILD_ARGS=${BUILD_ARGS:-}" \
-e "DEPLOY_SERVICE=${SERVICE_ARG:-}" \
-v "${DEPLOY_PATH}:/workspace/nexus" \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/nexus-deploy-script.sh:/deploy.sh:ro \
-w /workspace/nexus \ -w /workspace/nexus \
-i \ -i \
docker:cli \ docker:cli \
sh -c " sh /deploy.sh < "${ENV_TMPFILE}"
set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT rm -f /tmp/nexus-deploy-script.sh
cat > /tmp/nexus-deploy-env
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
" < "${ENV_TMPFILE}"
echo "✅ Docker compose up completed" echo "✅ Docker compose up completed"
@@ -334,17 +374,14 @@ jobs:
if: always() if: always()
run: | run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}" 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 "═══════════════════════════════════════" echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary" echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════" echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}" echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}" 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 " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}" echo " Trigger: ${TRIGGER}"
echo " Bump type: ${VERSION_BUMP}"
echo " Actor: @${{ gitea.actor }}" echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}" echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════" echo "═══════════════════════════════════════"
+15 -6
View File
@@ -39,11 +39,10 @@ jobs:
name: Rollback Nexus name: Rollback Nexus
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: 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_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }} ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps: steps:
@@ -95,12 +94,22 @@ jobs:
fi 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: | run: |
set -euo pipefail 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 cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline # Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus POSTGRES_DB=nexus
@@ -110,7 +119,7 @@ jobs:
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD} OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME= OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
@@ -271,7 +280,7 @@ jobs:
echo "│ Letzter bekannter funktionierender Stand: │" echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │" echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │" 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 "│ docker compose up -d (vorheriger Stand) │"
echo "│ │" echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘" echo "└─────────────────────────────────────────────────────────────┘"
+4
View File
@@ -30,6 +30,10 @@ docker-compose.override.yml
*.tmp *.tmp
*.bak *.bak
# Crash artefacts / Core dumps
**/core
**/core.*
# pnpm (lockfile IS committed for reproducible CI builds) # pnpm (lockfile IS committed for reproducible CI builds)
# Claude local config (per-developer, not repo-shared) # Claude local config (per-developer, not repo-shared)
+22 -19
View File
@@ -15,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
- ASP.NET Core 10 REST API (Minimal API pattern) - ASP.NET Core 10 REST API (Minimal API pattern)
- Entity Framework Core and PostgreSQL - Entity Framework Core and PostgreSQL
- JWT owner authentication with rotating refresh sessions - JWT owner authentication with rotating refresh sessions
- `IAgentRuntime` abstraction with an OpenClaw adapter - `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
- `IModelProvider` abstractions for Ollama and NVIDIA
- Responsive dark-mode operations dashboard - Responsive dark-mode operations dashboard
- Container-only entry point on `127.0.0.1:18880` - Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
## Local/container start ## Local/container start
@@ -31,12 +30,11 @@ curl http://127.0.0.1:18880/health
``` ```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`, On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14 `OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
characters. Existing databases are never overwritten by the bootstrap process. characters. Existing databases are never overwritten by the bootstrap process.
The web service is loopback-only. Public reverse-proxy activation for The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
`nexus.noveria.net` remains a separate infrastructure change and must terminate Health checks, rate limiting, and security headers are active.
TLS before forwarding to port `18880`.
## Workspace mounts ## Workspace mounts
@@ -45,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
| Host path | Container mount | | Host path | Container mount |
|---|---| |---|---|
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
## Frontend architecture ## Frontend architecture
@@ -283,11 +281,16 @@ Backlog → Blocked → In progress / Done
provider key. Conversation IDs are stable per browser and Iris is the default provider key. Conversation IDs are stable per browser and Iris is the default
agent target. agent target.
The configured model-routing policy is: The configured model-routing policy routes through the OpenClaw Gateway only.
Ollama and NVIDIA providers have been removed. Currently active models:
1. `qwen3:4b` through Ollama for routine and monitoring work | Agent | Model |
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work |-------|-------|
3. `gpt-5.5` through OpenClaw for strategic and critical review | Iris | `openai/gpt-5.4` |
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
The Settings module reports runtime and provider state without exposing The Settings module reports runtime and provider state without exposing
credentials. credentials.
@@ -316,7 +319,7 @@ Deployment can happen automatically or manually:
#### Manual Deploy (`workflow_dispatch`) #### 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 2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref 3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys 4. Workflow bumps VERSION, creates git tag, builds and deploys
@@ -332,7 +335,7 @@ Deployment can happen automatically or manually:
#### Database Backup (`workflow_dispatch`) #### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions 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 3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable) 4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`) 5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
+3
View File
@@ -109,6 +109,9 @@ internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow } new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct); }, 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) public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException(); => throw new NotSupportedException();
+253
View File
@@ -0,0 +1,253 @@
using Nexus.Api.Data;
using Xunit;
namespace Nexus.Api.Tests;
public class TaskBoardTests
{
// ── TaskStateHelper: BoardGroupKey ──
[Theory]
[InlineData("Backlog", "offen")]
[InlineData("In progress", "inProgress")]
[InlineData("Delegated", "delegated")]
[InlineData("Review", "review")]
[InlineData("Blocked", "blocked")]
[InlineData("Done", "done")]
[InlineData("backlog", "offen")]
[InlineData("in progress", "inProgress")]
[InlineData("delegated", "delegated")]
[InlineData("review", "review")]
[InlineData("blocked", "blocked")]
[InlineData("done", "done")]
[InlineData("", "offen")]
[InlineData(null, "offen")]
[InlineData("unknown", "offen")]
public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
{
var result = TaskStateHelper.BoardGroupKey(state);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: BoardGroupToState ──
[Theory]
[InlineData("offen", "Backlog")]
[InlineData("inProgress", "In progress")]
[InlineData("inprogress", "In progress")]
[InlineData("delegated", "Delegated")]
[InlineData("review", "Review")]
[InlineData("blocked", "Blocked")]
[InlineData("done", "Done")]
[InlineData("Offen", "Backlog")]
[InlineData("", null)]
[InlineData(null, null)]
[InlineData("unknown", null)]
public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
{
var result = TaskStateHelper.BoardGroupToState(groupKey);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: AllStates has 6 entries ──
[Fact]
public void AllStates_ContainsAllSixStates()
{
var states = TaskStateHelper.AllStates;
Assert.Equal(6, states.Length);
Assert.Contains("Backlog", states);
Assert.Contains("In progress", states);
Assert.Contains("Delegated", states);
Assert.Contains("Review", states);
Assert.Contains("Blocked", states);
Assert.Contains("Done", states);
}
// ── TaskStateHelper: IsValidState ──
[Theory]
[InlineData("Backlog", true)]
[InlineData("In progress", true)]
[InlineData("Delegated", true)]
[InlineData("Review", true)]
[InlineData("Blocked", true)]
[InlineData("Done", true)]
[InlineData("backlog", true)]
[InlineData("offen", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("unknown", false)]
public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsValidState(state));
}
// ── TaskStateHelper: IsInProgressOrBlocked ──
[Theory]
[InlineData("In progress", true)]
[InlineData("Blocked", true)]
[InlineData("Backlog", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)]
[InlineData("Done", false)]
[InlineData(null, false)]
public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
}
// ── TaskStateHelper: IsDoneOrBacklog ──
[Theory]
[InlineData("Done", true)]
[InlineData("Backlog", true)]
[InlineData("In progress", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)]
[InlineData("Blocked", false)]
[InlineData(null, false)]
public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
}
// ── TaskStateHelper: ToDisplayString ──
[Theory]
[InlineData("Backlog", "Offen")]
[InlineData("In progress", "In Bearbeitung")]
[InlineData("Delegated", "Delegiert")]
[InlineData("Review", "Review")]
[InlineData("Blocked", "Blockiert")]
[InlineData("Done", "Erledigt")]
[InlineData("backlog", "Offen")]
[InlineData("", "")]
[InlineData(null, "")]
[InlineData("unknown", "unknown")]
public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
{
Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
}
// ── TaskState helper: ToStateString and ToTaskState roundtrip ──
[Fact]
public void ToStateString_And_ToTaskState_RoundTrip()
{
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Delegated, TaskState.Review, TaskState.Blocked, TaskState.Done };
foreach (var state in states)
{
var str = state.ToStateString();
var parsed = str.ToTaskState();
Assert.Equal(state, parsed);
}
}
[Fact]
public void ToTaskState_DefaultsToBacklog_ForUnknownString()
{
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
}
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
[Fact]
public void CanChangeState_Iris_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
}
[Fact]
public void CanChangeState_Bao_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
}
[Fact]
public void CanChangeState_NexusSystem_IsAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
}
[Fact]
public void CanChangeState_UnknownCaller_Rejected()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.False(TaskStateHelper.CanChangeState("", task));
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
Assert.False(TaskStateHelper.CanChangeState(null, task));
}
// ── TaskStateHelper: CanEditContent ──
[Fact]
public void CanEditContent_Iris_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("iris"));
}
[Fact]
public void CanEditContent_Bao_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("bao"));
}
[Fact]
public void CanEditContent_SubAgents_AreAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("programmer"));
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
Assert.True(TaskStateHelper.CanEditContent("architekt"));
}
[Fact]
public void CanEditContent_NexusSystem_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
}
[Fact]
public void CanEditContent_UnknownCaller_Rejected()
{
Assert.False(TaskStateHelper.CanEditContent(""));
Assert.False(TaskStateHelper.CanEditContent(null));
Assert.False(TaskStateHelper.CanEditContent(" "));
}
}
+184
View File
@@ -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;
}
+19
View File
@@ -92,9 +92,28 @@ public class AgentsController(
if (request.Content.Length > 500 * 1024) if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
try
{
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct); var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null return result is null
? Results.BadRequest(new { error = "Invalid filename or path." }) ? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt }); : 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);
}
}
} }
+34 -3
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
@@ -14,7 +15,8 @@ public class AuthController(
IAuthService authService, IAuthService authService,
IAntiforgery antiforgery, IAntiforgery antiforgery,
IConfiguration config, IConfiguration config,
IHostEnvironment env) : ControllerBase IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{ {
[HttpGet("csrf")] [HttpGet("csrf")]
public IActionResult GetCsrfToken() public IActionResult GetCsrfToken()
@@ -30,11 +32,38 @@ public class AuthController(
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
var session = await authService.LoginAsync(request, ct); var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (session is null) return Results.Unauthorized();
var session = await authService.LoginAsync(request, ct);
if (session is null)
{
var remaining = attemptTracker.RecordFailedAttempt(ip);
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
// Attach remaining info to the 401 response via headers only
// (the frontend can also parse the 429 body)
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
if (retryAfterSeconds > 0)
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
}
// Success — reset attempt counter
attemptTracker.Reset(ip);
SetRefreshCookie(Response, session.RefreshToken); SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store"; Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session)); return Results.Ok(ToAuthResponse(session));
} }
@@ -54,6 +83,8 @@ public class AuthController(
SetRefreshCookie(Response, session.RefreshToken); SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store"; Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session)); return Results.Ok(ToAuthResponse(session));
} }
+194 -4
View File
@@ -1,13 +1,21 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.Models; using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[Authorize]
[ApiController] [ApiController]
[Route("api/dashboard")] [Route("api/dashboard")]
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase public class DashboardController(
IDashboardService dashboardService,
ITaskService taskService,
IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor) : ControllerBase
{ {
[HttpGet("status")] [HttpGet("status")]
public async Task<DashboardStatus> GetStatus() public async Task<DashboardStatus> GetStatus()
@@ -115,17 +123,24 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." }); return BadRequest(new { error = "Title is required." });
try
{
var task = await taskService.CreateDashboardTaskAsync( 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)); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
} }
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("tasks/{id:guid}")] [HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask( public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{ {
var result = await taskService.UpdateDashboardTaskAsync( 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 return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }), TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
@@ -149,6 +164,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus( public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{ {
// 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); var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch return result.Outcome switch
{ {
@@ -158,6 +187,167 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
}; };
} }
// ── Task Board Endpoints ──
[HttpGet("tasks/board")]
public async Task<BoardResponse> GetBoard(CancellationToken ct)
=> await taskService.GetBoardAsync(ct);
[HttpPatch("tasks/{id:guid}/move")]
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.State))
return BadRequest(new { error = "State is required." });
// Enforce workflow rules based on caller agent
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
{
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
}
var result = await taskService.MoveTaskAsync(id, request.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// </summary>
private string ResolveCallerAgent()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
// ── New Endpoints: Reset Stale, Children, Activity ──
[HttpPost("tasks/reset-stale")]
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
[FromBody] ResetStaleRequest request, CancellationToken ct)
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
}
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var children = await taskService.GetChildTasksAsync(id, ct);
return Ok(children.Select(MapToDto).ToList());
}
[HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
{
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
return Ok(task);
}
[HttpGet("tasks/{id:guid}/activity")]
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
{
var events = await taskService.GetTaskActivityAsync(id, ct);
return Ok(events);
}
[HttpPost("tasks/{id:guid}/activity")]
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
if (string.IsNullOrWhiteSpace(request.Message))
return BadRequest(new { error = "Message is required." });
var ev = new ActivityEvent
{
Type = request.Type ?? "comment",
Message = request.Message.Trim(),
TaskId = id
};
await activityService.AddAsync(ev, ct);
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
}
// ── Agent Workflow Endpoints (Iris Overview) ──
/// <summary>
/// Returns agent-tasks that are still open and waiting for input.
/// Iris uses this to see who she is waiting for.
/// </summary>
[HttpGet("tasks/agent-waiting")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
{
var waiting = await taskService.GetWaitingTasksAsync(ct);
return Ok(waiting.Select(MapToDto).ToList());
}
/// <summary>
/// Returns a complete agent-workflow overview grouped by expected respondent
/// + stale detection. This is the main Iris dashboard data.
/// </summary>
[HttpGet("tasks/agent-overview")]
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
CancellationToken ct, [FromQuery] int staleHours = 2)
{
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct));
}
/// <summary>
/// Creates an agent-task: a task that is tracked as originating from the agent workflow.
/// Sub-agents (programmer, reviewer) can only CREATE, not move state.
/// </summary>
[HttpPost("tasks/agent")]
public async Task<ActionResult<DashboardTaskDto>> CreateAgentTask(
[FromBody] CreateAgentTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
try
{
var task = await taskService.CreateAgentTaskAsync(
request.Title, request.Detail, request.Source ?? "iris",
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new( private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt); t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
} }
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
@@ -7,6 +8,7 @@ namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{ {
[AllowAnonymous]
[HttpGet("/health/live")] [HttpGet("/health/live")]
public IResult Live() public IResult Live()
{ {
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard/notifications")]
public class NotificationsController(INotificationService notificationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
}
[HttpGet("unread-count")]
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
return Ok(new UnreadCountDto(count));
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+29
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
@@ -70,6 +72,10 @@ public class TasksController(ITaskService taskService) : ControllerBase
return result.Outcome switch return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => Results.NotFound(), 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) _ => Results.Ok(result.Task)
}; };
} }
@@ -99,4 +105,27 @@ public class TasksController(ITaskService taskService) : ControllerBase
_ => Results.NoContent() _ => 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.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
=> Results.Ok(await taskService.GetBoardAsync(ct));
/// <summary>
/// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
}
} }
+23
View File
@@ -26,6 +26,29 @@ public sealed record UserInfo
public string Role { get; init; } = string.Empty; public string Role { get; init; } = string.Empty;
} }
public sealed record AdminUserInfo
{
public Guid Id { get; init; }
public string Email { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
}
public sealed record AdminCreateUserRequest
{
public string Email { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string? DisplayName { get; init; }
public string? Role { get; init; }
}
public sealed record AdminUpdateRoleRequest
{
public string Role { get; init; } = string.Empty;
}
public sealed record UpdateProfileRequest public sealed record UpdateProfileRequest
{ {
[MaxLength(100)] [MaxLength(100)]
+1
View File
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
string? Title, string? Title,
DateTimeOffset? Since DateTimeOffset? Since
); );
+126 -4
View File
@@ -18,8 +18,10 @@ public enum TaskState
{ {
Backlog, Backlog,
InProgress, InProgress,
Delegated,
Blocked, Blocked,
Done Done,
Review
} }
public static class TaskStateHelper public static class TaskStateHelper
@@ -28,20 +30,35 @@ public static class TaskStateHelper
{ {
[TaskState.Backlog] = "Backlog", [TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress", [TaskState.InProgress] = "In progress",
[TaskState.Delegated] = "Delegated",
[TaskState.Blocked] = "Blocked", [TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done" [TaskState.Done] = "Done",
[TaskState.Review] = "Review"
}; };
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
{ {
["Backlog"] = TaskState.Backlog, ["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress, ["In progress"] = TaskState.InProgress,
["Delegated"] = TaskState.Delegated,
["Blocked"] = TaskState.Blocked, ["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done ["Done"] = TaskState.Done,
["Review"] = TaskState.Review
};
/// <summary>Mapping from state string to display label.</summary>
private static readonly Dictionary<string, string> DisplayLabels = new(StringComparer.OrdinalIgnoreCase)
{
["Backlog"] = "Offen",
["In progress"] = "In Bearbeitung",
["Delegated"] = "Delegiert",
["Review"] = "Review",
["Blocked"] = "Blockiert",
["Done"] = "Erledigt"
}; };
/// <summary>Valid task-state string values for API validation.</summary> /// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"]; public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"];
/// <summary>Convert a TaskState enum to its API string representation.</summary> /// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state]; public static string ToStateString(this TaskState state) => StateToString[state];
@@ -54,6 +71,10 @@ public static class TaskStateHelper
public static bool IsValidState(string? state) => public static bool IsValidState(string? state) =>
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state); !string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
/// <summary>Returns the German display label for a state string.</summary>
public static string ToDisplayString(string? state) =>
state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? "";
public static bool IsInProgressOrBlocked(string? state) => public static bool IsInProgressOrBlocked(string? state) =>
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase) string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase); || string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
@@ -61,6 +82,77 @@ public static class TaskStateHelper
public static bool IsDoneOrBacklog(string? state) => public static bool IsDoneOrBacklog(string? state) =>
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase) string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase); || string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the caller is allowed to change this task's state.
/// POLICY:
/// - **Iris und Bao** dürfen Status ändern / verschieben.
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern.
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
/// </summary>
public static bool CanChangeState(string? callerAgent, WorkTask task)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
// Sub-agents must never move state
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" };
if (subAgents.Contains(caller)) return false;
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
if (caller == "nexus-system") return true;
// Iris und Bao dürfen Status ändern
return caller == "iris" || caller == "bao";
}
/// <summary>
/// Returns true if the caller is allowed to edit a task's content fields
/// (title, detail, priority, assignedTo, dueDate).
/// POLICY:
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
/// - Nur unbekannte/leere Caller werden abgewiesen.
/// </summary>
public static bool CanEditContent(string? callerAgent)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
if (string.IsNullOrWhiteSpace(caller)) return false;
return true;
}
/// <summary>Group key for board responses (lowercased English state).</summary>
public static string BoardGroupKey(string? state)
{
if (string.IsNullOrWhiteSpace(state)) return "offen";
var lower = state.ToLowerInvariant();
return lower switch
{
"backlog" => "offen",
"in progress" => "inProgress",
"delegated" => "delegated",
"review" => "review",
"blocked" => "blocked",
"done" => "done",
_ => "offen"
};
}
/// <summary>Map a board group key back to the canonical state string.</summary>
public static string? BoardGroupToState(string? groupKey)
{
if (string.IsNullOrWhiteSpace(groupKey)) return null;
var lower = groupKey.ToLowerInvariant();
return lower switch
{
"offen" => "Backlog",
"inprogress" => "In progress",
"delegated" => "Delegated",
"review" => "Review",
"blocked" => "Blocked",
"done" => "Done",
_ => null
};
}
} }
public sealed class Project public sealed class Project
@@ -82,15 +174,45 @@ public sealed class WorkTask
public string Priority { get; set; } = "Normal"; public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao"; public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; } public string? AssignedTo { get; set; }
/// <summary>
/// True if this task was created programmatically by an agent (not manually by Bao).
/// Agent-tasks in the board are subject to stricter workflow rules.
/// </summary>
public bool IsAgentTask { get; set; } = false;
/// <summary>
/// Which agent/user is expected to respond next.
/// Helps Iris see who she is waiting for.
/// </summary>
public string? ExpectedFrom { get; set; }
public Guid? ParentTaskId { get; set; }
public WorkTask? ParentTask { get; set; }
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
public sealed class Notification
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
public string? Message { get; set; } // Detailtext
public required string ForUser { get; set; } // "bao" oder "iris"
public Guid? TaskId { get; set; } // Verknüpfte Task
public bool IsRead { get; set; } = false;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ActivityEvent public sealed class ActivityEvent
{ {
public long Id { get; init; } public long Id { get; init; }
public required string Type { get; set; } public required string Type { get; set; }
public required string Message { get; set; } public required string Message { get; set; }
public Guid? TaskId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
+15
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Nexus.Api.Data; namespace Nexus.Api.Data;
@@ -28,6 +29,20 @@ public class NexusUser
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>(); public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
} }
/// <summary>
/// Tracks one-time seed operations so they are never re-executed — even
/// if the underlying data is deleted. This is the single guard that
/// prevents owner-password drift after DB resets or volume recreations.
/// </summary>
public class SeedAudit
{
[Key]
[MaxLength(80)]
public string Key { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class RefreshToken public class RefreshToken
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -0,0 +1,311 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618214335_AddNotifications")]
partial class AddNotifications
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
table: "Notifications",
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskParentChild : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentTaskId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDueDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DueDate",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DueDate",
table: "Tasks");
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddActivityTaskReference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "TaskId",
table: "Activity",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Activity_TaskId",
table: "Activity",
column: "TaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Activity_TaskId",
table: "Activity");
migrationBuilder.DropColumn(
name: "TaskId",
table: "Activity");
}
}
}
@@ -0,0 +1,270 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618233003_AddDelegatedState")]
partial class AddDelegatedState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddDelegatedState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delegated state is a pure code change to the TaskState enum and
// TaskStateHelper. No schema change required since the State column
// is already a free-form string column.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// No schema to revert.
}
}
}
@@ -0,0 +1,322 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260620174200_AddAgentTaskFields")]
partial class AddAgentTaskFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddAgentTaskFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAgentTask",
table: "Tasks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ExpectedFrom",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks",
column: "IsAgentTask");
migrationBuilder.CreateIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks",
column: "ExpectedFrom");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "IsAgentTask",
table: "Tasks");
}
}
}
@@ -0,0 +1,336 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260621081500_AddSeedAudit")]
partial class AddSeedAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddSeedAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SeedAudit",
columns: table => new
{
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeedAudit", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SeedAudit");
}
}
}
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("character varying(1000)"); .HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type") b.Property<string>("Type")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity"); b.ToTable("Activity");
}); });
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b => modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -166,6 +214,20 @@ namespace Nexus.Api.Migrations
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b => modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -183,6 +245,19 @@ namespace Nexus.Api.Migrations
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority") b.Property<string>("Priority")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -211,6 +286,12 @@ namespace Nexus.Api.Migrations
b.HasIndex("AssignedTo"); b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source"); b.HasIndex("Source");
b.ToTable("Tasks"); b.ToTable("Tasks");
@@ -227,10 +308,25 @@ namespace Nexus.Api.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{ {
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
}); });
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+23 -1
View File
@@ -6,9 +6,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
{ {
public DbSet<Project> Projects => Set<Project>(); public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkTask> Tasks => Set<WorkTask>(); public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>(); public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>(); public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -19,10 +21,30 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
entity.Property(x => x.Detail).HasMaxLength(2000); entity.Property(x => x.Detail).HasMaxLength(2000);
entity.Property(x => x.Source).HasMaxLength(60); entity.Property(x => x.Source).HasMaxLength(60);
entity.Property(x => x.AssignedTo).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.Source);
entity.HasIndex(x => x.AssignedTo); 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<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique(); modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId }); modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Helpers;
using Nexus.Api.Middleware;
using Nexus.Api.Services;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for configuring the Nexus application pipeline and startup.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
/// are deleted — the DB is the single source of truth for the owner password after first seed.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
var configuration = app.Configuration;
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
const string seedKey = "owner_created";
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
// Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync();
}
}
/// <summary>
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
/// </summary>
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseApiKeyAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (env.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
return app;
}
}
@@ -0,0 +1,249 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for registering Nexus application services in the DI container.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
var jwtKey = configuration["Jwt:Key"];
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
/// <summary>
/// Configures rate limiting policies (auth and agents).
/// </summary>
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.ContentType = "application/json";
var retryAfterSeconds = 60;
// Try to read retry-after info from the metadata
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
retryAfterSeconds = (int)retryAfter.TotalSeconds;
}
// Set standard headers
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
var body = new
{
error = "rate_limit_exceeded",
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
remaining = 0,
retryAfterSeconds
};
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
};
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
return services;
}
/// <summary>
/// Configures forwarded headers for reverse proxy scenarios.
/// </summary>
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
return services;
}
/// <summary>
/// Configures Swagger and JSON serialization options.
/// </summary>
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
return services;
}
/// <summary>
/// Registers the Entity Framework Core DbContext with Npgsql.
/// </summary>
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
return services;
}
/// <summary>
/// Registers typed and named HTTP clients for OpenClaw integration.
/// </summary>
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
return services;
}
/// <summary>
/// Registers application domain services (transient, scoped, singleton).
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
return services;
}
/// <summary>
/// Registers data repositories.
/// </summary>
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
return services;
}
/// <summary>
/// Configures health checks (PostgreSQL connectivity and runtime status).
/// </summary>
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
}
}
+37
View File
@@ -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;
}
}
+39
View File
@@ -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>();
}
+72 -3
View File
@@ -86,8 +86,14 @@ public sealed record DashboardTaskDto(
string State, string State,
string Priority, string Priority,
string? AssignedTo, string? AssignedTo,
Guid? ParentTaskId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt, DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt DateTimeOffset UpdatedAt,
bool IsAgentTask = false,
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null
); );
public sealed record CreateDashboardTaskRequest( public sealed record CreateDashboardTaskRequest(
@@ -95,7 +101,18 @@ public sealed record CreateDashboardTaskRequest(
string? Detail, string? Detail,
string? Source, string? Source,
string? Priority, 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( public sealed record UpdateDashboardTaskRequest(
@@ -103,7 +120,8 @@ public sealed record UpdateDashboardTaskRequest(
string? Detail, string? Detail,
string? Source, string? Source,
string? Priority, string? Priority,
string? AssignedTo string? AssignedTo,
DateTimeOffset? DueDate = null
); );
public sealed record UpdateDashboardTaskStatusRequest( public sealed record UpdateDashboardTaskStatusRequest(
@@ -114,3 +132,54 @@ public sealed record AgentActivityEntry(
string Time, string Time,
string Text string Text
); );
// ── Task Board DTOs ──
public sealed record BoardResponse(
List<DashboardTaskDto> Offen,
List<DashboardTaskDto> InProgress,
List<DashboardTaskDto> Delegated,
List<DashboardTaskDto> Review,
List<DashboardTaskDto> Blocked,
List<DashboardTaskDto> Done
);
public sealed record MoveTaskRequest(
string State
);
public sealed record ResetStaleRequest(
int StaleHours = 2
);
public sealed record ResetStaleResponse(
int ResetCount
);
public sealed record PostActivityRequest(
string Message,
string? Type = null
);
// ── Agent Workflow DTOs ──
/// <summary>
/// Overview of the agent workflow state, grouping tasks by expected respondent
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
/// </summary>
public sealed record AgentWorkflowOverview(
List<DashboardTaskDto> WaitingForBao,
List<DashboardTaskDto> WaitingForIris,
List<DashboardTaskDto> WaitingForOthers,
List<DashboardTaskDto> StaleTasks,
TimeSpan StaleThreshold
);
// ── Notification DTOs ──
public sealed record NotificationDto(
Guid Id, string Type, string Title, string? Message,
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
);
public sealed record UnreadCountDto(int Count);
+20
View File
@@ -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
View File
@@ -1,234 +1,26 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Nexus.Api.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Middleware;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- JWT Configuration --- // --- Service Registration ---
var jwtKey = builder.Configuration["Jwt:Key"]; builder.Services.AddNexusAuth(builder.Configuration);
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; builder.Services.AddNexusRateLimiting();
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; builder.Services.AddNexusForwardedHeaders();
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) builder.Services.AddNexusSwagger();
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddNexusApplicationServices();
.AddJwtBearer(options => builder.Services.AddNexusRepositories();
{ builder.Services.AddNexusHealthChecks(builder.Configuration);
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
// --- Rate Limiting ---
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
// --- Forwarded Headers ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// --- Swagger & JSON ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// --- Database ---
builder.Services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
// --- HTTP Clients ---
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(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 ---
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
// --- Database Migration & Owner Seeding --- // --- Database Migration & Seeding ---
await using (var scope = app.Services.CreateAsyncScope()) await app.EnsureDatabaseAsync();
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = builder.Configuration["Owner:Password"];
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseForwardedHeaders(); app.UseNexusPipeline(app.Environment);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
// --- Helpers ---
static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
@@ -0,0 +1,84 @@
using System.Collections.Concurrent;
namespace Nexus.Api.RateLimiting;
/// <summary>
/// Simple in-memory tracking of login attempts per IP,
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
///
/// Provides remaining-attempt count that can be passed back to the frontend.
/// </summary>
public sealed class LoginAttemptTracker
{
private const int MaxAttempts = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
// IP → (count, windowStartTicks)
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
/// <summary>
/// Registers a failed attempt for the given IP.
/// Returns remaining attempts (0 = locked out until reset).
/// </summary>
public int RecordFailedAttempt(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
var (count, windowStart) = _store.AddOrUpdate(ip,
_ => (1, now),
(_, entry) =>
{
if (now - entry.WindowStartTicks >= windowTicks)
return (1, now);
return (entry.Count + 1, entry.WindowStartTicks);
});
return Math.Max(0, MaxAttempts - count);
}
/// <summary>
/// Returns the remaining attempts for the given IP without recording.
/// </summary>
public int GetRemaining(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (_store.TryGetValue(ip, out var entry))
{
if (now - entry.WindowStartTicks >= windowTicks)
return MaxAttempts;
return Math.Max(0, MaxAttempts - entry.Count);
}
return MaxAttempts;
}
/// <summary>
/// Returns the number of seconds until the rate-limit window resets,
/// or 0 if the window has already expired / no attempts recorded.
/// </summary>
public int GetRetryAfterSeconds(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (!_store.TryGetValue(ip, out var entry))
return 0;
var elapsed = now - entry.WindowStartTicks;
if (elapsed >= windowTicks)
return 0;
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
}
/// <summary>
/// Resets attempt count for the given IP (e.g. on success).
/// </summary>
public void Reset(string ip)
{
_store.TryRemove(ip, out _);
}
}
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct); => db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0)
return Task.FromResult(new List<ActivityEvent>());
return db.Activity.AsNoTracking()
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync( public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default) string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
{ {
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
public interface IActivityRepository public interface IActivityRepository
{ {
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default); Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync( Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default); string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default); Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
+2
View File
@@ -7,8 +7,10 @@ public interface IUserRepository
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default); ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default); Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
Task<bool> AnyUsersAsync(CancellationToken ct = default); Task<bool> AnyUsersAsync(CancellationToken ct = default);
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default); Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default);
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
+1
View File
@@ -30,6 +30,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default) public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{ {
task.UpdatedAt = DateTimeOffset.UtcNow; task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
+14
View File
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default) public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); => db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
public Task<bool> AnyUsersAsync(CancellationToken ct = default) public Task<bool> AnyUsersAsync(CancellationToken ct = default)
=> db.Users.AnyAsync(ct); => db.Users.AnyAsync(ct);
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateAsync(NexusUser user, CancellationToken ct = default) public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
=> db.SaveChangesAsync(ct); => db.SaveChangesAsync(ct);
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
{
// Remove refresh tokens first
var tokens = await db.RefreshTokens
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
db.RefreshTokens.RemoveRange(tokens);
db.Users.Remove(user);
await db.SaveChangesAsync(ct);
}
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default) public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
=> db.RefreshTokens => db.RefreshTokens
.Include(r => r.User) .Include(r => r.User)
+13
View File
@@ -0,0 +1,13 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface INotificationService
{
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
}
+17 -2
View File
@@ -1,5 +1,6 @@
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services; namespace Nexus.Api.Services;
@@ -21,9 +22,23 @@ public interface ITaskService
// Dashboard-facing task operations // Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default); Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(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<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, 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> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(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);
} }
+61
View File
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification
{
Type = type,
Title = title,
Message = message,
ForUser = forUser.ToLowerInvariant(),
TaskId = taskId
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
return notification;
}
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var query = db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant());
if (unreadOnly)
query = query.Where(n => !n.IsRead);
return await query
.OrderByDescending(n => n.CreatedAt)
.Take(limit)
.ToListAsync(ct);
}
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
{
var notification = await db.Notifications.FindAsync([id], ct);
if (notification is null) return false;
notification.IsRead = true;
await db.SaveChangesAsync(ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var count = await db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
return count;
}
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
{
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
}
+474 -23
View File
@@ -1,19 +1,34 @@
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
namespace Nexus.Api.Services; namespace Nexus.Api.Services;
public sealed class TaskService( public sealed class TaskService(
ITaskRepository taskRepo, ITaskRepository taskRepo,
IActivityRepository activityRepo) : ITaskService IActivityRepository activityRepo,
INotificationService notificationService,
IHttpContextAccessor httpContextAccessor) : ITaskService
{ {
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct); => await taskRepo.GetAllAsync(ct);
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct); => await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
return MapToDtoWithActivity(task, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct); => await taskRepo.GetPendingApprovalAsync(ct);
@@ -26,7 +41,7 @@ public sealed class TaskService(
ProjectId = request.ProjectId ProjectId = request.ProjectId
}; };
await taskRepo.AddAsync(task, ct); 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);
return task; return task;
} }
@@ -40,7 +55,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Done); task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -54,7 +69,7 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Backlog); task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -66,9 +81,15 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct); var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical; task.State = canonical;
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -77,15 +98,27 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct); var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); 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(); 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(); task.Priority = request.Priority.Trim();
}
if (request.ProjectId.HasValue) if (request.ProjectId.HasValue)
{
changes.Add($"Projekt-ID geändert");
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
}
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -97,7 +130,7 @@ public sealed class TaskService(
if (!TaskStateHelper.IsDoneOrBacklog(task.State)) if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task); 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 taskRepo.DeleteAsync(task, ct);
return new TaskOperationResult(TaskOperationOutcome.Success); return new TaskOperationResult(TaskOperationOutcome.Success);
} }
@@ -112,36 +145,208 @@ public sealed class TaskService(
.ToList(); .ToList();
} }
public async Task<WorkTask> CreateDashboardTaskAsync( /// <summary>
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default) /// Returns agent-tasks that are still open and where an agent is expected to respond.
/// Iris Dashboard uses this to see who she is waiting for.
/// </summary>
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{ {
var all = await taskRepo.GetAllAsync(ct);
return all
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
.ThenByDescending(t => t.UpdatedAt)
.ToList();
}
/// <summary>
/// Returns agent-tasks grouped by which agent is expected to respond,
/// with stale-detection: tasks in InProgress/Delegated that haven't been
/// updated within the stale threshold.
/// </summary>
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
}));
var staleTasks = map(agentTasks
.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
// Validate parent task exists if specified
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
if (parent is null)
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
}
var task = new WorkTask var task = new WorkTask
{ {
Title = title.Trim(), Title = title.Trim(),
Detail = detail?.Trim(), Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(), Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = assignedTo?.Trim() AssignedTo = ValidateAssignedTo(assignedTo),
ParentTaskId = parentTaskId
}; };
await taskRepo.AddAsync(task, ct); await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
var message = $"Task \"{task.Title}\" created ({task.Source})";
if (parentTaskId.HasValue)
message += $" [child of {parentTaskId.Value}]";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
// Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_assigned",
$"Neue Aufgabe: {task.Title}",
detail,
"bao",
task.Id,
ct);
}
return task;
}
public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
{
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
// Persist the agent-task-specific fields
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "agent_task",
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
TaskId = task.Id
}, ct);
// Notify iris about new agent-task
await notificationService.CreateAsync(
"agent_task_created",
$"Neuer Agent-Task: {task.Title}",
detail,
"iris",
task.Id,
ct);
return task; return task;
} }
public async Task<TaskOperationResult> UpdateDashboardTaskAsync( 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); var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim(); var changes = new List<string>();
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim(); if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim(); {
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim(); changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
task.Title = title.Trim();
}
if (detail is not null)
{
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
{
changes.Add("Beschreibung aktualisiert");
task.Detail = newDetail;
}
}
if (!string.IsNullOrWhiteSpace(source))
task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
task.Priority = priority.Trim();
}
if (assignedTo is not null)
{
var validated = ValidateAssignedTo(assignedTo);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
task.AssignedTo = validated;
}
}
if (dueDate.HasValue)
{
if (task.DueDate?.Date != dueDate.Value.Date)
{
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
}
await taskRepo.UpdateAsync(task, ct); 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);
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
$"{changeSummary}",
"iris",
task.Id,
ct);
}
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -153,10 +358,16 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct); var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound); if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical; task.State = canonical;
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -167,7 +378,7 @@ public sealed class TaskService(
task.State = "Done"; task.State = "Done";
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
@@ -185,7 +396,247 @@ public sealed class TaskService(
}; };
await taskRepo.UpdateAsync(task, ct); 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);
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
// ── Board operations ──
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var delegated = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in all)
{
var dto = MapToDto(task);
switch (task.State.ToLowerInvariant())
{
case "backlog":
offen.Add(dto); break;
case "in progress":
inProgress.Add(dto); break;
case "delegated":
delegated.Add(dto); break;
case "review":
review.Add(dto); break;
case "blocked":
blocked.Add(dto); break;
case "done":
done.Add(dto); break;
default:
offen.Add(dto); break;
}
}
offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
delegated.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(offen, inProgress, delegated, review, blocked, done);
}
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
// Resolve canonical state: accept board group keys or canonical strings
var canonical = TaskStateHelper.AllStates
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
if (canonical is null)
{
// Try mapping from board group key
canonical = TaskStateHelper.BoardGroupToState(newState);
}
if (canonical is null)
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{
var normalizedHours = Math.Max(1, staleHours);
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
}
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
var prevState = task.State;
task.State = "Backlog";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
TaskId = task.Id
}, ct);
}
return staleTasks.Count;
}
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{
var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList();
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
/// </summary>
private static string? ValidateAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var lower = assignedTo.Trim().ToLowerInvariant();
return ValidAssignees.Contains(lower) ? lower : null;
}
/// <summary>
/// Resolves the caller identity from the HTTP context.
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
/// </summary>
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system"; // internal system ops allowed
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
/// <summary>
/// Creates status-change notifications when a task moves to a new state.
/// - Wenn Bao ändert → Iris benachrichtigen
/// - Wenn Iris ändert → Bao benachrichtigen
/// - Review/Blocked bekommen spezifische Töne
/// </summary>
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
{
var caller = ResolveCaller();
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_review",
$"Task zur Überprüfung: {task.Title}",
$"Status auf Review geändert von {caller}",
"bao",
task.Id,
ct);
}
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_blocked",
$"Aufgabe blockiert: {task.Title}",
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
"iris",
task.Id,
ct);
}
else
{
// Allgemeine Statusänderung: Gegenüber benachrichtigen
if (caller == "bao")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Bao hat Status geändert: {task.Title}",
$"Status → {canonical}",
"iris",
task.Id,
ct);
}
else if (caller == "iris")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Iris hat Status geändert: {task.Title}",
$"Status → {canonical}",
"bao",
task.Id,
ct);
}
}
}
} }
+43 -10
View File
@@ -4,7 +4,14 @@ services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 384M
reservations:
memory: 96M
environment: environment:
POSTGRES_INITDB_ARGS: --data-checksums
POSTGRES_DB: ${POSTGRES_DB:-nexus} POSTGRES_DB: ${POSTGRES_DB:-nexus}
POSTGRES_USER: ${POSTGRES_USER:-nexus} POSTGRES_USER: ${POSTGRES_USER:-nexus}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
@@ -28,6 +35,11 @@ services:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
restart_policy: restart_policy:
condition: on-failure condition: on-failure
delay: 5s delay: 5s
@@ -41,17 +53,22 @@ services:
Jwt__Issuer: ${JWT_ISSUER:-nexus} Jwt__Issuer: ${JWT_ISSUER:-nexus}
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web} Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env} Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
# OWNER_PASSWORD is only used during initial seed (first deploy).
# After that the DB is the single source of truth, enforced by SeedAudit.
# Default: empty (seed uses a random password if unset on first run).
Owner__Password: ${OWNER_PASSWORD:-} Owner__Password: ${OWNER_PASSWORD:-}
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner} Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789} Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-} Admin__ResetToken: ${Admin__ResetToken:-}
NexusApiKey: ${NEXUS_API_KEY:-}
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_started
restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s interval: 30s
@@ -59,13 +76,13 @@ services:
retries: 3 retries: 3
start_period: 15s start_period: 15s
volumes: volumes:
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro - /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris - /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer - /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer - /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt - /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher - /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor - /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
networks: networks:
- nexus - nexus
- openclaw_default - openclaw_default
@@ -80,23 +97,37 @@ services:
context: ./frontend context: ./frontend
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
restart_policy: restart_policy:
condition: on-failure condition: on-failure
delay: 5s delay: 5s
max_attempts: 3 max_attempts: 3
window: 120s window: 120s
labels:
- "traefik.enable=true"
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
- "traefik.http.routers.nexus.tls=true"
- "traefik.http.routers.nexus.tls.certresolver=letsencrypt"
- "traefik.http.services.nexus.loadbalancer.server.port=80"
ports: ports:
- "127.0.0.1:18880:80" - "127.0.0.1:18880:80"
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_started
restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 10s start_period: 10s
networks: [nexus] networks:
- nexus
- proxy
logging: logging:
driver: "json-file" driver: "json-file"
options: options:
@@ -107,6 +138,8 @@ networks:
nexus: nexus:
openclaw_default: openclaw_default:
external: true external: true
proxy:
external: true
volumes: volumes:
nexus-postgres: nexus-postgres:
@@ -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 weve found it to be very fast and reliable.
[![npm version](https://img.shields.io/npm/v/pnpm.svg?label=latest)](https://github.com/pnpm/pnpm/releases/latest)
[![Join the chat at Discord](https://img.shields.io/discord/731599538665553971.svg)](https://r.pnpm.io/chat)
[![OpenCollective](https://opencollective.com/pnpm/backers/badge.svg)](https://opencollective.com/pnpm)
[![OpenCollective](https://opencollective.com/pnpm/sponsors/badge.svg)](https://opencollective.com/pnpm)
[![X Follow](https://img.shields.io/twitter/follow/pnpmjs.svg?style=social&label=Follow)](https://x.com/intent/follow?screen_name=pnpmjs&region=follow_link)
[![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](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:
![](https://pnpm.io/img/benchmarks/alotta-files.svg)
## 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"
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ const activeView = computed(() => {
const routePaths: Record<string, string> = { const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security', Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar', Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings', Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
} }
const navigate = (label: string) => { const navigate = (label: string) => {
@@ -32,7 +32,7 @@ const navigate = (label: string) => {
} }
const mobileNavOpen = ref(false) const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value)) const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) store.refresh() if (auth.isAuthenticated) store.refresh()
@@ -87,6 +87,12 @@ const statusColors: Record<string, string> = {
idle: 'var(--st-idle)', idle: 'var(--st-idle)',
block: 'var(--st-block)', 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> </script>
<template> <template>
@@ -99,7 +105,7 @@ const statusColors: Record<string, string> = {
<!-- Header --> <!-- Header -->
<div class="m-head"> <div class="m-head">
<div :class="['m-av', { iris: agent.id === 'iris' }]"> <div :class="['m-av', { iris: agent.id === 'iris' }]">
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }} {{ avatarLabel() }}
</div> </div>
<div style="flex:1; min-width:0"> <div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div> <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 class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</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 --> <!-- Modell wählen -->
<div class="m-sec"> <div class="m-sec">
<h4>Modell wählen</h4> <h4>Modell wählen</h4>
@@ -519,6 +535,35 @@ const statusColors: Record<string, string> = {
@keyframes blink { 50% { opacity: 0; } } @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 ──────────────────────────────────── */ /* ── Models ──────────────────────────────────── */
.m-models { .m-models {
display: flex; display: flex;
@@ -33,7 +33,11 @@ defineEmits<{
{ entering } { entering }
]" ]"
:style="{ left: left + '%', top: top + '%' }" :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"> <div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot --> <!-- Header: Avatar + Name + Role + Status-Dot -->
@@ -19,6 +19,7 @@ defineProps<{
blockerCount: number blockerCount: number
todayCost: string todayCost: string
todayTokens: string todayTokens: string
blockerLabel?: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -62,7 +63,7 @@ defineEmits<{
@click="$emit('blockerClick')" @click="$emit('blockerClick')"
> >
<span class="dot block"></span> <span class="dot block"></span>
{{ blockerCount }} Blocker {{ blockerLabel || `${blockerCount} Blocker` }}
</button> </button>
</div> </div>
</template> </template>
@@ -168,4 +169,24 @@ defineEmits<{
.blk:hover { .blk:hover {
background: rgba(251,113,133,.22); 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> </style>
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout' import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons' import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue' import AgentNode from './AgentNode.vue'
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
const props = defineProps<{ const props = defineProps<{
agents: AgentNodeData[] agents: AgentNodeData[]
@@ -113,8 +114,8 @@ function renderEdges() {
} else { } else {
// Orchestration (Iris → Agent) // Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b) const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18 const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>` 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 ──────────────────────────────── */ /* ── Drag & Drop ──────────────────────────────── */
const DRAG_THRESHOLD = 5 const {
onClick,
interface DragState { onClickCapture,
id: string onPointerDown,
startX: number onPointerMove,
startY: number onPointerUp,
ox: number } = useFlowCanvasInteractions({
oy: number flowRef,
moved: boolean renderEdges,
raf: number | null updatePositions: positions => emit('updatePositions', positions),
} selectAgent: id => emit('select', id),
getPositions: () => props.positions,
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
})
}
}
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 ── */ /* ── Keyboard handler for Enter key on buttons ── */
function handleReset() { function handleReset() {
@@ -271,6 +198,8 @@ function handleReset() {
<div <div
ref="flowRef" ref="flowRef"
class="flow" class="flow"
@click="onClick"
@click.capture="onClickCapture"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
@@ -288,12 +217,12 @@ function handleReset() {
@click="handleReset" @click="handleReset"
> >
<span class="btn-icon" v-html="icons.flow || ''"></span> <span class="btn-icon" v-html="icons.flow || ''"></span>
Reset <span class="reset-label">Reset</span>
</button> </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> <span class="btn-icon" v-html="icons.plus || ''"></span>
Agent hinzufügen <span class="add-label">Agent hinzufügen</span>
</button> </button>
</div> </div>
@@ -481,4 +410,28 @@ function handleReset() {
:deep(.node.dragging) { :deep(.node.dragging) {
cursor: grabbing; 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> </style>
@@ -251,6 +251,22 @@ watch(
@keyframes blink { 50% { opacity: 0; } } @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 ───────────────────────────────────── */ /* ── Input ───────────────────────────────────── */
.chat-in { .chat-in {
padding: 12px; padding: 12px;
@@ -146,4 +146,20 @@ function statusLabel(s: TaskItem['status']): string {
padding: 12px; padding: 12px;
white-space: nowrap; 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> </style>
@@ -16,6 +16,8 @@ export interface TaskItem {
priority: 'high' | 'medium' | 'low' priority: 'high' | 'medium' | 'low'
status: 'active' | 'pending' | 'blocked' status: 'active' | 'pending' | 'blocked'
progress: number // 0100 progress: number // 0100
detail?: string | null
source?: string
} }
/* ── Agent Detail Modal Types ─────────────────── */ /* ── Agent Detail Modal Types ─────────────────── */
@@ -26,6 +28,11 @@ export interface ThinkingItem {
ts: string ts: string
} }
export interface AgentActivityItem {
time: string
text: string
}
/** Dashboard view-model for an agent detail modal */ /** Dashboard view-model for an agent detail modal */
export interface AgentDetailData { export interface AgentDetailData {
id: string id: string
@@ -51,5 +58,6 @@ export interface AgentDetailData {
lastActive: string lastActive: string
activeTaskCount: number activeTaskCount: number
thinking: ThinkingItem[] thinking: ThinkingItem[]
activity: AgentActivityItem[]
availableModels: { id: string; alias: string }[] 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,
}
}
+13 -2
View File
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { import {
Activity, Bot, Boxes, Command, FileText, Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings, LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen, Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar, AlertTriangle, Calendar,
} from '@lucide/vue' } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { initials } from '../../utils/format' import { initials } from '../../utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -23,6 +24,11 @@ const emit = defineEmits<{
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const ownerInitials = computed(() => const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW' auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
@@ -37,6 +43,7 @@ const navigation = [
{ label: 'Task Board', icon: ListTodo }, { label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle }, { label: 'Incidents', icon: AlertTriangle },
{ separator: true }, { separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar }, { label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot }, { label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal }, { label: 'Models', icon: SlidersHorizontal },
@@ -76,6 +83,7 @@ async function logout() {
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i> <i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</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> </button>
</template> </template>
</nav> </nav>
@@ -154,6 +162,9 @@ async function logout() {
border-radius: 5px; border-radius: 5px;
line-height: 1.4; line-height: 1.4;
} }
.nav button i.badge-red {
background: #e16e75;
}
.nav-separator { .nav-separator {
height: 1px; height: 1px;
margin: 6px 10px; margin: 6px 10px;
+58 -1
View File
@@ -6,6 +6,14 @@ import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons' import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons' import type { NavGroupDef } from '../../composables/icons'
defineProps<{
mobileOpen?: boolean
}>()
defineEmits<{
close: []
}>()
import NavGroup from './NavGroup.vue' import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format' import { initials } from '../../utils/format'
@@ -63,7 +71,8 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
</script> </script>
<template> <template>
<aside class="sidebar"> <aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand --> <!-- Brand -->
<div class="side-top"> <div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div> <div class="brand-mark" v-html="icons.command || ''"></div>
@@ -171,6 +180,54 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
background: rgba(124,108,255,.06); 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 { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
+72 -3
View File
@@ -3,11 +3,19 @@ import { icons } from '../../composables/icons'
defineProps<{ defineProps<{
connected?: boolean connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>() }>()
</script> </script>
<template> <template>
<header class="topbar"> <header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search --> <!-- Search -->
<div class="search"> <div class="search">
<span class="search-icon" v-html="icons.search || ''"></span> <span class="search-icon" v-html="icons.search || ''"></span>
@@ -20,13 +28,13 @@ defineProps<{
<!-- Status Pill --> <!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']"> <span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span> <span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? 'OpenClaw verbunden' : 'Preview' }} {{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span> </span>
<!-- Ask Iris Button --> <!-- 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> <span class="btn-icon" v-html="icons.spark || ''"></span>
Ask Iris <span class="ask-label">Ask Iris</span>
</button> </button>
</header> </header>
</template> </template>
@@ -138,4 +146,65 @@ defineProps<{
height: 15px; 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> </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,
}
}
+46 -4
View File
@@ -3,22 +3,46 @@
* NexusLayout — V2 Dashboard Shell * NexusLayout — V2 Dashboard Shell
* Flex row, 100vh, overflow hidden. * Flex row, 100vh, overflow hidden.
* Sidebar (248px) + Main (flex:1, flex-column) * Sidebar (248px) + Main (flex:1, flex-column)
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
*/ */
import { ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useAgentStore } from '../stores/agents' import { useDashboardStore } from '../stores/dashboard'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue' import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue' import Topbar from '../components/layout/Topbar.vue'
const agentStore = useAgentStore() const dashboardStore = useDashboardStore()
/* ── Mobile Sidebar State ───────────────────────── */
const mobileMenuOpen = ref(false)
function closeMobileMenu() {
mobileMenuOpen.value = false
}
</script> </script>
<template> <template>
<div class="nexus-layout"> <div class="nexus-layout">
<GalaxyBackground /> <GalaxyBackground />
<Sidebar /> <Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop -->
<div
v-if="mobileMenuOpen"
class="mobile-backdrop"
@click="closeMobileMenu"
></div>
<main class="nexus-main"> <main class="nexus-main">
<Topbar :connected="agentStore.isConnected" /> <Topbar
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content"> <div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
@@ -49,4 +73,22 @@ const agentStore = useAgentStore()
overflow: hidden; overflow: hidden;
min-height: 0; 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> </style>
+6 -1
View File
@@ -11,6 +11,9 @@ import IncidentsView from './views/IncidentsView.vue'
import CalendarView from './views/CalendarView.vue' import CalendarView from './views/CalendarView.vue'
import NexusLayout from './layouts/NexusLayout.vue' import NexusLayout from './layouts/NexusLayout.vue'
import FlowBoard from './views/Dashboard/FlowBoard.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 = [ const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } }, { path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
@@ -33,11 +36,13 @@ const routes = [
{ path: '/calendar', name: 'Calendar', component: CalendarView }, { path: '/calendar', name: 'Calendar', component: CalendarView },
{ path: '/projects', name: 'Projects', component: { template: '' } }, { path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView }, { 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: '/agents', name: 'Agents', component: AgentsIndexView },
{ path: '/models', name: 'Models', component: { template: '' } }, { path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } }, { path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } }, { path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView },
{ path: '/settings', name: 'Settings', component: SettingsView }, { path: '/settings', name: 'Settings', component: SettingsView },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
] ]
+27 -2
View File
@@ -11,7 +11,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiFetch } from '../services/api' import { apiFetch } from '../services/api'
import type { AgentNodeData } from '../composables/useFlowLayout' 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 ──────────────────────────── */ /* ── API Response Shapes ──────────────────────────── */
@@ -40,6 +40,11 @@ interface ModelOption {
provider: string provider: string
} }
interface AgentActivityEntry {
time: string
text: string
}
/* ── Status Mapping ───────────────────────────────── */ /* ── Status Mapping ───────────────────────────────── */
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] { 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', lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
activeTaskCount: data.task ? 1 : 0, activeTaskCount: data.task ? 1 : 0,
thinking: buildThinkingItems(data), thinking: buildThinkingItems(data),
activity: [],
availableModels: models, availableModels: models,
} }
} }
@@ -159,6 +165,7 @@ export const useAgentStore = defineStore('agents', {
loading: false, loading: false,
error: null as string | null, error: null as string | null,
selectedAgentId: null as string | null, selectedAgentId: null as string | null,
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
refreshInterval: null as ReturnType<typeof setInterval> | null, refreshInterval: null as ReturnType<typeof setInterval> | null,
isConnected: false, isConnected: false,
}), }),
@@ -179,7 +186,10 @@ export const useAgentStore = defineStore('agents', {
if (!state.selectedAgentId) return null if (!state.selectedAgentId) return null
const data = state.agents.find(a => a.id === state.selectedAgentId) const data = state.agents.find(a => a.id === state.selectedAgentId)
if (!data) return null if (!data) return null
return buildAgentDetail(data, state.models) return {
...buildAgentDetail(data, state.models),
activity: state.activityByAgentId[data.id] ?? [],
}
}, },
/** Is the modal open? */ /** 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 ───────────────────────────────── */ /* ── Selection ───────────────────────────────── */
selectAgent(id: string | null) { selectAgent(id: string | null) {
this.selectedAgentId = id this.selectedAgentId = id
if (id) void this.fetchAgentActivity(id)
}, },
/* ── Polling ─────────────────────────────────── */ /* ── Polling ─────────────────────────────────── */
+86 -3
View File
@@ -13,6 +13,12 @@ interface AuthPayload {
user: AuthUser user: AuthUser
} }
interface LoginErrorInfo {
message: string
remaining: number
retryAfterSeconds: number
}
let refreshInFlight: Promise<boolean> | null = null let refreshInFlight: Promise<boolean> | null = null
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@@ -22,28 +28,51 @@ export const useAuthStore = defineStore('auth', {
user: null as AuthUser | null, user: null as AuthUser | null,
initialized: false, initialized: false,
loading: 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: { getters: {
isAuthenticated: state => Boolean(state.accessToken && state.user), 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: { actions: {
applySession(payload: AuthPayload) { applySession(payload: AuthPayload) {
this.accessToken = payload.accessToken this.accessToken = payload.accessToken
this.expiresAt = payload.expiresAt this.expiresAt = payload.expiresAt
this.user = payload.user this.user = payload.user
this.remainingAttempts = null
this.retryAfterSeconds = 0
}, },
clearSession() { clearSession() {
this.accessToken = null this.accessToken = null
this.expiresAt = null this.expiresAt = null
this.user = null this.user = null
this.remainingAttempts = null
this.retryAfterSeconds = 0
}, },
async initialize() { async initialize() {
if (this.initialized) return this.isAuthenticated if (this.initialized) return this.isAuthenticated
this.initialized = true this.initialized = true
return this.refresh() return this.refresh()
}, },
async login(email: string, password: string) { async login(email: string, password: string): Promise<void> {
this.loading = true this.loading = true
this.remainingAttempts = null
this.retryAfterSeconds = 0
try { try {
const response = await fetch('/api/v1/auth/login', { const response = await fetch('/api/v1/auth/login', {
method: 'POST', method: 'POST',
@@ -52,9 +81,50 @@ export const useAuthStore = defineStore('auth', {
body: JSON.stringify({ email, password }), 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.ok) {
if (response.status === 429) throw new Error('Too many attempts. Please wait one minute.') // Try to parse structured JSON body for rate-limit info
throw new Error('Invalid email or password.') 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) 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
}
}
+110
View File
@@ -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
}
},
},
})
+123
View File
@@ -0,0 +1,123 @@
/**
* 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') {
// Unread count polling every 30s (for sidebar badge)
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
}
},
},
})
+245 -9
View File
@@ -1,9 +1,10 @@
/** /**
* Task Store V2 Dashboard * Task Store V2 Dashboard + Task Board
* *
* Fetches tasks from /api/dashboard/tasks and maps them into * Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
* TaskItem[] format for the TaskStrip component. * and maps them into TaskItem[] format for the TaskStrip component.
* *
* Board state: grouped by column (offen, inProgress, delegated, review, done, blocked)
* Auto-refresh: every 30 seconds. * Auto-refresh: every 30 seconds.
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -12,7 +13,7 @@ import type { TaskItem } from '../components/dashboard/v2/types'
/* ── API Response Shapes ──────────────────────────── */ /* ── API Response Shapes ──────────────────────────── */
interface DashboardTaskDto { export interface DashboardTaskDto {
id: string id: string
title: string title: string
detail: string | null detail: string | null
@@ -20,8 +21,31 @@ interface DashboardTaskDto {
state: string state: string
priority: string priority: string
assignedTo: string | null assignedTo: string | null
parentTaskId?: string | null
dueDate?: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
isAgentTask?: boolean
expectedFrom?: string | null
lastActivityMessage?: string | null
lastActivityAt?: string | null
}
export interface BoardGroup {
offen: DashboardTaskDto[]
inProgress: DashboardTaskDto[]
delegated: DashboardTaskDto[]
review: DashboardTaskDto[]
done: DashboardTaskDto[]
blocked: DashboardTaskDto[]
}
export interface AgentWorkflowOverview {
waitingForBao: DashboardTaskDto[]
waitingForIris: DashboardTaskDto[]
waitingForOthers: DashboardTaskDto[]
staleTasks: DashboardTaskDto[]
staleThreshold: string
} }
/* ── State Mapping ────────────────────────────────── */ /* ── State Mapping ────────────────────────────────── */
@@ -56,6 +80,8 @@ function mapTask(t: DashboardTaskDto): TaskItem {
priority: mapPriority(t.priority), priority: mapPriority(t.priority),
status: mapState(t.state), status: mapState(t.state),
progress: mapProgress(t.state), progress: mapProgress(t.state),
detail: t.detail,
source: t.source,
} }
} }
@@ -65,14 +91,45 @@ export const useTaskStore = defineStore('tasks', {
loading: false, loading: false,
error: null as string | null, error: null as string | null,
refreshInterval: null as ReturnType<typeof setInterval> | null, refreshInterval: null as ReturnType<typeof setInterval> | null,
boardRefreshInterval: null as ReturnType<typeof setInterval> | null,
// Board state
board: {
offen: [] as DashboardTaskDto[],
inProgress: [] as DashboardTaskDto[],
delegated: [] as DashboardTaskDto[],
review: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
blocked: [] 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: { getters: {
taskList: (state) => state.tasks, 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: { actions: {
/* ── API: Fetch tasks ───────────────────────── */ /* ── API: Fetch tasks (for TaskStrip) ─────────── */
async fetchTasks() { async fetchTasks() {
this.loading = true this.loading = true
try { try {
@@ -89,7 +146,99 @@ 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',
delegated: 'Delegated',
review: 'Review',
done: 'Done',
blocked: 'Blocked',
}
// 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.delegated) ??
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) { async addTask(title: string, detail?: string, priority?: string, assignedTo?: string) {
try { try {
const res = await apiFetch('/api/dashboard/tasks', { const res = await apiFetch('/api/dashboard/tasks', {
@@ -110,8 +259,8 @@ export const useTaskStore = defineStore('tasks', {
} }
}, },
/* ── API: Update task ───────────────────────── */ /* ── API: Update task ─────────────────────────── */
async updateTask(id: string, updates: { title?: string; detail?: string; priority?: string; assignedTo?: string }) { async updateTask(id: string, updates: { title?: string; detail?: string | null; priority?: string; assignedTo?: string | null; dueDate?: string | null }) {
try { try {
const res = await apiFetch(`/api/dashboard/tasks/${id}`, { const res = await apiFetch(`/api/dashboard/tasks/${id}`, {
method: 'PUT', method: 'PUT',
@@ -119,12 +268,84 @@ export const useTaskStore = defineStore('tasks', {
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
await this.fetchTasks() await this.fetchTasks()
await this.fetchBoard()
} catch (err) { } catch (err) {
console.warn('[TaskStore] updateTask failed', 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
}) {
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,
}),
})
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() { startPolling() {
if (this.refreshInterval) return if (this.refreshInterval) return
this.fetchTasks() this.fetchTasks()
@@ -139,5 +360,20 @@ export const useTaskStore = defineStore('tasks', {
this.refreshInterval = null this.refreshInterval = null
} }
}, },
startBoardPolling() {
if (this.boardRefreshInterval) return
this.fetchBoard()
this.boardRefreshInterval = setInterval(() => {
this.fetchBoard()
}, 30000)
},
stopBoardPolling() {
if (this.boardRefreshInterval) {
clearInterval(this.boardRefreshInterval)
this.boardRefreshInterval = null
}
},
}, },
}) })
+47 -56
View File
@@ -12,84 +12,62 @@
* *
* Polling startet bei Mount, stoppt bei Unmount. * Polling startet bei Mount, stoppt bei Unmount.
*/ */
import { ref, onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { useAgentStore } from '../../stores/agents' import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat' import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import AlertBar from '../../components/dashboard/v2/AlertBar.vue' import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue' import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import IrisChat from '../../components/dashboard/v2/IrisChat.vue' import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue' import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue' import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
import type { AgentNodeData } from '../../composables/useFlowLayout' import { useFlowBoardState } from '../../composables/useFlowBoardState'
import { extraAgentPool } from '../../composables/useFlowLayout'
/* ── Stores ──────────────────────────────────────── */ /* ── Stores ──────────────────────────────────────── */
const agentStore = useAgentStore() const agentStore = useAgentStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const dashboardStore = useDashboardStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
/* ── Agent Layout State ───────────────────────────── */ const {
const agentPositions = ref<Record<string, { x: number; y: number }>>({}) addAgent,
const enteringIds = ref<string[]>([]) agentPositions,
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool]) changeModel,
closeAgent,
/* ── Event Handlers ───────────────────────────────── */ enteringIds,
resetLayout,
function handleSelect(id: string) { selectAgent,
agentStore.selectAgent(id) sendChatMessage,
} updatePositions,
} = useFlowBoardState(agentStore, chatStore)
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 }
}
function handleBlockerClick() { function handleBlockerClick() {
console.log('[FlowBoard] blocker clicked') console.log('[FlowBoard] blocker clicked')
} }
function handleChatSend(text: string) { function blockerLabel() {
chatStore.sendMessage(text) const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
if (!blockedTask) return undefined
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
}
function blockerCount() {
return taskStore.taskList.filter(task => task.status === 'blocked').length
} }
/* ── Lifecycle ────────────────────────────────────── */ /* ── Lifecycle ────────────────────────────────────── */
onMounted(() => { onMounted(() => {
agentStore.startPolling() agentStore.startPolling()
chatStore.startPolling() chatStore.startPolling()
dashboardStore.startPolling()
taskStore.startPolling() taskStore.startPolling()
}) })
onUnmounted(() => { onUnmounted(() => {
agentStore.stopPolling() agentStore.stopPolling()
chatStore.stopPolling() chatStore.stopPolling()
dashboardStore.stopPolling()
taskStore.stopPolling() taskStore.stopPolling()
}) })
</script> </script>
@@ -104,9 +82,10 @@ onUnmounted(() => {
:active-count="agentStore.activeCount" :active-count="agentStore.activeCount"
:think-count="agentStore.thinkCount" :think-count="agentStore.thinkCount"
:idle-count="agentStore.idleCount" :idle-count="agentStore.idleCount"
:blocker-count="agentStore.blockerCount" :blocker-count="blockerCount()"
:today-cost="agentStore.todayCost" :today-cost="agentStore.todayCost"
:today-tokens="agentStore.todayTokens" :today-tokens="agentStore.todayTokens"
:blocker-label="blockerLabel()"
@blocker-click="handleBlockerClick" @blocker-click="handleBlockerClick"
/> />
@@ -114,10 +93,10 @@ onUnmounted(() => {
:agents="agentStore.agentList" :agents="agentStore.agentList"
:positions="agentPositions" :positions="agentPositions"
:entering-ids="enteringIds" :entering-ids="enteringIds"
@select="handleSelect" @select="selectAgent"
@add="handleAdd" @add="addAgent"
@reset-layout="handleResetLayout" @reset-layout="resetLayout"
@update-positions="handleUpdatePositions" @update-positions="updatePositions"
/> />
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" /> <TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
@@ -128,7 +107,7 @@ onUnmounted(() => {
:messages="chatStore.messageList" :messages="chatStore.messageList"
:is-thinking="chatStore.isThinking" :is-thinking="chatStore.isThinking"
:error="chatStore.error" :error="chatStore.error"
@send="handleChatSend" @send="sendChatMessage"
/> />
</div> </div>
@@ -137,9 +116,9 @@ onUnmounted(() => {
v-if="agentStore.modalOpen && agentStore.selectedAgent" v-if="agentStore.modalOpen && agentStore.selectedAgent"
:agent="agentStore.selectedAgent" :agent="agentStore.selectedAgent"
:agent-order="agentStore.agentOrder" :agent-order="agentStore.agentOrder"
@close="handleCloseModal" @close="closeAgent"
@select="handleSelect" @select="selectAgent"
@change-model="handleChangeModel" @change-model="changeModel"
/> />
</div> </div>
</template> </template>
@@ -177,4 +156,16 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 767px) {
.board-body {
flex-direction: column;
padding: 8px;
gap: 10px;
}
.stage {
flex: 1;
}
}
</style> </style>
+444 -24
View File
@@ -1,15 +1,57 @@
<script setup lang="ts"> <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 { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const email = ref('') const email = ref('')
const password = ref('') const password = ref('')
const error = 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() { async function submit() {
error.value = '' error.value = ''
@@ -20,42 +62,420 @@ async function submit() {
: '/dashboard' : '/dashboard'
await router.replace(target) await router.replace(target)
} catch (reason) { } 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> </script>
<template> <template>
<main class="login-page"> <div class="login-container">
<section class="login-card"> <GalaxyBackground />
<div class="login-content">
<!-- Branding -->
<div class="login-brand"> <div class="login-brand">
<div class="brand-mark"><Command :size="20" /></div> <div class="brand-icon">
<div><strong>NEXUS</strong><span>Noveria Operations</span></div> <Command :size="22" />
</div>
<span class="brand-name">NEXUS</span>
<span class="brand-sub">Mission Control · Noveria</span>
</div> </div>
<div class="login-heading"> <!-- Login Card -->
<span class="eyebrow">OWNER ACCESS</span> <div class="login-card">
<h1>Sign in to mission control</h1> <div class="card-header">
<p>Use your private owner credentials to continue.</p> <span class="eyebrow">AUTHENTIFIZIERUNG</span>
<h1>Anmelden</h1>
<p>Gib deine Zugangsdaten ein, um auf das Mission Control zuzugreifen.</p>
</div> </div>
<form @submit.prevent="submit"> <form @submit.prevent="submit" class="login-form">
<label> <div class="field">
<span>Email</span> <label for="email">
<input v-model="email" type="email" autocomplete="username" required maxlength="120" /> <Mail :size="14" />
<span>E-Mail</span>
</label> </label>
<label> <input
<span>Password</span> id="email"
<input v-model="password" type="password" autocomplete="current-password" required minlength="10" maxlength="200" /> 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> </label>
<p v-if="error" class="login-error" role="alert">{{ error }}</p> <div class="password-wrap">
<button type="submit" :disabled="auth.loading"> <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" /> <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> </button>
</form> </form>
<footer>Protected owner session · Refresh token stored in a secure HTTP-only cookie</footer> <footer class="card-footer">
</section> <span class="lock-icon">🔒</span>
</main> <span>Gesicherte Sitzung · Refresh-Token im HTTP-only Cookie</span>
</footer>
</div>
</div>
</div>
</template> </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>
+249
View File
@@ -0,0 +1,249 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore()
const router = useRouter()
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()
})
onUnmounted(() => {
store.stopListPolling()
})
</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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35 -1
View File
@@ -25,7 +25,41 @@ docker compose ps
echo "" echo ""
echo "[4/4] Verifikation..." echo "[4/4] Verifikation..."
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden" check_code() {
local path="$1"
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
}
HEALTH_CODE=$(check_code /health)
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
WEB_CID="$(docker compose ps -q web || true)"
if [ -n "$WEB_CID" ]; then
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
if [ "$WEB_STATE" = "created" ]; then
echo " ️ API healthy, aber web noch im Status 'created' — starte web nach"
docker compose up -d web
sleep 2
DASHBOARD_CODE=$(check_code /dashboard)
OPS_CODE=$(check_code /api/v1/operations/snapshot)
fi
fi
fi
echo " /health -> ${HEALTH_CODE}"
echo " /dashboard -> ${DASHBOARD_CODE}"
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
echo " ❌ Verifikation fehlgeschlagen"
exit 1
fi
echo " ✅ Health-Check bestanden"
echo " ✅ Dashboard erreichbar"
echo " ✅ Operations API fordert Auth an"
echo "" echo ""
echo "=== Deployment abgeschlossen ===" echo "=== Deployment abgeschlossen ==="
+55
View File
@@ -0,0 +1,55 @@
# ==============================================================================
# Noveria.net Landingpage — Nginx Server Block
# ==============================================================================
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
#
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
# ==============================================================================
server {
listen 443 ssl http2;
server_name noveria.net www.noveria.net;
# SSL (gleiche Zertifikate wie nexus)
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
include /etc/nginx/snippets/ssl-params.conf;
# Security Header
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
location / {
proxy_pass http://127.0.0.1:18881;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name noveria.net www.noveria.net;
return 301 https://$host$request_uri;
}
# ==============================================================================
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
# ==============================================================================
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
# grep -rn "noveria.net" /etc/nginx/sites-available/
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
#
# 2. Config testen nach Änderung
# nginx -t
#
# 3. Nginx neuladen
# systemctl reload nginx
# ==============================================================================
+95 -1
View File
@@ -1,7 +1,101 @@
# Changelog # Changelog
> Letzte Aktualisierung: 2026-06-09 > Letzte Aktualisierung: 2026-06-21
- 2026-06-21: **Permanenter Owner-Passwort-Persistenz-Fix (SeedAudit + Single Source of Truth).**
- Root Cause: Dual-Source-Architektur (Gitea-Secret vs Host-.env) verursachte Passwort-Drift nach DB-Reseed.
- Code-Fix: `SeedAudit`-Entity + Migration (`20260621081500_AddSeedAudit`) eingebaut. `EnsureDatabaseAsync` prueft jetzt `SeedAudit` VOR dem Seeden. Key `owner_created` blockiert erneutes Seeden permanent.
- Workflow-Fix: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` aus dem Host-`.env` (Single Source of Truth), nicht mehr aus Gitea-Secret.
- `compose.yaml`: Kommentar hinzugefuegt dass OWNER_PASSWORD nur beim initialen Seed verwendet wird.
- Verifikation: Login funktioniert nach `docker compose down && up`, `--force-recreate`, und `restart`.
- Git: Commit `f95463e`, manuell ausgerollt.
- Betroffene Dateien: `ApplicationBuilderExtensions.cs`, `Identity.cs`, `NexusDbContext.cs`, `20260621081500_AddSeedAudit.cs`, `NexusDbContextModelSnapshot.cs`, `deploy.yaml`, `rollback.yaml`, `compose.yaml`, `nexus.md`, `phases/deployment.md`.
- 2026-06-20: **Agent-Progress-Visibility live ausgerollt; normaler Gitea-Deploy-Trigger weiter defekt.**
- Feature-Stand auf `main`: `adae7ba` (`feat: ship agent progress visibility`); nach CI-Blocker-Fix `2d21885` (`Fix activity repository test double`) war CI fuer Backend, Frontend und Security gruen.
- Da `POST /actions/workflows/deploy.yaml/dispatches` serverseitig `HTTP 500` lieferte und fuer `2d21885` kein erfolgreicher Deploy-Run belegbar war, wurde der produktive Rollout manuell aus einem **sauberen Snapshot von Commit `2d21885`** durchgefuehrt statt aus dem schmutzigen lokalen Worktree.
- Deploy-Pfad: Snapshot-Sync nach `/home/projekte_bao/openclaw/data/openclaw/workspace/nexus` und danach `docker compose --env-file .env up -d --build --force-recreate --wait`.
- Verifikation: Host-Deploy-Pfad auf Git-HEAD `2d218853a5d198fa8521dadbb4c6ea9be19e191c`; `nexus-postgres-1`, `nexus-api-1`, `nexus-web-1` healthy; `https://nexus.noveria.net/health/live` = `200`; `/dashboard` = `200`; `/api/v1/operations/snapshot` = `401` ohne Auth; `GET /api/dashboard/tasks/caab972a-c46c-4af5-b2c4-9d31be824da3` liefert live `lastActivityMessage` und `lastActivityAt`.
- Offener Betriebsblocker: Gitea-Deploy-Trigger / `workflow_dispatch` fuer `deploy.yaml` liefert weiter `HTTP 500` und muss separat repariert werden.
- 2026-06-20: **Live-Nexus nach Deploy-Stoerung verifiziert, Bao-Folgetasks angelegt und Agent-Workflow live gegengeprueft.**
- `https://nexus.noveria.net/` lieferte wieder `200 OK` mit SPA-Titel `Nexus | Noveria Operations`.
- `/health/live` lieferte `200 Healthy`.
- `GET /api/dashboard/tasks/board`, `GET /api/dashboard/tasks/agent-overview` und `GET /api/dashboard/agents` lieferten mit `X-Nexus-Api-Key` wieder `200 OK`.
- Neuer Task angelegt: `Restore agent progress visibility in Nexus` (`assignedTo=bao`, `priority=High`, State `Backlog`).
- Neuer Task angelegt: `Review: Agenten-Progress mit letztem Status + Timestamp sichtbar machen` (`assignedTo=bao`, State `Backlog`).
- Live-Artefakt-Pruefung bestaetigt Frontend-Strings fuer `nur Iris und Bao`, `researcher`, `executor`, `Worauf warte ich?`, `expectedFrom` und `isAgentTask`.
- Reversible Live-Verifikation erfolgreich: temp. Agent-Task mit `expectedFrom=researcher` erschien korrekt in `waitingForOthers`, erzeugte Activity und wurde direkt wieder geloescht (`DELETE ... -> 204`).
- Reversible Notification-Verifikation erfolgreich: simulierte Bao-Aenderung (`X-Agent-Id: bao`) erzeugte live `task_content_changed` und `task_status_changed` fuer Iris; simulierte Iris-Statusaenderung erzeugte live `task_review` fuer Bao.
- Live-Regel fuer Delete bestaetigt: Tasks lassen sich nur in `Backlog` oder `Done` loeschen; ein temp. Review-Task lieferte erwartungsgemaess `403` bis zum Ruecksetzen auf `Backlog`.
- Geaenderte Dateien: `nexus.md`, `phases/changelog.md`.
- 2026-06-20: **Researcher und Executor in den Agent-Task-Workflow aufgenommen.**
- `ValidAssignees` in TaskService.cs um `"researcher"` und `"executor"` erweitert.
- Frontend `expectedFromLabel`-Mapping, Create-Task- und Detail-Dropdowns um Researcher (🔬) und Executor (⚡) ergänzt.
- Researcher/Executor bleiben als Sub-Agenten vom Status-Change ausgeschlossen (nur Bao/Iris dürfen).
- Geänderte Dateien: `backend/Services/TaskService.cs`, `frontend/src/views/TaskBoardView.vue`, `phases/changelog.md`.
- 2026-06-20: **Bao-Status-Change + Content-Change-Benachrichtigung aktiviert.**
- **Neue Autorisierungsregel (TaskStateHelper.CanChangeState):**
- **Iris + Bao** dürfen jetzt Status ändern / verschieben.
- Sub-Agents (`programmer`, `reviewer`, `architekt`) dürfen weiterhin NIEMALS Status ändern.
- `nexus-system` bleibt als technischer Fallback erlaubt.
- **Neue Methode `CanEditContent`:** Bestätigt, dass alle bekannten Caller (bao, iris, sub-agents, nexus-system) Inhalt bearbeiten dürfen.
- **Benachrichtigungen bei Bao-Änderungen:**
- Wenn Bao eine Task inhaltlich ändert (Titel, Detail, Priorität, AssignedTo, DueDate), erhält **Iris** eine `task_content_changed`-Notification mit Detailangabe, WAS geändert wurde.
- Wenn Bao den Status ändert, erhält **Iris** eine `task_status_changed`-Notification.
- Wenn Iris den Status ändert, erhält **Bao** eine `task_status_changed`-Notification.
- **Verbesserte Activity-Einträge:** Zeigen jetzt detailliert, was sich geändert hat (statt nur "Task updated").
- **Geänderte Fehlermeldungen:** DashboardController und TasksController zeigen jetzt "nur Iris und Bao" statt "nur Iris".
- **Frontend:** `canChangeState`-Computed prüft jetzt `authStore.isIris || authStore.isBao`. Permission-Banner, State-Dropdown-Readonly-Tag und Tooltips aktualisiert. Neuer `isBao`-Getter im Auth-Store.
- **Tests:** `CanChangeState_Bao_CannotChangeAnyTask``CanChangeState_Bao_CanChangeAnyTask`. Neue Tests für `CanEditContent`.
- Geänderte Dateien: Backend `Entities.cs`, `TaskService.cs`, `DashboardController.cs`, `TasksController.cs`;
Frontend `TaskBoardView.vue`, `auth.ts`; Tests `TaskBoardTests.cs`; Docs `changelog.md`.
- 2026-06-20: **Agent-Task-Workflow implementiert.**
- **Neue Felder in WorkTask-Entity:** `IsAgentTask` (bool, Index) und `ExpectedFrom` (string? MaxLength 60, Index).
Agent-Tasks sind als solche im Board erkennbar (🤖 Badge) und unterliegen einem strikt von Iris geführten Statusfluss.
- **Status-Change-Autorisierung:** `TaskStateHelper.CanChangeState()` prüft jetzt strikt:
- **Nur `iris`** darf Status ändern oder Karten verschieben.
- Sub-Agents (`programmer`, `reviewer`, `architekt`) dürfen **niemals** Status ändern.
- `bao` darf Tasks inhaltlich bearbeiten, aber **keinen** Status ändern.
- Der technische Fallback `nexus-system` darf Status nur für interne Systempfade wie automatische Reset-/Cron-Operationen ändern.
Caller wird via `X-Agent-Id`-Header oder JWT-Claim aufgelöst; HTTP-Fallback ist **nicht** mehr `bao`, sondern leer und wird für Statusänderungen abgewiesen.
- **Neue API-Endpunkte:**
- `POST /api/dashboard/tasks/agent` Agent-Task anlegen (mit `expectedFrom`).
- `GET /api/dashboard/tasks/agent-waiting` Offene Agent-Tasks nach Erwartung.
- `GET /api/dashboard/tasks/agent-overview?staleHours=2` Komplette Iris-Übersicht:
`waitingForBao`, `waitingForIris`, `waitingForOthers`, `staleTasks`.
- **Neue Service-Methoden:** `CreateAgentTaskAsync`, `GetWaitingTasksAsync`, `GetAgentWorkflowOverviewAsync`.
- **DashboardController-Sicherheit:** `UpdateTaskStatus` und `MoveTask` prüfen jetzt via
`ResolveCallerAgent()` + `TaskStateHelper.CanChangeState()`. Bei Verstoß: **403** mit klarer Iris-only-Fehlermeldung.
- **Frontend:**
- TaskBoardView: Agent-Task-Badge (🤖), ExpectedFrom-Label (⏳), Stale-Banner,
kollabierbares Iris-Overview-Panel („Iris Worauf warte ich?“) mit 4 Sektionen:
Warte auf Iris / Bao / Andere / Stale Tasks.
- Für Nicht-Iris: Permission-Banner, kein wirksames Drag&Drop, Status-Dropdown deaktiviert.
- tasks.ts-Store: `fetchAgentOverview()`, `createAgentTask()`, Getter für Iris-Ansicht.
- Detail-Panel: zeigt Agent-Task-Status und ExpectedFrom im Snapshot.
- **Tests:** TaskStateHelper-Coverage erweitert um `CanChangeState`.
- **Dokumentation:** Changelog aktualisiert.
- Geänderte Dateien: siehe Backend `Entities.cs`, `TaskService.cs`, `ITaskService.cs`, `DashboardController.cs`,
`Dashboard.cs` (Models), `NexusDbContext.cs`; Frontend `tasks.ts`, `TaskBoardView.vue`.
- 2026-06-20: Nexus-Auth-Persistenz live verifiziert.
- 2026-06-20: Nexus-Auth-Persistenz live verifiziert. Owner-Passwort in der produktiven Postgres-DB geprüft, Stack vollständig neu gestartet und anschließend `postgres`, `api` und `web` per `docker:cli compose up -d --force-recreate` neu erstellt, ohne das DB-Volume zu löschen. Ergebnis: `/health/live` blieb healthy, der Passwort-Hash für `vmbao62@hotmail.de` blieb vor und nach Restart/Recreate identisch. Wichtiges Learning: temporäre Passwörter oder Auth-Fixes niemals an Bao weitergeben, bevor der echte Live-Login oder mindestens der persistierte DB-Hash auf dem Zielstack verifiziert ist.
- 2026-06-20: Task Board um klickbare Linear-inspirierte Detailansicht erweitert: Board-Karten öffnen jetzt ein strukturiertes Side/Overlay-Detailpanel mit editierbarem Titel, Beschreibung, Status, Priorität, Zuständigkeit und Fälligkeitsdatum sowie geladener Aktivität und Unteraufgaben. `frontend/src/views/TaskBoardView.vue` und `frontend/src/stores/tasks.ts` angepasst. Verifiziert mit `COREPACK_HOME=$PWD/.corepack-home PNPM_HOME=$PWD/.pnpm-home pnpm build`.
- 2026-06-19: Task-Board-Doku-Drift behoben: Header-Kommentar in TaskBoardView.vue von "4 columns" auf "6 columns" (Offen, InBearbeitung, Delegiert, Review, Blockiert, Erledigt) korrigiert. tasks.ts-Store-Kopfkommentar um delegated ergänzt.
- 2026-06-19: Veralteter TODO.md-Import entfernt: `ImportFromIrisTodoAsync` in TaskService.cs, ITaskService.cs und der import-from-iris-todo-API-Endpoint in DashboardController.cs gelöscht. ImportResultDto aus Models/Dashboard.cs entfernt. TODO.md ist abgeschafft, Task Board alleinige Quelle.
- 2026-06-19: Backend-Tests erweitert: TaskBoardTests.cs (69 Tests total, +13 neue) decken TaskStateHelper-BoardGroupKey/ToState/BoardGroupToState/DisplayString/AllStates/IsValidState/IsInProgressOrBlocked/IsDoneOrBacklog ab. Backend-Build 0 Errors, Frontend vue-tsc 0 Errors.
- 2026-06-16: Program.cs refactored: DI extrahiert in `Extensions/ServiceCollectionExtensions.cs`, Middleware in `Extensions/ApplicationBuilderExtensions.cs`, Helpers in `Helpers/PasswordHelper.cs`. Program.cs von ~200 auf 26 Zeilen reduziert.
- 2026-06-16: Nexus auf Netcup (mission-control) redeployed. Neuer Stack unter `/home/projekte_bao/nexus/`. Traefik reverse-proxy mit Let's Encrypt TLS. Volume und Netzwerk-Namen bereinigt (postgres-data, internal). Compose-Pfade von Ionos auf Netcup migriert.
- 2026-06-16: Ollama-Modelle (2.4 GB) und alle ungenutzten Runtime-Dateien entfernt. Codex-Logs bereinigt (~342 MB). Workspace-Aufräumung (~3.1 GB gesamt).
- 2026-06-16: Modell-Healthcheck nach Migration: Alle 7 aktiven Modelle laufen (DeepSeek Flash/Pro, GPT-5.4/5.5, Claude Sonnet/Opus via CLI-Backend). Ollama und NVIDIA endgültig deaktiviert.
- 2026-06-14: Server-Migration von Ionos (85.214.180.137) nach Netcup (178.105.105.106). Hostname: mission-control. Migration: OpenClaw, Gitea, Nexus-Volume.
- 2026-06-12: Agent-Workspaces finalisiert. Iris als Chief of Staff mit Approval-Autonomie. Bidirektionale Kommunikation etabliert.
- 2026-06-11: Gitea CI/CD-Pipeline aktiv. Agent-Repo-Permissions mit API-Tokens (statt Passwort-Auth). DevOps-Token für Deploy-Trigger.
- 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors. - 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors.
- 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only). - 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only).
- 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2). - 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2).
+51 -4
View File
@@ -1,7 +1,7 @@
# Deployment # Deployment
> Letzte Aktualisierung: 2026-06-13 > Letzte Aktualisierung: 2026-06-21
> Status: ✅ CD v3 (Auto + Manual) > Status: ✅ CD v3 (Auto + Manual) + Owner-Passwort-Persistenz (SeedAudit)
> Live-URL: https://nexus.noveria.net > Live-URL: https://nexus.noveria.net
## CD-Philosophie (v3) ## CD-Philosophie (v3)
@@ -107,6 +107,23 @@ schedule:
## Secrets und Konfiguration ## Secrets und Konfiguration
### Owner Password Persistence (2026-06-21, permanent fix)
**Root Cause**: Dual-Source-Architektur fuer das Owner-Passwort (Gitea-Secret `ENV_OWNER_PASSWORD` vs Host `.env` `OWNER_PASSWORD`) verursachte Drift wenn die DB jemals neu geseedet wurde.
**Fix (3 Schichten)**:
1. **SeedAudit-Entity** (DB-Migration `20260621081500_AddSeedAudit`): `EnsureDatabaseAsync` prueft die `SeedAudit`-Tabelle auf Key `owner_created` VOR dem Seeden. Ist dieser Key vorhanden, wird der Owner NIE neu erstellt — selbst wenn die Users-Tabelle komplett geloescht wird.
2. **Single Source of Truth**: Deploy- und Rollback-Workflows lesen `OWNER_PASSWORD` jetzt aus dem persistenten Host-`.env` (via `grep` auf dem Deploy-Pfad), NICHT mehr aus separatem Gitea-Secret. Das Host-`.env` ist die kanonische Quelle.
3. **admin-reset-password** Endpoint existiert als Recovery-Pfad (braucht `Admin__ResetToken` aus dem `.env`).
**Verifikation (2026-06-21)**:
- Login funktioniert nach `docker compose down && up` (kompletter Stack-Neustart)
- Login funktioniert nach `docker compose up -d --force-recreate --wait`
- Login funktioniert nach `docker compose restart`
- SeedAudit-Eintrag `owner_created` blockiert erneutes Seeden bei jedem Startup
**Regel gegen Wiederholung**: `OWNER_PASSWORD` nur im Host-`.env` aendern. Das Host-`.env` wird von CI-Deploys gelesen. Niemals ein separates Gitea-Secret fuer OWNER_PASSWORD anlegen.
### Secrets in Gitea ### Secrets in Gitea
Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfiguriert: Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfiguriert:
@@ -115,9 +132,11 @@ Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfi
|---|---| |---|---|
| `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort | | `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort |
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) | | `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
| `ENV_OWNER_PASSWORD` | Owner-Account-Passwort |
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token | | `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
> **Hinweis**: `ENV_OWNER_PASSWORD` wurde aus den Gitea-Secrets ENTFERNT (2026-06-21).
> OWNER_PASSWORD kommt ausschliesslich aus dem Host-`.env` auf dem Deploy-Pfad.
### Safe Secret Handling (v3) ### Safe Secret Handling (v3)
**Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem. **Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem.
@@ -175,8 +194,19 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13) - [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13)
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13) - [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13) - [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
- [x] Live-Recheck nach Deploy-Stoerung: `/health`, SPA-Root und `GET /api/dashboard/tasks` wieder 200; Bao-Folgetask zur Agent-Progress-Visibility erstellt (2026-06-20)
- [x] Agent-Progress-Stand (`2d21885`) manuell als sauberer Commit-Snapshot live ausgerollt, nachdem der normale Gitea-Deploy-Trigger blockierte (2026-06-20)
## Verifizierung (2026-06-09) ## Verifizierung
### 2026-06-20
- https://nexus.noveria.net/ → 200 OK, SPA geladen (`<title>Nexus | Noveria Operations</title>`)
- /health → 200 Healthy, PostgreSQL + Runtime healthy
- /api/dashboard/tasks → 200 OK mit `X-Nexus-Api-Key`
- Follow-up-Task `Restore agent progress visibility in Nexus` fuer `assignedTo=bao` erfolgreich angelegt
### 2026-06-09
- https://nexus.noveria.net → 200 OK, SPA geladen - https://nexus.noveria.net → 200 OK, SPA geladen
- /health → Healthy - /health → Healthy
@@ -185,9 +215,26 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
- Let's Encrypt TLS-Zertifikat aktiv - Let's Encrypt TLS-Zertifikat aktiv
- Nginx-Proxy → 127.0.0.1:18880 - Nginx-Proxy → 127.0.0.1:18880
## Incident-Hinweis (2026-06-14)
- Verifizierter Ausfallpfad: `api` konnte wegen DB-Passwort-Mismatch nicht healthy werden; dadurch blieb `web` per `depends_on: service_healthy` im Status `Created`.
- Nach einem isolierten API-Fix startet `web` nicht automatisch nach. Sicherer Minimalpfad:
1. `docker compose ps`
2. `curl http://127.0.0.1:18880/health`
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
- Der manuelle Helper [`ops/deploy.sh`](/home/node/.openclaw/workspace/nexus/ops/deploy.sh) verifiziert deshalb jetzt nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
## Offene Arbeit ## Offene Arbeit
- [!] Gitea-Deploy-Trigger reparieren: `POST /actions/workflows/deploy.yaml/dispatches` liefert aktuell `HTTP 500`; fuer Commit `2d21885` war deshalb kein erfolgreicher normaler Deploy-Run belegbar
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter) - [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
- [ ] Docker-Logs und Container-Health-Monitoring einrichten - [ ] Docker-Logs und Container-Health-Monitoring einrichten
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren - [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern - [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern
### Deploy-Trigger-Actor (2026-06-14)
- Deploy-Trigger werden durch DevOps (nicht Iris) ausgelöst
- Git-Remote origin verwendet DevOps-Token → Gitea zeigt devops als Actor
- Workflow-Dispatch API-Calls mit DevOps-Token authentifizieren
+4 -3
View File
@@ -1,13 +1,14 @@
# Phase 1 MVP # Phase 1 MVP
> Letzte Aktualisierung: 2026-06-09 > Letzte Aktualisierung: 2026-06-16
> Fokus: Mission-Control-Board bereitstellen und Infrastruktur anschliessen > Status: ✅ Abgeschlossen
## Status ## Status
- Gesamtfortschritt: ca. 95 % - Gesamtfortschritt: 100 % ✅
- Produktiv live: ja (https://nexus.noveria.net) - Produktiv live: ja (https://nexus.noveria.net)
- Letzter Build: Backend + Frontend erfolgreich - Letzter Build: Backend + Frontend erfolgreich
- Ollama/NVIDIA entfernt, nur OpenClaw-Integration
## Prioritaet ## Prioritaet
+15 -12
View File
@@ -1,23 +1,26 @@
# Runtime und Routing # Runtime und Routing
> Letzte Aktualisierung: 2026-06-08 > Letzte Aktualisierung: 2026-06-16
## Aktive Modelle ## Aktive Modelle (7 von 8 konfiguriert)
| Priorität | Modell | Zweck | Provider | | Agent | Modell | Provider |
|-----------|--------|-------|----------| |-------|--------|----------|
| 1 | deepseek/deepseek-v4-flash | Programmer Agent | DeepSeek (über OpenClaw) | | Iris | `openai/gpt-5.4` | OpenAI (OAuth) |
| 2 | deepseek/deepseek-v4-pro | Reviewer Agent, Iris Fallback | DeepSeek (über OpenClaw) | | Programmer, Executor | `deepseek/deepseek-v4-flash` | DeepSeek (API-Key) |
| 3 | openai/gpt-5.3-chat-latest | Iris Hauptmodell | OpenAI (über OpenClaw) | | Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` | DeepSeek (API-Key) |
| — | `openai/gpt-5.5` | OpenAI (verfügbar) |
| — | `anthropic/claude-sonnet-4-6` | Anthropic (CLI-Backend) |
| — | `anthropic/claude-opus-4-6/4.8` | Anthropic (CLI-Backend) |
## Deaktiviert ## Entfernt / Deaktiviert
- **Ollama** (qwen3:4b): deaktiviert, funktioniert aktuell nicht. Wird später wieder aufgegriffen. - **Ollama** (qwen3:4b): komplett entfernt (2.4 GB Models gelöscht 16.06.)
- **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt. - **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt.
- **Kimi 2.6**: vollständig entfernt. - **IModelProvider-Abstraktion**: entfernt, nur noch `IAgentRuntime` mit OpenClaw-Adapter.
## Integration ## Integration
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime` - Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
- Keine direkten Provider-Registrierungen mehr im Backend (OllamaProvider, NvidiaProvider entfernt) - Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
- Model-Routing läuft zentral über OpenClaw Gateway - API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)