Compare commits

...

86 Commits

Author SHA1 Message Date
devops 86ceb2bcce style: unify all views on Mission Control V2 design tokens
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
- Map legacy v1 CSS variables (--nx-*, --panel, --text-*, --surface*) to V2 tokens in nexus-tokens.css
- Restyle global shell (main.css), AppSidebar, AppHeader to match V2 Sidebar/Topbar (glass, gradients, Space Grotesk)
- Add GalaxyBackground to the v1 shell in App.vue
- Replace hardcoded v1 hex colors with V2 tokens in all deviating views (Agents, Calendar, Docs, Incidents, Memory, Notifications, ProjectDetail, Security, Team, TaskDetail, Settings)
- Keep JS status colors as hex where alpha suffixes are concatenated (AgentsIndex, Team, Notifications)
- Add Settings nav item (System group) + gear icon to V2 sidebar so it shows on the dashboard
- No component or layout structure changes; LoginView and TaskBoardView untouched

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:05:52 +02:00
devops 706ff82ccd fix: harden Nexus runtime health and rollback 2026-07-10 00:37:24 +02:00
devops dbda764190 fix: execute Nexus deploy inside host-mounted workspace
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Failing after 21s
2026-07-10 00:16:47 +02:00
devops 361a64f886 ops: move canonical workspace to projects root
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 7s
2026-07-10 00:02:41 +02:00
devops a104acf160 ci: consolidate Nexus deployment and provenance
CI - Build & Test / Backend (.NET) (push) Successful in 48s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 55s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
2026-07-09 23:46:46 +02:00
devops aaec3eb4ed feat: complete Nexus mission-control workflows 2026-07-09 23:40:36 +02:00
devops 436ddfee0f fix: stream deploy source snapshot
CI - Build & Test / Backend (.NET) (push) Successful in 38s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:56:18 +02:00
devops 38954feb8f fix: snapshot deploy source before sync
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 3s
2026-06-24 07:52:24 +02:00
devops f30cce4fb3 fix: harden nexus deploy sync
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:43:02 +02:00
devops 7216bfdeff fix: repair gitea deploy pipeline
CI - Build & Test / Backend (.NET) (push) Successful in 34s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 7s
2026-06-24 07:33:10 +02:00
devops 16385d10cb docs: update routing docs for Traefik
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:43:33 +02:00
devops 250e730f33 fix: require auth for chat endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:32:04 +02:00
devops c9e22195ad fix: assert forbidden workflow results
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:36:18 +02:00
devops 8d8f8cc8a8 fix: assert task workflow result statuses
CI - Build & Test / Backend (.NET) (push) Failing after 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:30:59 +02:00
devops 873c5d586c fix: repair agent model converter build
CI - Build & Test / Backend (.NET) (push) Failing after 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:26:56 +02:00
devops 95495a8332 feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:23:49 +02:00
devops 68b428e411 fix: prioritize rollback over queued deploys 2026-06-24 01:22:55 +02:00
devops 1214cf9a4d chore: simplify nexus cicd pipeline 2026-06-24 01:22:55 +02:00
devops 195c497c88 fix: route standalone views via route metadata 2026-06-24 01:22:55 +02:00
devops a2272c5df6 fix: harden owner bootstrap and auth persistence 2026-06-24 01:22:55 +02:00
devops 5df5194651 fix: deploy GatewayBridge + Dashboard SSE live endpoints
CI - Build & Test / Backend (.NET) (push) Successful in 39s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
Root cause: /api/bridge/health and /api/dashboard/live returned 404 live because
the source files existed on disk but were never committed to git. The CD pipeline
deploys from committed main, so the deployed containers lacked these endpoints.

Changes:
- Add GatewayBridgeController with /api/bridge/health endpoint
- Add DashboardController Live() SSE endpoint for /api/dashboard/live
- Add LiveUpdateService (in-memory pub/sub for SSE updates)
- Add TaskBridgeService (structured agent-to-backend bridge)
- Add nginx routing blocks for /api/bridge/ and /api/dashboard/live
- Add host-level nginx-nexus.conf blocks for bridge + live pass-through
- Update ServiceCollectionExtensions with DI registrations
- Update Dashboard.cs model with SSE-related DTOs

Verification after deploy:
  curl https://nexus.noveria.net/api/bridge/health → 200 JSON
  curl https://nexus.noveria.net/api/dashboard/live → 200 SSE stream
2026-06-22 20:43:31 +02:00
devops 39aeab62d6 revert: remove image tag experiment
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-22 20:14:08 +02:00
devops 8b1400da17 fix: tag api image to force rebuild
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-22 20:12:31 +02:00
devops dd17cefa3b fix: add --no-cache to deploy-now build step; revert temp cache bust changes
CI - Build & Test / Backend (.NET) (push) Successful in 34s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-22 20:10:04 +02:00
devops 2aa41e6366 fix: remove package-lock.json again
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-22 20:07:46 +02:00
devops 696e9daf4d chore: add DEPLOY_TIMESTAMP build arg for cache busting
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-22 20:07:43 +02:00
devops 701e15ee9b fix: restore pnpm-lock.yaml (CI requires it) and remove package-lock.json
CI - Build & Test / Backend (.NET) (push) Successful in 37s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-22 20:05:27 +02:00
devops fe97d1aaf0 chore: force backend Docker cache bust for deploy
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Failing after 8s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-22 20:04:22 +02:00
devops df94ed3cd4 feat: board-first orchestration with Gateway Bridge, live-update, and flow-board
CI - Build & Test / Backend (.NET) (push) Successful in 1m19s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
- GatewayBridgeController: MCP-artiger Kommando-Adapter für Agent-zu-Backend
- TaskBridgeService + LiveUpdateService: SSE Live-Sync + Bridge-Kommandos
- FlowBoard.vue: Board-first orchestration dashboard panel
- live-sync.ts store + live.ts service: SSE-basierte Live-Updates
- Nullability-Warnung in HealthController.cs gefixt
- nginx.conf: SSE-Proxy + CORS für Bridge-Endpunkte
- .gitignore: pnpm/corepack local caches ausgeschlossen
- docs: architecture-board-first-orchestration.md hinzugefügt
- README: Backend Bridge API dokumentiert
2026-06-22 19:56:45 +02:00
devops de1fc198cb chore: restore deploy.yaml name, keep deploy-now.yaml as working fallback
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-21 21:41:26 +02:00
devops f33e8c8b58 chore: remove deploy diagnostic test workflows
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-21 21:41:22 +02:00
devops dd38570c7b fix: read OWNER_PASSWORD via docker instead of direct filesystem
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 2s
2026-06-21 21:39:50 +02:00
devops 5dc00c1142 fix: deploy-now without heredocs — Gitea 1.26.3 compat
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-21 21:38:09 +02:00
devops cf10ca3ed8 test: no heredoc, echo-based
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-21 21:37:38 +02:00
devops 1f8b45a327 test: heredoc quoted
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-21 21:37:21 +02:00
devops b1dcf51218 test: heredoc
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-21 21:37:02 +02:00
devops 0fdc40a95c test: checkout + GITEA_OUTPUT
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-21 21:36:45 +02:00
devops 071cbe8ce5 test: checkout only
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-21 21:36:29 +02:00
devops c750a5abcd test: checkout + version + heredoc
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-21 21:36:12 +02:00
devops dd81070afd test: env with secrets
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-21 21:35:55 +02:00
devops be5c3884a0 test: minimal dispatch test
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-21 21:35:38 +02:00
devops 7a2d5ded20 feat: add deploy-now workflow as dispatch-only fallback
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-21 21:35:15 +02:00
devops 784cb4eeee fix: replace broken deploy workflow with deploy-v2
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-21 21:34:47 +02:00
devops 6b2ab04f8d fix: remove workflow_dispatch inputs to unblock dispatch endpoint
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-21 21:34:15 +02:00
devops ca4bad2ba7 fix: repair deploy pipeline — boolean type and unterminated quoted string
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
- Changed no_cache input type from boolean to string (Gitea 1.26.3 compat)
- Fixed multi-line double-quoted string in DEPLOYSCRIPT heredoc
- Hardened inputs.* references with proper short-circuit fallbacks
2026-06-21 21:32:25 +02:00
devops ac131f7f53 feat: Parent-Child TaskFlow — Delegated durch sichtbare Child-Tasks ersetzt
- Delegated State aus Board, Entities, DTOs, Frontend-Spalten und Tests entfernt
- Parent-Tasks bleiben InProgress waehrend delegierter Agentenarbeit
- Child-Tasks laufen sichtbar mit normalen States und parentTaskId
- Doku: README, Phase 3, Changelog, Controller-Kommentare angepasst
- openclaw-task-board-flow.md als Referenzdoku hinzugefuegt
- 73/73 Backend-Tests gruen, Frontend-Build gruen
2026-06-21 21:30:52 +02:00
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
devops 36b32f0e88 chore: bump version to 0.2.56 [skip ci] 2026-06-14 07:50:18 +00:00
reviewer 8a556c25a0 Add local liveness health endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:49:25 +02:00
160 changed files with 17419 additions and 1856 deletions
-12
View File
@@ -1,12 +0,0 @@
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=replace-with-a-strong-database-password
JWT_KEY=replace-with-at-least-32-random-bytes
OWNER_EMAIL=owner@example.com
OWNER_PASSWORD=replace-with-at-least-14-characters
OWNER_DISPLAY_NAME=Owner
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_PASSWORD=
OLLAMA_BASE_URL=http://host.docker.internal:11434
NVIDIA_API_KEY=
+4 -6
View File
@@ -15,13 +15,11 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
# ── Owner Account ───────────────────────────────────────
OWNER_EMAIL=***
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
# ── Bootstrap Owner (first seed only) ───────────────────
BOOTSTRAP_OWNER_EMAIL=***
# ── OpenClaw Integration ────────────────────────────────
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
OPENCLAW_BASE_URL=http://host.docker.internal:18789
# Internal Docker-DNS URL of the OpenClaw gateway
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=***
OPENCLAW_GATEWAY_PASSWORD=***
+198
View File
@@ -0,0 +1,198 @@
#!/bin/sh
set -eu
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
ENV_TMPFILE=""
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
cleanup() {
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
fi
}
trap cleanup EXIT INT TERM
require_env() {
name="$1"
eval "value=\${$name:-}"
if [ -z "$value" ]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env ENV_POSTGRES_PASSWORD
require_env ENV_JWT_KEY
secure_tmpfile() {
template="$1"
dir="$(dirname "$template")"
base="$(basename "$template")"
mkdir -p "$dir"
mktemp "$dir/$base.XXXXXX"
}
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
chmod 600 "$ENV_TMPFILE"
if [ ! -f VERSION ]; then
echo "VERSION file not found" >&2
exit 1
fi
VERSION="$(tr -d '[:space:]' < VERSION)"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Invalid VERSION value: $VERSION" >&2
exit 1
fi
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "Deploying Nexus v$VERSION from $GIT_REF"
umask 077
cat > "$ENV_TMPFILE" <<EOF_ENV
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=${VERSION}
NEXUS_GIT_SHA=${GIT_SHA}
EOF_ENV
echo "Syncing source to deploy path: $DEPLOY_PATH"
git archive --format=tar HEAD | docker run --rm -i \
-v "$DEPLOY_PATH:/dest" \
alpine:3.20 \
sh -c '
set -eu
dest_owner="$(stat -c "%u:%g" /dest)"
mkdir -p /src-snapshot
tar -xf - -C /src-snapshot
is_protected_path() {
case "$1" in
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
rm -rf "$path"
fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
cp -a "$path" /dest/
fi
done
chown -R "$dest_owner" /dest
'
echo "Building and starting Docker compose stack"
docker run --rm \
-v "$DEPLOY_PATH:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c 'set -eu
umask 077
cat > /tmp/nexus-deploy-env
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-deploy-env build
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
docker compose --env-file /tmp/nexus-deploy-env ps
' < "$ENV_TMPFILE"
echo "Verifying image provenance"
for container in nexus-api-1 nexus-web-1; do
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
version="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$container")"
if [ "$revision" != "$GIT_SHA" ]; then
echo "Image revision mismatch for $container: expected $GIT_SHA, got $revision" >&2
exit 1
fi
if [ "$version" != "$VERSION" ]; then
echo "Image version mismatch for $container: expected $VERSION, got $version" >&2
exit 1
fi
echo "$container provenance verified: v$version $revision"
done
echo "Checking live health"
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
case "$health_body" in
'{"status":"Healthy"'*)
echo "Health check passed"
break
;;
esac
if [ -n "$health_body" ]; then
echo "Health endpoint is reachable but not healthy: $health_body" >&2
fi
if [ "$retry" -eq 6 ]; then
echo "Health check failed" >&2
exit 1
fi
sleep "$retry"
done
pass=0
fail=0
check() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check_post() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{}' "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check "/dashboard" "200" "Dashboard"
check "/health" "200" "Health"
check "/api/v1/operations/snapshot" "401" "Operations auth"
check_post "/api/v1/chat" "401" "Chat auth"
if [ "$fail" -ne 0 ]; then
echo "Smoke test failed: $fail failed, $pass passed" >&2
exit 1
fi
echo "Nexus v$VERSION deployed and verified"
+4 -4
View File
@@ -33,7 +33,7 @@ on:
host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)'
required: false
default: '/opt/openclaw/backups'
default: '/home/projekte_bao/backups/nexus'
type: string
# Optional: uncomment to enable nightly automatic backups
@@ -43,11 +43,11 @@ on:
jobs:
backup:
name: Backup PostgreSQL
runs-on: ubuntu-latest
runs-on: linux
env:
ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
DEPLOY_PATH: /home/projekte_bao/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1
steps:
@@ -72,7 +72,7 @@ jobs:
echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus" \
| gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
+28 -3
View File
@@ -8,9 +8,12 @@ concurrency:
on:
push:
branches: [main]
branches:
- main
- 'codex/**'
pull_request:
branches: [main]
workflow_dispatch:
jobs:
# ─── Backend ───────────────────────────────────
@@ -51,7 +54,7 @@ jobs:
- name: Setup pnpm
run: |
corepack enable
corepack prepare pnpm@latest --activate
corepack prepare pnpm@10.12.1 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,7 +76,7 @@ jobs:
security:
name: Security Check
runs-on: linux
if: github.ref == 'refs/heads/main'
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -97,3 +100,25 @@ jobs:
else
echo "✅ No obvious secrets found"
fi
deploy:
name: Deploy Nexus
runs-on: linux
needs: [backend, frontend, security]
concurrency:
group: deploy-production
cancel-in-progress: false
if: |
gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main'
env:
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy after green CI
run: sh .gitea/scripts/deploy-nexus.sh
+8 -357
View File
@@ -1,377 +1,28 @@
name: Deploy to Production
run-name: 🚀 Deploy by @${{ gitea.actor }}
name: Deploy Nexus Manual
run-name: Deploy Nexus manually by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version-Bump / CI Loop Prevention:
# The version-bump commit includes "[skip ci]" in its message,
# which Gitea Actions respects. The auto-trigger additionally
# checks for "[skip ci]" as a second safety layer. Together
# they guarantee that a version-bump commit does NOT trigger
# another CI → Deploy → Bump → CI cycle.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run:
workflows: ["CI - Build & Test"]
types: [completed]
branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch:
inputs:
version_bump:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
service:
description: 'Service to deploy (empty = all)'
required: false
default: ''
type: string
no_cache:
description: 'Disable Docker build cache'
required: false
default: false
type: boolean
git_ref:
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
required: false
default: 'main'
type: string
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
# ── Env for the deploy target path ──
runs-on: linux
env:
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
# ═══════════════════════════════════════════════════
# Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
- name: Checkout main
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
ref: main
fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════
# Step 2: Set up Git identity
# ═══════════════════════════════════════════════════
- name: Configure Git
run: |
git config user.email "devops@noveria.net"
git config user.name "DevOps"
# ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version
#
# Deploying main: DevOps may bump VERSION and create a tag.
# Deploying any other ref: deploy exactly that ref, but DO NOT
# mutate main or create a version-bump commit on another branch.
#
# For auto-deploys (workflow_run): always "patch" bump on main.
# ═══════════════════════════════════════════════════
- name: Resolve Version
id: version
run: |
set -euo pipefail
# Determine bump type (auto-deploy → patch; manual → user choice)
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
# Read current version
if [ ! -f VERSION ]; then
echo "❌ VERSION file not found"
exit 1
fi
CURRENT=$(cat VERSION | tr -d '[:space:]')
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$CURRENT'"
exit 1
fi
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
MINOR=$(echo "$CURRENT" | cut -d. -f2)
PATCH=$(echo "$CURRENT" | cut -d. -f3)
case "$BUMP_TYPE" in
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
esac
# Determine git ref — auto-deploy always uses main
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
echo "$NEW_VERSION" > VERSION
git add VERSION
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin HEAD:main --tags
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
echo "mutated_main=true" >> "$GITEA_OUTPUT"
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
else
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
fi
# ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets (SAFE)
#
# Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after
# compose operations complete.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets → temp file)
run: |
set -euo pipefail
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea Secrets → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Code synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy
run: |
set -euo pipefail
# Auto-deploy: always use cache. Manual: respect no_cache input.
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
BUILD_ARGS=""
if [ "$NO_CACHE" = "true" ]; then
BUILD_ARGS="--no-cache"
fi
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c "
set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT
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"
# ═══════════════════════════════════════════════════
# Step 7: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 8: Health Check (exponential backoff)
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
# Fibonacci-ish backoff: 1,2,3,5,8,13
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 9: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ═══════════════════════════════════════════════════
# Step 10: Deployment Summary
# ═══════════════════════════════════════════════════
- name: Deployment Summary
if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}"
echo " Bump type: ${VERSION_BUMP}"
echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 11: Failure → Reviewer Handoff
#
# On failure: DevOps (Architekt) analyses the log,
# notifies Reviewer (Code-Fixer) with the exact error.
# This output provides a ready-to-copy message.
# ═══════════════════════════════════════════════════
- name: 🔴 Failure — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Version: v${{ steps.version.outputs.version }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
- name: Deploy main
run: sh .gitea/scripts/deploy-nexus.sh
+58 -31
View File
@@ -18,9 +18,12 @@ run-name: 🔙 Rollback by @${{ gitea.actor }}
# migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps.
# ───────────────────────────────────────────────────────
# Rollback wins over queued/in-progress deploys.
# It shares deploy-production with deploy.yaml so rollback and deploy never run together,
# but cancel-in-progress=true prevents a queued auto-deploy from running after rollback.
concurrency:
group: deploy-production
cancel-in-progress: false
cancel-in-progress: true
on:
workflow_dispatch:
@@ -37,13 +40,12 @@ on:
jobs:
rollback:
name: Rollback Nexus
runs-on: ubuntu-latest
runs-on: linux
env:
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
@@ -109,12 +111,12 @@ jobs:
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
NEXUS_GIT_SHA=$(git rev-parse HEAD)
EOF
chmod 600 "${ENV_TMPFILE}"
@@ -127,18 +129,38 @@ jobs:
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
git archive --format=tar HEAD | docker run --rm -i \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
sh -c '
set -eu
dest_owner="$(stat -c "%u:%g" /dest)"
mkdir -p /src-snapshot
tar -xf - -C /src-snapshot
is_protected_path() {
case "$1" in
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then rm -rf "$path"; fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
done
chown -R "$dest_owner" /dest
'
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
@@ -151,16 +173,18 @@ jobs:
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
sh -c '
set -eu
umask 077
cat > /tmp/nexus-rollback-env
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
' < "${ENV_TMPFILE}"
echo "✅ Rollback redeploy completed"
@@ -186,11 +210,14 @@ jobs:
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
case "$HEALTH_BODY" in
'{"status":"Healthy"'*)
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
;;
esac
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
@@ -271,7 +298,7 @@ jobs:
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
echo "│ cd /home/projekte_bao/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+7 -2
View File
@@ -6,7 +6,6 @@
# Environment
.env
!.env.example
!.env.template
# IDE
@@ -30,7 +29,13 @@ docker-compose.override.yml
*.tmp
*.bak
# pnpm (lockfile IS committed for reproducible CI builds)
# Crash artefacts / Core dumps
**/core
**/core.*
# pnpm / corepack local caches (lockfile IS committed for reproducible CI builds)
frontend/.pnpm-home/
frontend/.corepack-home/
# Claude local config (per-developer, not repo-shared)
.claude/
+6 -5
View File
@@ -31,7 +31,7 @@
│ 127.0.0.1:18880 │
│ │ │
│ ┌───────────────────────────┼───────────────────┐ │
│ │ Host nginx reverse proxy │ │ │
│ │ Traefik v3 reverse proxy │ │ │
│ │ nexus.noveria.net :443 ───┘ │ │
│ └───────────────────────────────────────────────┘ │
│ │
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
ss -tlnp | grep 18880
```
### Host nginx Reverse Proxy
### Traefik Reverse Proxy
Falls `nexus.noveria.net` nicht erreichbar:
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880`
- TLS-Zertifikat gültig?
- Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
- `web` hängt am externen `proxy`-Netzwerk
- TLS-Zertifikat/Let's-Encrypt-Resolver in Traefik gültig?
---
@@ -151,5 +152,5 @@ Falls `nexus.noveria.net` nicht erreichbar:
| backend/Dockerfile | ✅ Multi-Stage .NET 10 |
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
| Host nginx Reverse Proxy | ⚠️ Muss auf Port 18880 zeigen |
| Traefik Reverse Proxy | ✅ Per Compose-Labels auf `web:80` |
| Docker installiert auf VPS | ⚠️ Vorausgesetzt |
+148 -43
View File
@@ -3,10 +3,14 @@
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
adapter-backed agent runtime, not a dependency of the frontend or domain model.
> CI runs automatically on every push. CD can run **automatically after successful CI**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
> stay read-only. Rollback and database backup are separate manual workflows.
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
> Backend-Brücke und Gateway-Integration geprüft. Siehe
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
> CI runs automatically on every push. CD runs **inside the green CI run**
> on main or can be triggered **manually** (workflow_dispatch). Deploy reads
> `VERSION` but does not mutate Git or create tags. Rollback and database backup
> are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation
@@ -15,28 +19,26 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
- ASP.NET Core 10 REST API (Minimal API pattern)
- Entity Framework Core and PostgreSQL
- JWT owner authentication with rotating refresh sessions
- `IAgentRuntime` abstraction with an OpenClaw adapter
- `IModelProvider` abstractions for Ollama and NVIDIA
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
- Responsive dark-mode operations dashboard
- Container-only entry point on `127.0.0.1:18880`
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
## Local/container start
```bash
cp .env.example .env
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY,
# OWNER_EMAIL and OWNER_PASSWORD.
cp .env.template .env
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
docker compose up --build -d
curl http://127.0.0.1:18880/health
```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14
characters. Existing databases are never overwritten by the bootstrap process.
On an empty database the API creates exactly one owner from `BOOTSTRAP_OWNER_EMAIL`,
derives the initial display name from that email, and logs a generated temporary password once.
After first seed the password lives only in PostgreSQL. Existing databases are
never overwritten by the bootstrap process.
The web service is loopback-only. Public reverse-proxy activation for
`nexus.noveria.net` remains a separate infrastructure change and must terminate
TLS before forwarding to port `18880`.
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
Health checks, rate limiting, and security headers are active.
## Workspace mounts
@@ -45,12 +47,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
| Host path | Container mount |
|---|---|
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
## Frontend architecture
@@ -165,13 +167,102 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
| Route | Name | Description |
|---|---|---|
| `/projects` | Projects | Project portfolio |
| `/tasks` | Task Board | Task board |
| `/tasks` | Task Board | Task board with visible parent/child agent flow |
| `/models` | Models | Provider routing status |
| `/activity` | Activity | Audit timeline |
| `/chat` | Mobile Chat | Owner-chat preview |
## API endpoints
### MCP Agent Data Plane
Nexus exposes an MCP endpoint at `/mcp` for agent-facing board operations.
It uses the official `ModelContextProtocol.AspNetCore` SDK with stateless
streamable HTTP transport. Tools are a thin facade over `ITaskBridgeService`;
they must not duplicate board business logic.
Auth follows the bridge rules: requests provide `X-Agent-Id` and/or
`X-Nexus-Api-Key`. Secrets stay in OpenClaw/Gateway config and are never
embedded in frontend code.
Registered tools:
| Tool | Purpose |
|---|---|
| `nexus_get_board` | Full task board |
| `nexus_agent_overview` | Waiting/stale workflow overview |
| `nexus_get_task` | Single task |
| `nexus_get_children` | Child tasks for a parent |
| `nexus_get_activity` | Task activity history |
| `nexus_create_task` | Create parent/standalone task |
| `nexus_create_child_task` | Create visible delegation child task |
| `nexus_update_status` | Update status using the canonical enum only |
| `nexus_append_activity` | Append checkpoint/activity |
| `nexus_handoff` | Handoff to a known agent |
The compatible `/api/bridge` HTTP facade remains available for internal
diagnostics and transition clients. New agent integrations should use MCP;
`/api/dashboard` is UI/admin surface, not an agent contract.
### Mission Control Gateway Plane
Nexus keeps the Browser -> Nexus -> OpenClaw boundary: the frontend never talks
to OpenClaw directly. Read-only Gateway status is exposed through
`GET /api/dashboard/gateway`; it reports reachability, discovered Gateway
version and the optional `Integrations:OpenClaw:RequiredVersion` pin. A set pin
does not mutate production config, but makes protocol drift visible in the UI.
Agent activity shown as "Thinking" is redacted before display. Lines containing
token, password, bearer, authorization, API key or secret markers are replaced
with a redaction marker. Persisted audit-worthy events should be written as
short Activity entries, not raw session transcripts.
Nexus activity updates stream live through the Dashboard SSE channel and are
filtered by explicit `agentIds`. Gateway session history is read-only fallback
data: it is fetched on demand, redacted before display and not persisted as a
long-term raw transcript. Agent "Now" and "Today" summaries are deterministic
derivations from redacted Nexus activity plus redacted Gateway history; Nexus
does not call an LLM to summarize this feed.
Config writes and approval actions are owner-only. Config saves validate before
replacement, keep a `.bak` when an existing file is replaced, write audit events
without file contents or secrets and return structured `validation`, `backup`
and `reloadCheck` results. Workspace Markdown hot reload is currently reported
truthfully as `not_supported`; JSON validation exists in the save path but JSON
files are not exposed unless they are explicitly allowlisted for editing.
### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
Agent-zu-Backend-Kommunikation. Kein Frontend-Code ruft diese Endpunkte auf.
Auth: `X-Agent-Id` Header, `X-Nexus-Api-Key`, oder JWT. Rate-Limited (30/min).
| Methode | Pfad | Kommando | Beschreibung |
|---|---|---|---|
| `GET` | `/api/bridge/health` | — | Bridge-Health-Check |
| `POST` | `/api/bridge/tasks` | `create_task` | Neue Top-Level-Task erstellen |
| `POST` | `/api/bridge/tasks/{id}/children` | `create_child_task` | Child-Task unter Parent erstellen |
| `PATCH` | `/api/bridge/tasks/{id}/status` | `update_status` | Task-Status ändern |
| `POST` | `/api/bridge/tasks/{id}/activity` | `append_activity` | Aktivitätseintrag anhängen |
| `POST` | `/api/bridge/tasks/{id}/handoff` | `handoff` | Task an anderen Agent übergeben |
| `GET` | `/api/bridge/board` | `get_board` | Vollständiges Task-Board |
| `GET` | `/api/bridge/tasks/{id}` | `get_task` | Einzelne Task abrufen |
| `GET` | `/api/bridge/tasks/{id}/children` | `get_children` | Child-Tasks abrufen |
| `GET` | `/api/bridge/tasks/{id}/activity` | `get_activity` | Task-Aktivität abrufen |
| `GET` | `/api/bridge/agent-overview` | `get_agent_overview` | Agent-Workflow-Übersicht |
Response-Format (TaskBridgeCommandResponse<T>):
```json
{
"ok": true,
"command": "create_task",
"data": { ... },
"error": null,
"timestamp": "2026-06-22T15:30:00.000Z"
}
```
### Health & Auth (public or rate-limited)
| Method | Path | Auth | Description |
@@ -191,6 +282,15 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|---|---|---|
| `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) |
### Parent/Child task flow
The Task Board now models OpenClaw delegation as a visible parent/child flow:
- Iris keeps the parent task `In progress` while delegated work is running.
- Delegated agent work is represented as visible child tasks linked via `parentTaskId`.
- Child tasks use the normal visible states (`Backlog`, `In progress`, `Review`, `Blocked`, `Done`) instead of a separate hidden delegation lane.
- Agent progress hints on parent tasks derive from recent activity and child-task status summaries.
- Full workflow documentation: [`docs/openclaw-task-board-flow.md`](docs/openclaw-task-board-flow.md)
### Projects
| Method | Path | Description |
@@ -207,11 +307,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|---|---|---|
| `GET` | `/api/v1/tasks` | List all tasks |
| `POST` | `/api/v1/tasks` | Create task |
| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour |
| `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals |
| `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) |
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress backlog) |
| `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
### Agents
@@ -221,10 +321,11 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
| `GET` | `/api/v1/agents` | List all agents |
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
| `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) |
| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary |
| `POST` | `/api/v1/agents/{id}/command` | Send command to agent |
| `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) |
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result |
### Memory & Docs
@@ -283,11 +384,16 @@ Backlog → Blocked → In progress / Done
provider key. Conversation IDs are stable per browser and Iris is the default
agent target.
The configured model-routing policy is:
The configured model-routing policy routes through the OpenClaw Gateway only.
Ollama and NVIDIA providers have been removed. Currently active models:
1. `qwen3:4b` through Ollama for routine and monitoring work
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
3. `gpt-5.5` through OpenClaw for strategic and critical review
| Agent | Model |
|-------|-------|
| Iris | `openai/gpt-5.4` |
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
The Settings module reports runtime and provider state without exposing
credentials.
@@ -303,24 +409,23 @@ Every push to `main` triggers `.gitea/workflows/ci.yaml`:
CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3)
### CD — Auto + Manual (CD v4)
Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main)
#### Auto-Deploy (after successful CI jobs on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
- Uses safe defaults: `patch` bump, all services, main ref
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
- Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
- Starts only after backend, frontend, and security jobs succeed on `main`
- Deploys the current `main` version after CI succeeds.
- This replaces `workflow_run`, which did not create deploy runs in this Gitea 1.26.3 installation.
- The deploy script reads `VERSION`; it does not mutate Git, bump versions, or create tags
#### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions
2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
2. Workflow validates `VERSION`, builds and deploys `main`
3. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`)
@@ -332,7 +437,7 @@ Deployment can happen automatically or manually:
#### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
+1 -1
View File
@@ -1 +1 @@
0.2.55
0.2.56
+108 -6
View File
@@ -45,6 +45,94 @@ public class AgentServiceTests
Assert.Null(agent);
}
[Fact]
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
Assert.Contains("product-owner", ids);
Assert.Contains("programmer-fast", ids);
}
[Fact]
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("Developer", agent.Role);
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
}
[Fact]
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "deepseek/deepseek-v4-flash"
},
"list": [
{
"id": "iris",
"name": "iris",
"model": "openai/gpt-5.5"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5", agent!.Model);
}
[Fact]
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "openai/gpt-5.5-mini"
},
"list": [
{
"id": "reviewer",
"name": "reviewer"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
}
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -53,10 +141,10 @@ public class AgentServiceTests
})
.Build();
private static string CreateAgentConfigFile()
private static string CreateAgentConfigFile(string? json = null)
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
File.WriteAllText(path, json ??
"""
{
"agents": {
@@ -69,19 +157,33 @@ public class AgentServiceTests
"list": [
{
"id": "iris",
"name": "iris"
"name": "iris",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "product-owner",
"name": "product-owner",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "programmer",
"name": "programmer"
"name": "programmer",
"model": { "primary": "openai/gpt-5.4" }
},
{
"id": "programmer-fast",
"name": "programmer-fast",
"model": { "primary": "openai/gpt-5.3-codex-spark" }
},
{
"id": "reviewer",
"name": "reviewer"
"name": "reviewer",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "architekt",
"name": "architekt"
"name": "architekt",
"model": { "primary": "openai/gpt-5.5" }
}
]
}
+397
View File
@@ -0,0 +1,397 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Tests for AuthService login, change-password, admin-reset, and related flows.
/// These are unit-level tests using an in-memory EF Core database so no
/// external PostgreSQL instance is needed.
/// </summary>
public sealed class AuthServiceTests
{
// ── Fixture helpers ─────────────────────────────────────────────────
/// <summary>
/// Creates a test fixture with an in-memory database, a UserRepository,
/// and an AuthService backed by an in-memory configuration.
/// </summary>
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
var repo = new UserRepository(db);
// In-memory config with minimum required JWT settings
var config = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
var auth = new AuthService(repo, config, logger);
return (db, repo, auth);
}
private static LoginRequest Login(string email, string password)
=> new() { Email = email, Password = password };
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
{
var user = new NexusUser
{
Email = email,
NormalizedEmail = AuthService.NormalizeEmail(email),
DisplayName = email.Split('@')[0],
PasswordHash = PasswordSecurity.Hash(password),
Role = role
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
// ══════════════════════════════════════════════════════════════════
// Password Security Unit Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public void Hash_And_Verify_RoundTrip_Succeeds()
{
const string password = "MyTestPassword123!";
var hash = PasswordSecurity.Hash(password);
Assert.NotNull(hash);
Assert.StartsWith("v1.", hash);
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
Assert.True(ok);
Assert.False(needsUpgrade);
}
[Fact]
public void Verify_WrongPassword_Fails()
{
var hash = PasswordSecurity.Hash("CorrectPassword123!");
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalse()
{
Assert.False(PasswordSecurity.Verify("password", "", out _));
}
[Fact]
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
{
const string password = "OldFormatPassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
Assert.True(ok);
Assert.True(needsUpgrade);
}
// ══════════════════════════════════════════════════════════════════
// Login Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task Login_WithValidCredentials_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string password = "ValidPassword123!";
await SeedUserAsync(db, "test@example.com", password);
var session = await auth.LoginAsync(Login("test@example.com", password));
Assert.NotNull(session);
Assert.Equal("test", session.User.DisplayName);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
}
[Fact]
public async Task Login_WithNonexistentEmail_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
}
[Fact]
public async Task Login_UpdatesLastLoginAt()
{
var (db, repo, auth) = CreateFixture();
const string password = "TestPassword123!";
var user = await SeedUserAsync(db, "test@example.com", password);
var beforeLogin = user.LastLoginAt;
await Task.Delay(10);
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated!.LastLoginAt);
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
}
/// <summary>
/// Validates that LoginAsync persists a password hash upgrade AND login
/// timestamps even when there are NO expired refresh tokens. Previously
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
/// but that only happens when oldTokens.Count > 0.
/// </summary>
[Fact]
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
{
var (db, repo, auth) = CreateFixture();
const string password = "LegacyUpgradePassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var user = new NexusUser
{
Email = "legacy@example.com",
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
DisplayName = "Legacy",
PasswordHash = legacyHash,
Role = "user"
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Login triggers hash upgrade
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.StartsWith("v1.", updated.PasswordHash);
Assert.NotEqual(legacyHash, updated.PasswordHash);
// Second login with the upgraded hash should also work
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
}
[Fact]
public async Task Login_WithExistingHash_DoesNotChangeHash()
{
var (db, repo, auth) = CreateFixture();
const string password = "StablePassword123!";
var user = await SeedUserAsync(db, "stable@example.com", password);
var originalHash = user.PasswordHash;
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(originalHash, updated.PasswordHash);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string oldPw = "OldPassword123!";
const string newPw = "NewPassword456!";
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = oldPw,
NewPassword = newPw
});
Assert.True(result);
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
}
[Fact]
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = "WrongPassword456!",
NewPassword = "NewPassword789!"
}));
}
// ══════════════════════════════════════════════════════════════════
// Admin Reset Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task AdminResetPassword_WithValidToken_Succeeds()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
const string oldPw = "OldPassword123!";
const string newPw = "NewAdminPassword456!";
await SeedUserAsync(db, "adminreset@example.com", oldPw);
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
}
[Fact]
public async Task AdminResetPassword_WithInvalidToken_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
}
[Fact]
public async Task AdminResetPassword_NonexistentUser_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
}
[Fact]
public async Task AdminResetPassword_ShortPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
}
// ══════════════════════════════════════════════════════════════════
// Profile Update Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateProfile_ChangesDisplayName()
{
var (db, repo, auth) = CreateFixture();
const string password = "Password123!";
var user = await SeedUserAsync(db, "profile@example.com", password);
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
{
DisplayName = "New Name"
});
Assert.NotNull(updated);
Assert.Equal("New Name", updated.DisplayName);
}
// ══════════════════════════════════════════════════════════════════
// NormalizeEmail
// ══════════════════════════════════════════════════════════════════
[Fact]
public void NormalizeEmail_TrimsAndUppercases()
{
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
}
}
/// <summary>
/// Minimal in-memory IConfiguration implementation for unit tests.
/// Reads from a case-insensitive dictionary.
/// </summary>
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly Dictionary<string, string?> _data;
private readonly Dictionary<string, MemoryConfigSection> _sections;
public MemoryConfig(Dictionary<string, string?> data)
{
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
}
public string? this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : null;
set => _data[key] = value ?? string.Empty;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (!_sections.TryGetValue(key, out var section))
{
section = new MemoryConfigSection(key, this);
_sections[key] = section;
}
return section;
}
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
{
public string Key => path.Split(':').Last();
public string Path => path;
public string? Value { get => root[path]; set => root[path] = value; }
public string? this[string key]
{
get => root[$"{path}:{key}"];
set => root[$"{path}:{key}"] = value;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
=> root.GetSection($"{path}:{key}");
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
internal sealed class NeverToken : IChangeToken
{
public static readonly NeverToken Instance = new();
public bool HasChanged => false;
public bool ActiveChangeCallbacks => false;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
}
internal sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
+17
View File
@@ -0,0 +1,17 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ChatControllerTests
{
[Fact]
public void ChatController_RequiresAuthorization()
{
var attribute = typeof(ChatController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
}
}
+496
View File
@@ -0,0 +1,496 @@
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Data;
using Nexus.Api.Controllers;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class MissionControlPhaseTests
{
[Fact]
public void AgentConfigSave_IsBaoOwnerOnly()
{
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal("owner", authorize!.Roles);
}
[Fact]
public void TaskApprovalEndpoints_AreOwnerOnly()
{
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
}
[Fact]
public void GatewayActivityRedaction_RemovesSensitiveLines()
{
var text = OpenClawGatewayClient.RedactSensitiveText("""
Status: ok
Authorization: Bearer abc.def.ghi
Next step ready
X-Nexus-Api-Key: secret
""");
Assert.Contains("Status: ok", text);
Assert.Contains("Next step ready", text);
Assert.DoesNotContain("Bearer abc", text);
Assert.DoesNotContain("secret", text);
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
}
[Fact]
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
{
var now = DateTimeOffset.UtcNow;
var activity = new[]
{
new ActivityEvent
{
Type = "agent_task",
Message = "programmer completed repo scan",
CreatedAt = now.AddHours(-3)
}
};
var gateway = new[]
{
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
};
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
Assert.Equal("gateway-session-history", summary.Now.Source);
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
Assert.Contains("Working on redaction", summary.Now.Text);
Assert.Equal("derived-mixed", summary.Today.Source);
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
Assert.Contains("Working on redaction", summary.Today.Text);
Assert.Contains("Checking task mapping", summary.Today.Text);
Assert.Contains("programmer completed repo scan", summary.Today.Text);
}
[Fact]
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var liveUpdates = new LiveUpdateService();
var subscription = await liveUpdates.SubscribeAsync();
var repository = new ActivityRepository(db, liveUpdates);
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
});
var stored = await repository.GetRecentAsync(1);
Assert.Single(stored);
Assert.DoesNotContain("secret-token", stored[0].Message);
Assert.Contains("programmer", stored[0].Message);
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
var envelope = await subscription.Reader.ReadAsync();
Assert.Equal("activity.created", envelope.Type);
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
using var doc = JsonDocument.Parse(payloadJson);
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Contains("programmer", agentIds);
}
[Fact]
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var repository = new ActivityRepository(db, new LiveUpdateService());
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: compile module"
});
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent reviewer: inspect module"
});
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
Assert.Single(programmerEvents);
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GatewayInfo_ReportsVersionDrift()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("2026.07.08", info.Version);
Assert.Equal("2026.07.09", info.RequiredVersion);
Assert.Equal("drift", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.08", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Null(info.Version);
Assert.Equal("missing", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.09", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
{
var client = CreateClient(request =>
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
};
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
return response;
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("matched", info.VersionStatus);
Assert.True(info.VersionMatches);
Assert.Null(info.Warning);
}
[Fact]
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
var client = CreateClient(request =>
{
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
{
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
object status = agentId switch
{
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
_ => new { status = "ready", model = "openai/gpt-5.5" }
};
return ToolResult(status);
}
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
var agents = await client.GetAgentsAsync();
Assert.Collection(agents.OrderBy(a => a.Id),
architekt =>
{
Assert.Equal("architekt", architekt.Id);
Assert.Equal("unsupported", architekt.StatusKind);
Assert.Equal("Unsupported", architekt.StatusLabel);
Assert.Equal("tool not available", architekt.StatusDetail);
},
iris =>
{
Assert.Equal("iris", iris.Id);
Assert.Equal("connected", iris.StatusKind);
Assert.Equal("Arbeitet", iris.StatusLabel);
},
programmer =>
{
Assert.Equal("programmer", programmer.Id);
Assert.Equal("stale", programmer.StatusKind);
Assert.Equal("Stale", programmer.StatusLabel);
Assert.NotNull(programmer.StatusDetail);
Assert.Contains("40m", programmer.StatusDetail!);
},
reviewer =>
{
Assert.Equal("reviewer", reviewer.Id);
Assert.Equal("error", reviewer.StatusKind);
Assert.Equal("Fehler", reviewer.StatusLabel);
Assert.Equal("gateway timeout", reviewer.StatusDetail);
});
}
[Fact]
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "original");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
Assert.NotNull(attempt.Failure);
Assert.Equal("validation_failed", attempt.Failure!.Code);
Assert.Equal("failed", attempt.Failure.Validation.Status);
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "before");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
Assert.NotNull(attempt.SaveResult);
var result = attempt.SaveResult!;
Assert.Equal("passed", result.Validation.Status);
Assert.Equal("markdown", result.Validation.FileKind);
Assert.Equal("created", result.Backup.Status);
Assert.True(result.Backup.BackupCreated);
Assert.Equal("not_supported", result.ReloadCheck.Status);
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
{
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"validation_failed",
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
new AgentConfigBackupResult("not_applicable", false),
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
var activityRepo = new CapturingActivityRepository();
var controller = new AgentsController(
new FakeAgentService(),
new FakeAgentRuntime(),
activityRepo,
configService,
new FakeDashboardService(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "bao"),
new Claim(ClaimTypes.Role, "owner")
], "TestAuth"))
}
}
};
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
var audit = Assert.Single(activityRepo.Added);
Assert.Equal("config_audit", audit.Type);
Assert.Contains("validation=failed", audit.Message);
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
}
private static OpenClawGatewayClient CreateClient(
Func<HttpRequestMessage, HttpResponseMessage> responder,
string? requiredVersion = null,
string[]? agentIds = null)
{
var configValues = new Dictionary<string, string?>
{
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
};
if (agentIds is not null)
{
var configPath = Path.GetTempFileName();
File.WriteAllText(configPath, JsonSerializer.Serialize(new
{
agents = new
{
list = agentIds.Select(id => new { id }).ToArray()
}
}));
configValues["AgentConfigPath"] = configPath;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
{
BaseAddress = new Uri("http://gateway.local")
};
return new OpenClawGatewayClient(httpClient, configuration);
}
private static HttpResponseMessage ToolResult(object payload)
=> new(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new { ok = true, result = payload }),
Encoding.UTF8,
"application/json")
};
}
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
{
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
=> Task.FromResult<AgentConfigFileContent?>(null);
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
=> Task.FromResult(attempt);
}
file sealed class CapturingActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeAgentService : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AgentDetail?>(null);
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
{
public string Name => "fake";
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+3
View File
@@ -9,6 +9,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
+149
View File
@@ -0,0 +1,149 @@
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class NexusMcpToolsTests
{
[Fact]
public void NexusMcpTools_RegistersExpectedToolNames()
{
var toolNames = typeof(NexusMcpTools)
.GetMethods()
.Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false)
.OfType<McpServerToolAttribute>()
.FirstOrDefault())
.Where(attribute => attribute is not null)
.Select(attribute => attribute!.Name ?? string.Empty)
.Order()
.ToArray();
Assert.Equal(
[
"nexus_agent_overview",
"nexus_append_activity",
"nexus_create_child_task",
"nexus_create_task",
"nexus_get_activity",
"nexus_get_board",
"nexus_get_children",
"nexus_get_task",
"nexus_handoff",
"nexus_update_status"
], toolNames);
}
[Fact]
public void NexusMcpTaskState_OnlyContainsCanonicalStates()
{
Assert.Equal(
[
nameof(NexusMcpTaskState.Backlog),
nameof(NexusMcpTaskState.InProgress),
nameof(NexusMcpTaskState.Blocked),
nameof(NexusMcpTaskState.Done),
nameof(NexusMcpTaskState.Review)
], Enum.GetNames<NexusMcpTaskState>());
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
}
[Fact]
public async Task McpTools_ReadAndWrite_UseBridgeService()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("iris");
var tools = CreateTools(fixture);
var createResult = await tools.CreateTask(
title: "MCP parent",
detail: "Created through MCP tool facade",
priority: "High",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(createResult.Ok);
Assert.NotNull(createResult.Data);
Assert.Equal("MCP parent", createResult.Data!.Title);
var activityResult = await tools.AppendActivity(
createResult.Data.Id,
"MCP checkpoint",
"comment",
CancellationToken.None);
Assert.True(activityResult.Ok);
Assert.Equal("MCP checkpoint", activityResult.Data!.Message);
var statusResult = await tools.UpdateStatus(
createResult.Data.Id,
NexusMcpTaskState.InProgress,
CancellationToken.None);
Assert.True(statusResult.Ok);
Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State);
var board = await tools.GetBoard(CancellationToken.None);
Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id);
var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None);
Assert.True(taskResult.Ok);
Assert.Equal("MCP parent", taskResult.Data!.Title);
}
[Fact]
public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var task = await fixture.TaskService.CreateDashboardTaskAsync(
"Programmer cannot move",
"State changes stay with Iris/Bao.",
"iris",
"Normal",
"programmer",
null,
CancellationToken.None);
fixture.SetCallerAgent("programmer");
var tools = CreateTools(fixture);
var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("nexus_update_status", result.Command);
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task McpTools_ServiceKey_ResolvesAsNexusSystem()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext(
headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
});
var tools = CreateTools(fixture);
var result = await tools.CreateTask(
title: "System MCP task",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("bao", result.Data!.Source);
}
private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture)
=> new(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.HttpContextAccessor,
fixture.Configuration,
NullLogger<NexusMcpTools>.Instance);
}
+8
View File
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
@@ -109,6 +111,9 @@ internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>(), ct);
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException();
@@ -140,4 +145,7 @@ internal sealed class SnapshotAgentServiceStub : IAgentService
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris" });
}
+348
View File
@@ -0,0 +1,348 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class StaleTaskRecoveryTests
{
[Fact]
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stale in progress",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
await fixture.ActivityRepository.AddAsync(new ActivityEvent
{
Type = "comment",
Message = "Previous agent note",
TaskId = staleInProgress.Id,
CreatedAt = staleTimestamp.AddMinutes(15)
}, CancellationToken.None);
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Blocked task",
State = "Blocked",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Review task",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Done task",
State = "Done",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Backlog task",
State = "Backlog",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
CreatedAt = staleTimestamp
}, CancellationToken.None);
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(1, resetCount);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
Assert.NotNull(resetActivity);
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var taskId = Guid.NewGuid();
var staleCandidate = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var currentTask = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
var activityRepository = new FakeActivityRepository();
var liveUpdateService = new FakeLiveUpdateService();
var recoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(0, resetCount);
Assert.Equal("Review", currentTask.State);
Assert.Equal(0, taskRepository.ResetCount);
Assert.Empty(activityRepository.Added);
Assert.Equal(0, liveUpdateService.PublishCount);
}
[Fact]
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 4,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
Assert.Equal(7, resetCount);
}
[Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 2,
IntervalMinutes = 30
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await backgroundService.StartAsync(cts.Token);
await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
}
[Fact]
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
{
const string key = "TaskRecovery__StaleHours";
var originalValue = Environment.GetEnvironmentVariable(key);
try
{
Environment.SetEnvironmentVariable(key, "5");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
})
.AddEnvironmentVariables()
.Build();
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
Assert.NotNull(options);
Assert.Equal(5, options!.StaleHours);
Assert.Equal(30, options.IntervalMinutes);
}
finally
{
Environment.SetEnvironmentVariable(key, originalValue);
}
}
}
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
{
public int ResetCount { get; private set; }
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask> { staleCandidate });
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
public Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (id != currentTask.Id
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|| currentTask.UpdatedAt >= staleBefore)
{
return Task.FromResult(false);
}
ResetCount++;
currentTask.State = "Backlog";
currentTask.UpdatedAt = updatedAt;
return Task.FromResult(true);
}
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask>());
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
=> Task.FromResult(task);
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<int> CountAsync(CancellationToken ct = default)
=> Task.FromResult(0);
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> Task.FromResult<WorkTask?>(null);
}
file sealed class FakeActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type,
string? sort,
int page,
int pageSize,
CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeLiveUpdateService : ILiveUpdateService
{
public int PublishCount { get; private set; }
public long CurrentSequence => PublishCount;
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
PublishCount++;
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
}
}
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{
public int CallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; }
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
CallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
+245
View File
@@ -0,0 +1,245 @@
using Nexus.Api.Data;
using Xunit;
namespace Nexus.Api.Tests;
public class TaskBoardTests
{
// ── TaskStateHelper: BoardGroupKey ──
[Theory]
[InlineData("Backlog", "offen")]
[InlineData("In progress", "inProgress")]
[InlineData("Review", "review")]
[InlineData("Blocked", "blocked")]
[InlineData("Done", "done")]
[InlineData("backlog", "offen")]
[InlineData("in progress", "inProgress")]
[InlineData("review", "review")]
[InlineData("blocked", "blocked")]
[InlineData("done", "done")]
[InlineData("", "offen")]
[InlineData(null, "offen")]
[InlineData("unknown", "offen")]
public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
{
var result = TaskStateHelper.BoardGroupKey(state);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: BoardGroupToState ──
[Theory]
[InlineData("offen", "Backlog")]
[InlineData("inProgress", "In progress")]
[InlineData("inprogress", "In progress")]
[InlineData("review", "Review")]
[InlineData("blocked", "Blocked")]
[InlineData("done", "Done")]
[InlineData("Offen", "Backlog")]
[InlineData("", null)]
[InlineData(null, null)]
[InlineData("unknown", null)]
public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
{
var result = TaskStateHelper.BoardGroupToState(groupKey);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: AllStates has 5 entries ──
[Fact]
public void AllStates_ContainsAllFiveStates()
{
var states = TaskStateHelper.AllStates;
Assert.Equal(5, states.Length);
Assert.Contains("Backlog", states);
Assert.Contains("In progress", states);
Assert.Contains("Review", states);
Assert.Contains("Blocked", states);
Assert.Contains("Done", states);
}
// ── TaskStateHelper: IsValidState ──
[Theory]
[InlineData("Backlog", true)]
[InlineData("In progress", true)]
[InlineData("Review", true)]
[InlineData("Blocked", true)]
[InlineData("Done", true)]
[InlineData("backlog", true)]
[InlineData("offen", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("unknown", false)]
public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsValidState(state));
}
// ── TaskStateHelper: IsInProgressOrBlocked ──
[Theory]
[InlineData("In progress", true)]
[InlineData("Blocked", true)]
[InlineData("Backlog", false)]
[InlineData("Review", false)]
[InlineData("Done", false)]
[InlineData(null, false)]
public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
}
// ── TaskStateHelper: IsDoneOrBacklog ──
[Theory]
[InlineData("Done", true)]
[InlineData("Backlog", true)]
[InlineData("In progress", false)]
[InlineData("Review", false)]
[InlineData("Blocked", false)]
[InlineData(null, false)]
public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
}
// ── TaskStateHelper: ToDisplayString ──
[Theory]
[InlineData("Backlog", "Offen")]
[InlineData("In progress", "In Bearbeitung")]
[InlineData("Review", "Review")]
[InlineData("Blocked", "Blockiert")]
[InlineData("Done", "Erledigt")]
[InlineData("backlog", "Offen")]
[InlineData("", "")]
[InlineData(null, "")]
[InlineData("unknown", "unknown")]
public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
{
Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
}
// ── TaskState helper: ToStateString and ToTaskState roundtrip ──
[Fact]
public void ToStateString_And_ToTaskState_RoundTrip()
{
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Review, TaskState.Blocked, TaskState.Done };
foreach (var state in states)
{
var str = state.ToStateString();
var parsed = str.ToTaskState();
Assert.Equal(state, parsed);
}
}
[Fact]
public void ToTaskState_DefaultsToBacklog_ForUnknownString()
{
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
}
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
[Fact]
public void CanChangeState_Iris_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
}
[Fact]
public void CanChangeState_Bao_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
}
[Fact]
public void CanChangeState_NexusSystem_IsAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
}
[Fact]
public void CanChangeState_UnknownCaller_Rejected()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.False(TaskStateHelper.CanChangeState("", task));
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
Assert.False(TaskStateHelper.CanChangeState(null, task));
}
// ── TaskStateHelper: CanEditContent ──
[Fact]
public void CanEditContent_Iris_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("iris"));
}
[Fact]
public void CanEditContent_Bao_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("bao"));
}
[Fact]
public void CanEditContent_SubAgents_AreAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("programmer"));
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
Assert.True(TaskStateHelper.CanEditContent("architekt"));
}
[Fact]
public void CanEditContent_NexusSystem_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
}
[Fact]
public void CanEditContent_UnknownCaller_Rejected()
{
Assert.False(TaskStateHelper.CanEditContent(""));
Assert.False(TaskStateHelper.CanEditContent(null));
Assert.False(TaskStateHelper.CanEditContent(" "));
}
}
+558
View File
@@ -0,0 +1,558 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class TaskWorkflowTests
{
[Fact]
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"PO spec",
"Prepare specification",
"iris",
"Medium",
"product-owner",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
Assert.Equal("Backlog", child.State);
Assert.Equal("product-owner", child.AssignedTo);
Assert.Equal("programmer-fast", child.ExpectedFrom);
Assert.True(child.IsAgentTask);
}
[Fact]
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"Implement",
"Code changes",
"iris",
"High",
"programmer-fast",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
Assert.NotNull(dto);
Assert.True(dto!.HasVisibleDelegation);
Assert.NotNull(dto.LastActivityMessage);
Assert.Equal("programmer-fast", dto.AssignedTo);
Assert.Equal("programmer-fast", dto.ExpectedFrom);
}
[Fact]
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Review",
"Review implementation",
"iris",
"Medium",
"reviewer",
"reviewer",
startsInProgress: false,
ct: CancellationToken.None);
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
var child = Assert.Single(children);
Assert.True(child.HasVisibleDelegation);
Assert.NotNull(child.LastActivityMessage);
Assert.Equal("reviewer", child.AssignedTo);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new DashboardController(
new FakeDashboardService(),
fixture.TaskService,
fixture.ActivityRepository,
new HttpContextAccessor(),
fixture.AgentService,
fixture.Configuration,
fixture.NotificationService,
fixture.LiveUpdateService)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext()
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "unknown-agent"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("programmer-fast");
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Implement",
"Ship the change",
"programmer-fast",
"Medium",
"programmer-fast",
"programmer-fast",
startsInProgress: false,
ct: CancellationToken.None);
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
Assert.NotNull(updatedParent);
Assert.Equal("In progress", updatedParent!.State);
}
private static void AssertStatusCode(IResult result, int expectedStatusCode)
{
if (expectedStatusCode == StatusCodes.Status403Forbidden)
{
Assert.Equal("Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult", result.GetType().FullName);
return;
}
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(expectedStatusCode, statusResult.StatusCode);
}
}
internal sealed class TaskWorkflowFixture : IAsyncDisposable
{
private readonly NexusDbContext _db;
private TaskWorkflowFixture(
NexusDbContext db,
IConfiguration configuration,
ITaskRepository taskRepository,
IActivityRepository activityRepository,
INotificationService notificationService,
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService,
ITaskService taskService,
ITaskBridgeService taskBridgeService,
IAgentService agentService,
HttpContextAccessor httpContextAccessor)
{
_db = db;
Configuration = configuration;
TaskRepository = taskRepository;
ActivityRepository = activityRepository;
NotificationService = notificationService;
LiveUpdateService = liveUpdateService;
StaleTaskRecoveryService = staleTaskRecoveryService;
TaskService = taskService;
TaskBridgeService = taskBridgeService;
AgentService = agentService;
HttpContextAccessor = httpContextAccessor;
}
public IConfiguration Configuration { get; }
public ITaskRepository TaskRepository { get; }
public IActivityRepository ActivityRepository { get; }
public INotificationService NotificationService { get; }
public ILiveUpdateService LiveUpdateService { get; }
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
public ITaskService TaskService { get; }
public ITaskBridgeService TaskBridgeService { get; }
public IAgentService AgentService { get; }
public HttpContextAccessor HttpContextAccessor { get; }
public static async Task<TaskWorkflowFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath,
["NexusApiKey"] = "test-service-key"
})
.Build();
var agentService = new AgentService(configuration, new FakeRuntime());
var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db);
var notificationService = new NotificationService(db, liveUpdateService);
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
var taskService = new TaskService(
taskRepository,
activityRepository,
notificationService,
agentService,
httpContextAccessor,
liveUpdateService,
staleTaskRecoveryService);
var taskBridgeService = new TaskBridgeService(
taskService,
agentService,
activityRepository,
notificationService,
liveUpdateService);
return new TaskWorkflowFixture(
db,
configuration,
taskRepository,
activityRepository,
notificationService,
liveUpdateService,
staleTaskRecoveryService,
taskService,
taskBridgeService,
agentService,
httpContextAccessor);
}
public static DefaultHttpContext CreateHttpContext(
string? agentId = null,
Dictionary<string, string>? headers = null,
ClaimsPrincipal? user = null)
{
var httpContext = new DefaultHttpContext();
if (!string.IsNullOrWhiteSpace(agentId))
httpContext.Request.Headers["X-Agent-Id"] = agentId;
if (headers is not null)
{
foreach (var (key, value) in headers)
httpContext.Request.Headers[key] = value;
}
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
return httpContext;
}
public static ClaimsPrincipal CreateUser(string userId, string role)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
public void SetCallerAgent(string agentId)
{
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
}
public async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
}
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
]
}
}
""");
return path;
}
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
*.user
*.suo
.vs/
.vscode/
.git/
.gitignore
.env
*.log
+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;
}
+163 -7
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
@@ -14,6 +16,7 @@ public class AgentsController(
IAgentRuntime runtime,
IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase
{
[HttpGet]
@@ -39,7 +42,25 @@ public class AgentsController(
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }));
var activity = items
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
.ToList();
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
foreach (var entry in gatewayEntries)
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
return Results.Ok(activity
.OrderByDescending(x => x.At)
.Take(50));
}
[HttpGet("{id}/summary")]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
}
[HttpPost("{id}/command")]
@@ -84,17 +105,152 @@ public class AgentsController(
}
[HttpPut("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
try
{
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
var caller = DescribeCaller(HttpContext.User);
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
if (attempt.Failure is not null)
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
}
var result = attempt.SaveResult!;
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
});
}
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);
}
}
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
public sealed record AgentActivityResponse(
long? Id,
string Type,
string Message,
DateTimeOffset At,
string Source,
string? RelativeTime = null
);
public sealed record AgentSummaryResponse(
AgentSummaryItemResponse Now,
AgentSummaryItemResponse Today,
DateTimeOffset GeneratedAt
);
public sealed record AgentSummaryItemResponse(
string Text,
string Source,
DateTimeOffset? Timestamp
);
public static class AgentSummaryBuilder
{
public static AgentSummaryResponse Build(
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
DateTimeOffset nowUtc)
{
var points = activity
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
.OrderByDescending(point => point.Timestamp)
.ToList();
var current = points.FirstOrDefault();
var now = current is null
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
var windowStart = nowUtc.AddHours(-24);
var todayPoints = points
.Where(point => point.Timestamp >= windowStart)
.ToList();
AgentSummaryItemResponse today;
if (todayPoints.Count == 0)
{
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
}
else
{
var snippets = todayPoints
.Select(point => point.Text)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
if (extraCount > 0)
text += $" (+{extraCount} weitere)";
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
? todayPoints[0].Source
: "derived-mixed";
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
}
return new AgentSummaryResponse(now, today, nowUtc);
}
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
}
+34 -3
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -14,7 +15,8 @@ public class AuthController(
IAuthService authService,
IAntiforgery antiforgery,
IConfiguration config,
IHostEnvironment env) : ControllerBase
IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
public IActionResult GetCsrfToken()
@@ -30,11 +32,38 @@ public class AuthController(
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
var session = await authService.LoginAsync(request, ct);
if (session is null) return Results.Unauthorized();
var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
var session = await authService.LoginAsync(request, ct);
if (session is null)
{
var remaining = attemptTracker.RecordFailedAttempt(ip);
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
// Attach remaining info to the 401 response via headers only
// (the frontend can also parse the 429 body)
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
if (retryAfterSeconds > 0)
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
}
// Success — reset attempt counter
attemptTracker.Reset(ip);
SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session));
}
@@ -54,6 +83,8 @@ public class AuthController(
SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session));
}
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.DTOs;
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/chat")]
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
+294 -6
View File
@@ -1,13 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard")]
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
public class DashboardController(
IDashboardService dashboardService,
ITaskService taskService,
IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor,
IAgentService agentService,
IConfiguration configuration,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ControllerBase
{
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
@@ -44,6 +56,10 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct);
[HttpGet("gateway")]
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
@@ -115,9 +131,16 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
try
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("tasks/{id:guid}")]
@@ -125,7 +148,7 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var result = await taskService.UpdateDashboardTaskAsync(
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
@@ -149,6 +172,20 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
// Enforce workflow rules based on caller agent
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
{
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
}
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch
{
@@ -158,6 +195,257 @@ public class DashboardController(IDashboardService dashboardService, ITaskServic
};
}
// ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
if (!await CanReadBoardAsync(ct))
return Unauthorized();
return Ok(await taskService.GetBoardAsync(ct));
}
[HttpGet("live")]
public async Task Live(
[FromQuery] string forUser = "bao",
[FromQuery] int notificationLimit = 50,
[FromQuery] long? afterSequence = null,
CancellationToken ct = default)
{
Response.Headers.Append("Content-Type", "text/event-stream");
Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
Response.Headers.Append("Connection", "keep-alive");
Response.Headers.Append("X-Accel-Buffering", "no");
async Task WriteEventAsync(string eventName, object payload)
{
await Response.WriteAsync($"event: {eventName}\n", ct);
await Response.WriteAsync($"data: {System.Text.Json.JsonSerializer.Serialize(payload)}\n\n", ct);
await Response.Body.FlushAsync(ct);
}
var currentSequence = liveUpdateService.CurrentSequence;
var initial = new DashboardLiveSnapshotDto(
await taskService.GetBoardAsync(ct),
await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct),
new LiveCursorDto(currentSequence, DateTimeOffset.UtcNow, "live"));
await WriteEventAsync("snapshot", initial);
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested)
{
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
{
var envelope = await readTask;
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
continue;
envelope = envelope with { Payload = snapshot };
}
if (envelope.Type == "tasks.board.snapshot")
{
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
}
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else if (await heartbeatTask)
{
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
}
}
}
[HttpPatch("tasks/{id:guid}/move")]
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.State))
return BadRequest(new { error = "State is required." });
// Enforce workflow rules based on caller agent
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
{
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
}
var result = await taskService.MoveTaskAsync(id, request.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// </summary>
private string ResolveCallerAgent()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
// ── New Endpoints: Reset Stale, Children, Activity ──
[HttpPost("tasks/reset-stale")]
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
[FromBody] ResetStaleRequest request, CancellationToken ct)
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
}
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var board = await taskService.GetBoardAsync(ct);
var children = board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done)
.Where(task => task.ParentTaskId == id)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
return Ok(children);
}
[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, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.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);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
}
@@ -0,0 +1,375 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// MCP-style (structured-command) backend bridge for agent-facing operations.
///
/// This is the SINGLE entrypoint for agents (Iris + sub-agents) to interact with
/// the Nexus task board, activity log, and delegation workflow.
///
/// AUTHENTICATION: Requires X-Nexus-Api-Key or a known allowed X-Agent-Id.
/// The browser NEVER uses this controller — only backend-to-backend and gateway-to-backend.
///
/// DESIGN PRINCIPLE: No MCP protocol between Nexus and Gateway — instead, the Gateway
/// calls these structured HTTP endpoints (same pattern, simpler transport).
///
/// COMMANDS:
/// create_task → POST /api/bridge/tasks
/// create_child_task → POST /api/bridge/tasks/{id}/children
/// update_status → PATCH /api/bridge/tasks/{id}/status
/// append_activity → POST /api/bridge/tasks/{id}/activity
/// handoff → POST /api/bridge/tasks/{id}/handoff
/// get_board → GET /api/bridge/board
/// get_task → GET /api/bridge/tasks/{id}
/// get_children → GET /api/bridge/tasks/{id}/children
/// get_activity → GET /api/bridge/tasks/{id}/activity
/// get_agent_overview → GET /api/bridge/agent-overview
/// </summary>
[ApiController]
[Route("api/bridge")]
[EnableRateLimiting("agents")]
public class GatewayBridgeController(
ITaskBridgeService bridge,
IAgentService agentService,
IConfiguration configuration,
ILogger<GatewayBridgeController> logger) : ControllerBase
{
private const string ApikeyErrorMessage =
"Bridge endpoints require X-Nexus-Api-Key or X-Agent-Id header with a recognized agent identity.";
[HttpGet("health")]
public IResult Health()
{
return Results.Ok(new
{
status = "ok",
service = "nexus-bridge",
version = "1.0.0",
commands = new[]
{
"create_task", "create_child_task", "update_status",
"append_activity", "handoff", "get_board", "get_task",
"get_children", "get_activity", "get_agent_overview"
}
});
}
[HttpPost("tasks")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateTask(
[FromBody] BridgeCreateTaskCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.CreateTaskAsync(
title: command.Title,
detail: command.Detail,
source: ResolveSource(agentId),
priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo ?? agentId,
projectId: command.ProjectId,
ct: ct);
return MapResult(result, "create_task");
}
[HttpPost("tasks/{parentTaskId:guid}/children")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> CreateChildTask(
Guid parentTaskId,
[FromBody] BridgeCreateChildTaskCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: command.Title,
detail: command.Detail,
source: ResolveSource(agentId),
priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo,
expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
startsInProgress: command.StartsInProgress,
ct: ct);
return MapResult(result, "create_child_task");
}
[HttpPatch("tasks/{taskId:guid}/status")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> UpdateStatus(
Guid taskId,
[FromBody] BridgeUpdateStatusCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var agentId = resolution.AgentId;
var result = await bridge.UpdateStatusAsync(
taskId: taskId,
state: command.State,
callerAgent: agentId,
ct: ct);
return MapResult(result, "update_status");
}
[HttpPost("tasks/{taskId:guid}/activity")]
public async Task<ActionResult<TaskBridgeCommandResponse<ActivityEntryDto>>> AppendActivity(
Guid taskId,
[FromBody] BridgeAppendActivityCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.AppendActivityAsync(
taskId: taskId,
message: command.Message,
type: command.Type ?? "comment",
ct: ct);
return MapActivityResult(result, "append_activity");
}
[HttpPost("tasks/{taskId:guid}/handoff")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> Handoff(
Guid taskId,
[FromBody] BridgeHandoffCommand command,
CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.HandoffAsync(
taskId: taskId,
targetAgent: command.TargetAgent,
note: command.Note,
ct: ct);
return MapResult(result, "handoff");
}
[HttpGet("board")]
public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
return Ok(await bridge.GetBoardAsync(ct));
}
[HttpGet("tasks/{taskId:guid}")]
public async Task<ActionResult<TaskBridgeCommandResponse<DashboardTaskDto>>> GetTask(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var result = await bridge.GetTaskAsync(taskId, ct);
return MapResult(result, "get_task");
}
[HttpGet("tasks/{taskId:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
return Ok(await bridge.GetChildTasksAsync(taskId, ct));
}
[HttpGet("tasks/{taskId:guid}/activity")]
public async Task<ActionResult<TaskBridgeCommandResponse<List<ActivityEntryDto>>>> GetActivity(
Guid taskId, CancellationToken ct)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var events = await bridge.GetTaskActivityAsync(taskId, ct);
var entries = events.Select(e => new ActivityEntryDto(e.Id, e.Type, e.Message, e.CreatedAt)).ToList();
return Ok(new TaskBridgeCommandResponse<List<ActivityEntryDto>>
{
Ok = true,
Command = "get_activity",
Data = entries
});
}
[HttpGet("agent-overview")]
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
CancellationToken ct,
[FromQuery] int staleHours = 2)
{
var resolution = await TryResolveAgentAsync(ct);
if (!resolution.Success)
return resolution.ErrorResult!;
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
return Ok(await bridge.GetAgentOverviewAsync(threshold, ct));
}
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
{
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
normalizedHeader,
HttpContext.Connection.RemoteIpAddress);
}
if (User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return (true, normalizedClaim, null);
// Browser JWT fallback is intentionally restricted to board owners/admins.
// Agent/service traffic should authenticate as an allowed agent or service principal.
if (User.IsInRole("owner") || User.IsInRole("admin"))
return (true, "bao", null);
}
if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
allowedActorIds.Contains("nexus-system"))
return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
logger.LogWarning("Bridge: unauthenticated request rejected from {Ip}", HttpContext.Connection.RemoteIpAddress);
return (false, string.Empty, unauthorized);
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static ActionResult MapResult<T>(TaskBridgeResult<T> result, string command) where T : class
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = true,
Command = command,
Data = result.Data
});
var statusCode = result.Outcome switch
{
TaskBridgeOutcome.NotFound => 404,
TaskBridgeOutcome.InvalidState => 422,
TaskBridgeOutcome.Unauthorized => 403,
TaskBridgeOutcome.ValidationError => 400,
_ => 500
};
return new ObjectResult(new TaskBridgeCommandResponse<T>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
private static ActionResult MapActivityResult(TaskBridgeResult<Data.ActivityEvent> result, string command)
{
if (result.Outcome == TaskBridgeOutcome.Success)
return new OkObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = true,
Command = command,
Data = result.Data is null ? null : new ActivityEntryDto(
result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt)
});
var statusCode = result.Outcome switch
{
TaskBridgeOutcome.NotFound => 404,
TaskBridgeOutcome.ValidationError => 400,
_ => 500
};
return new ObjectResult(new TaskBridgeCommandResponse<ActivityEntryDto>
{
Ok = false,
Command = command,
Error = result.Error ?? "Unknown error"
}) { StatusCode = statusCode };
}
}
public sealed class TaskBridgeCommandResponse<T>
{
public bool Ok { get; init; }
public string Command { get; init; } = string.Empty;
public T? Data { get; init; }
public string? Error { get; init; }
public string Timestamp { get; init; } = DateTimeOffset.UtcNow.ToString("o");
}
public sealed record BridgeCreateTaskCommand(
string Title,
string? Detail = null,
string? Priority = null,
string? AssignedTo = null,
Guid? ProjectId = null
);
public sealed record BridgeCreateChildTaskCommand(
string Title,
string? Detail = null,
string? Priority = null,
string? AssignedTo = null,
string? ExpectedFrom = null,
bool StartsInProgress = false
);
public sealed record BridgeUpdateStatusCommand(string State);
public sealed record BridgeAppendActivityCommand(
string Message,
string? Type = null
);
public sealed record BridgeHandoffCommand(
string TargetAgent,
string? Note = null
);
public sealed record ActivityEntryDto(
long Id,
string Type,
string Message,
DateTimeOffset CreatedAt
);
+16 -3
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Integrations;
@@ -7,6 +8,13 @@ namespace Nexus.Api.Controllers;
[ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{
[AllowAnonymous]
[HttpGet("/health/live")]
public IResult Live()
{
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
}
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
{
@@ -26,20 +34,25 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
runtimeDetail = ex.Message;
}
static IReadOnlyDictionary<string, object?> NormalizeData(IReadOnlyDictionary<string, object> source)
{
return source.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);
}
var entries = report.Entries.ToDictionary(
e => e.Key,
e => new
{
status = e.Value.Status.ToString(),
description = e.Value.Description,
data = e.Value.Data
data = NormalizeData(e.Value.Data)
});
entries["runtime"] = new
{
status = runtimeStatus,
description = runtimeDetail ?? "Runtime status checked",
data = (IReadOnlyDictionary<string, object>)new Dictionary<string, object>()
description = runtimeDetail,
data = new Dictionary<string, object?>() as IReadOnlyDictionary<string, object?>
};
var isHealthy = report.Status == HealthStatus.Healthy && runtimeStatus == "Online";
@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard/notifications")]
public class NotificationsController(INotificationService notificationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
}
[HttpGet("unread-count")]
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
return Ok(new UnreadCountDto(count));
}
[HttpGet("snapshot")]
public async Task<ActionResult<NotificationSnapshotDto>> GetSnapshot(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
return Ok(await notificationService.GetSnapshotAsync(forUser, limit, unreadOnly, ct));
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/projects")]
public class ProjectsController(IProjectService projectService) : ControllerBase
+99 -1
View File
@@ -1,13 +1,22 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/v1/tasks")]
public class TasksController(ITaskService taskService) : ControllerBase
public class TasksController(
ITaskService taskService,
IAgentService agentService,
IConfiguration configuration,
IActivityRepository activityRepository) : ControllerBase
{
[HttpGet]
public async Task<IResult> GetAll(CancellationToken ct)
@@ -24,6 +33,7 @@ public class TasksController(ITaskService taskService) : ControllerBase
}
[HttpGet("pending-approval")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetPendingApproval(CancellationToken ct)
{
var pending = await taskService.GetPendingApprovalAsync(ct);
@@ -31,9 +41,11 @@ public class TasksController(ITaskService taskService) : ControllerBase
}
[HttpPost("{id:guid}/approve")]
[Authorize(Roles = "owner")]
public async Task<IResult> Approve(Guid id, CancellationToken ct)
{
var result = await taskService.ApproveAsync(id, ct);
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -46,9 +58,11 @@ public class TasksController(ITaskService taskService) : ControllerBase
}
[HttpPost("{id:guid}/reject")]
[Authorize(Roles = "owner")]
public async Task<IResult> Reject(Guid id, CancellationToken ct)
{
var result = await taskService.RejectAsync(id, ct);
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -70,6 +84,10 @@ public class TasksController(ITaskService taskService) : ControllerBase
return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
TaskOperationOutcome.InvalidState => Results.Problem(
title: "Action denied",
detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
};
}
@@ -99,4 +117,84 @@ public class TasksController(ITaskService taskService) : ControllerBase
_ => Results.NoContent()
};
}
// ── Board & Stale-Reset (für Iris Autonomous Worker) ──
/// <summary>
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
/// Wird vom Iris Autonomous Worker genutzt.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (bel. erkannter Agent) ODER
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
{
var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
return Results.Unauthorized();
return Results.Ok(await taskService.GetBoardAsync(ct));
}
/// <summary>
/// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt.
///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
/// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isService && !isPrivilegedUser)
{
// A presented but unrecognized agent header is an invalid credential, not a missing one.
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
return Results.Forbid();
return Results.Unauthorized();
}
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
}
private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
TaskOperationOutcome outcome,
string? state,
CancellationToken ct)
{
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
TaskId = taskId
}, ct);
}
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
+23
View File
@@ -26,6 +26,29 @@ public sealed record UserInfo
public string Role { get; init; } = string.Empty;
}
public sealed record AdminUserInfo
{
public Guid Id { get; init; }
public string Email { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
}
public sealed record AdminCreateUserRequest
{
public string Email { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string? DisplayName { get; init; }
public string? Role { get; init; }
}
public sealed record AdminUpdateRoleRequest
{
public string Role { get; init; } = string.Empty;
}
public sealed record UpdateProfileRequest
{
[MaxLength(100)]
+1
View File
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
string? Title,
DateTimeOffset? Since
);
+120 -4
View File
@@ -19,7 +19,8 @@ public enum TaskState
Backlog,
InProgress,
Blocked,
Done
Done,
Review
}
public static class TaskStateHelper
@@ -29,7 +30,8 @@ public static class TaskStateHelper
[TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress",
[TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done"
[TaskState.Done] = "Done",
[TaskState.Review] = "Review"
};
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
@@ -37,11 +39,22 @@ public static class TaskStateHelper
["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress,
["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done
["Done"] = TaskState.Done,
["Review"] = TaskState.Review
};
/// <summary>Mapping from state string to display label.</summary>
private static readonly Dictionary<string, string> DisplayLabels = new(StringComparer.OrdinalIgnoreCase)
{
["Backlog"] = "Offen",
["In progress"] = "In Bearbeitung",
["Review"] = "Review",
["Blocked"] = "Blockiert",
["Done"] = "Erledigt"
};
/// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"];
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"];
/// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state];
@@ -54,6 +67,10 @@ public static class TaskStateHelper
public static bool IsValidState(string? state) =>
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
/// <summary>Returns the German display label for a state string.</summary>
public static string ToDisplayString(string? state) =>
state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? "";
public static bool IsInProgressOrBlocked(string? state) =>
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
@@ -61,6 +78,75 @@ public static class TaskStateHelper
public static bool IsDoneOrBacklog(string? state) =>
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the caller is allowed to change this task's state.
/// POLICY:
/// - **Iris und Bao** dürfen Status ändern / verschieben.
/// - Sub-agents (programmer, reviewer, architekt, researcher, executor) dürfen NIEMALS Status ändern.
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
/// </summary>
public static bool CanChangeState(string? callerAgent, WorkTask task)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
// Sub-agents must never move state
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt", "researcher", "executor" };
if (subAgents.Contains(caller)) return false;
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
if (caller == "nexus-system") return true;
// Iris und Bao dürfen Status ändern
return caller == "iris" || caller == "bao";
}
/// <summary>
/// Returns true if the caller is allowed to edit a task's content fields
/// (title, detail, priority, assignedTo, dueDate).
/// POLICY:
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
/// - Nur unbekannte/leere Caller werden abgewiesen.
/// </summary>
public static bool CanEditContent(string? callerAgent)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
if (string.IsNullOrWhiteSpace(caller)) return false;
return true;
}
/// <summary>Group key for board responses (lowercased English state).</summary>
public static string BoardGroupKey(string? state)
{
if (string.IsNullOrWhiteSpace(state)) return "offen";
var lower = state.ToLowerInvariant();
return lower switch
{
"backlog" => "offen",
"in progress" => "inProgress",
"review" => "review",
"blocked" => "blocked",
"done" => "done",
_ => "offen"
};
}
/// <summary>Map a board group key back to the canonical state string.</summary>
public static string? BoardGroupToState(string? groupKey)
{
if (string.IsNullOrWhiteSpace(groupKey)) return null;
var lower = groupKey.ToLowerInvariant();
return lower switch
{
"offen" => "Backlog",
"inprogress" => "In progress",
"review" => "Review",
"blocked" => "Blocked",
"done" => "Done",
_ => null
};
}
}
public sealed class Project
@@ -82,15 +168,45 @@ public sealed class WorkTask
public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; }
/// <summary>
/// True if this task was created programmatically by an agent (not manually by Bao).
/// Agent-tasks in the board are subject to stricter workflow rules.
/// </summary>
public bool IsAgentTask { get; set; } = false;
/// <summary>
/// Which agent/user is expected to respond next.
/// Helps Iris see who she is waiting for.
/// </summary>
public string? ExpectedFrom { get; set; }
public Guid? ParentTaskId { get; set; }
public WorkTask? ParentTask { get; set; }
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
public Guid? ProjectId { get; set; }
public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class Notification
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
public string? Message { get; set; } // Detailtext
public required string ForUser { get; set; } // "bao" oder "iris"
public Guid? TaskId { get; set; } // Verknüpfte Task
public bool IsRead { get; set; } = false;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ActivityEvent
{
public long Id { get; init; }
public required string Type { get; set; }
public required string Message { get; set; }
public Guid? TaskId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+16
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Nexus.Api.Data;
@@ -28,6 +29,21 @@ public class NexusUser
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
/// <summary>
/// Tracks one-time seed operations so they are never re-executed — even
/// if the underlying data is deleted. This is the single guard that
/// prevents owner-password drift after DB resets or volume recreations.
/// </summary>
[Table("SeedAudit")]
public class SeedAudit
{
[Key]
[MaxLength(80)]
public string Key { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class RefreshToken
{
public Guid Id { get; set; } = Guid.NewGuid();
@@ -0,0 +1,311 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618214335_AddNotifications")]
partial class AddNotifications
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
table: "Notifications",
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskParentChild : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentTaskId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDueDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DueDate",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DueDate",
table: "Tasks");
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddActivityTaskReference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "TaskId",
table: "Activity",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Activity_TaskId",
table: "Activity",
column: "TaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Activity_TaskId",
table: "Activity");
migrationBuilder.DropColumn(
name: "TaskId",
table: "Activity");
}
}
}
@@ -0,0 +1,270 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618233003_AddDelegatedState")]
partial class AddDelegatedState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddDelegatedState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delegated state is a pure code change to the TaskState enum and
// TaskStateHelper. No schema change required since the State column
// is already a free-form string column.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// No schema to revert.
}
}
}
@@ -0,0 +1,322 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260620174200_AddAgentTaskFields")]
partial class AddAgentTaskFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddAgentTaskFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAgentTask",
table: "Tasks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ExpectedFrom",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks",
column: "IsAgentTask");
migrationBuilder.CreateIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks",
column: "ExpectedFrom");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "IsAgentTask",
table: "Tasks");
}
}
}
@@ -0,0 +1,336 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260621081500_AddSeedAudit")]
partial class AddSeedAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddSeedAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SeedAudit",
columns: table => new
{
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeedAudit", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SeedAudit");
}
}
}
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
@@ -166,6 +214,20 @@ namespace Nexus.Api.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
@@ -183,6 +245,19 @@ namespace Nexus.Api.Migrations
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
@@ -211,6 +286,12 @@ namespace Nexus.Api.Migrations
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
@@ -227,10 +308,25 @@ namespace Nexus.Api.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
+23 -1
View File
@@ -6,9 +6,11 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -19,10 +21,30 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
entity.Property(x => x.Detail).HasMaxLength(2000);
entity.Property(x => x.Source).HasMaxLength(60);
entity.Property(x => x.AssignedTo).HasMaxLength(60);
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
entity.HasIndex(x => x.Source);
entity.HasIndex(x => x.AssignedTo);
entity.HasIndex(x => x.IsAgentTask);
entity.HasIndex(x => x.ExpectedFrom);
entity.HasOne(x => x.ParentTask)
.WithMany(x => x.ChildTasks)
.HasForeignKey(x => x.ParentTaskId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Notification>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Message).HasMaxLength(1000);
entity.Property(x => x.Type).HasMaxLength(60);
entity.Property(x => x.ForUser).HasMaxLength(60);
entity.HasIndex(x => new { x.ForUser, x.IsRead, x.CreatedAt });
});
modelBuilder.Entity<ActivityEvent>(entity =>
{
entity.Property(x => x.Message).HasMaxLength(1000);
entity.HasIndex(x => x.TaskId);
});
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
+6
View File
@@ -6,6 +6,12 @@ COPY . .
RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
LABEL org.opencontainers.image.title="Nexus API" \
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
org.opencontainers.image.version="${NEXUS_VERSION}" \
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
WORKDIR /app
COPY --from=build /app/publish .
RUN apk add --no-cache curl
@@ -0,0 +1,103 @@
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.
///
/// Single-transaction guarantee: if the seed block is entered at all (user creation needed
/// or just the audit-log write), the SeedAudit row is written inside the same transaction
/// so that a crash mid-way can never leave the DB in a re-seedable state.
/// </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["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
var hasUsers = await db.Users.AnyAsync();
// ── Double-check SeedAudit after the migration — if another pod wrote it
// while we were reading, bail out early. ──
alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
// ── Use a strategy-based transaction so the user + audit row are
// persisted atomically. If the DB crashes after SaveChanges the
// entire transaction is rolled back, preventing partial-seed states.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
var initialPassword = PasswordHelper.GenerateTemporaryPassword();
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
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();
await tx.CommitAsync();
});
}
}
/// <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,262 @@
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 ModelContextProtocol.AspNetCore;
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.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
return services;
}
/// <summary>
/// Registers data repositories.
/// </summary>
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
return services;
}
/// <summary>
/// Configures health checks (PostgreSQL connectivity and runtime status).
/// </summary>
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
}
}
+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;
}
}
+2 -2
View File
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
return true;
}
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
public static bool IsValidConfigFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName)) return false;
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
}
}
+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>();
}
+126 -4
View File
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
string? Goal = null,
string RoleBadge = "badge-slate",
string StatusLabel = "Bereit",
string StatusKind = "ready",
string? StatusDetail = null,
string? Elapsed = null,
string? Think = null,
string? Next = null
@@ -86,8 +88,18 @@ public sealed record DashboardTaskDto(
string State,
string Priority,
string? AssignedTo,
Guid? ParentTaskId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
DateTimeOffset UpdatedAt,
bool IsAgentTask = false,
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null,
List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0,
int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false
);
public sealed record CreateDashboardTaskRequest(
@@ -95,7 +107,20 @@ public sealed record CreateDashboardTaskRequest(
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
string? AssignedTo,
Guid? ParentTaskId = null
);
public sealed record CreateAgentTaskRequest(
string Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo,
string? ExpectedFrom,
Guid? ParentTaskId = null,
bool StartsInProgress = true,
string? InitialState = null
);
public sealed record UpdateDashboardTaskRequest(
@@ -103,7 +128,8 @@ public sealed record UpdateDashboardTaskRequest(
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
string? AssignedTo,
DateTimeOffset? DueDate = null
);
public sealed record UpdateDashboardTaskStatusRequest(
@@ -112,5 +138,101 @@ public sealed record UpdateDashboardTaskStatusRequest(
public sealed record AgentActivityEntry(
string Time,
string Text
string Text,
DateTimeOffset Timestamp,
string Source = "gateway-session-history"
);
public sealed record GatewayRuntimeInfo(
bool Reachable,
string BaseUrl,
string? Version,
string? RequiredVersion,
bool VersionPinned,
bool VersionMatches,
string VersionStatus,
DateTimeOffset CheckedAt,
string? Message,
string? Warning = null
);
// ── Task Board DTOs ──
public sealed record BoardResponse(
List<DashboardTaskDto> Offen,
List<DashboardTaskDto> InProgress,
List<DashboardTaskDto> Review,
List<DashboardTaskDto> Blocked,
List<DashboardTaskDto> Done
);
public sealed record MoveTaskRequest(
string State
);
public sealed record ResetStaleRequest(
int StaleHours = 2
);
public sealed record ResetStaleResponse(
int ResetCount
);
public sealed record PostActivityRequest(
string Message,
string? Type = null
);
// ── Agent Workflow DTOs ──
/// <summary>
/// Overview of the agent workflow state, grouping tasks by expected respondent
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
/// </summary>
public sealed record AgentWorkflowOverview(
List<DashboardTaskDto> WaitingForBao,
List<DashboardTaskDto> WaitingForIris,
List<DashboardTaskDto> WaitingForOthers,
List<DashboardTaskDto> StaleTasks,
TimeSpan StaleThreshold
);
// ── Notification DTOs ──
public sealed record NotificationDto(
Guid Id, string Type, string Title, string? Message,
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
);
public sealed record UnreadCountDto(int Count);
public sealed record LiveUpdateEnvelope(
string Type,
DateTimeOffset Timestamp,
object Payload,
long Sequence,
string Channel
);
public sealed record LiveCursorDto(
long Sequence,
DateTimeOffset Timestamp,
string Mode
);
public sealed record NotificationSnapshotDto(
List<NotificationDto> Notifications,
int UnreadCount,
string ForUser
);
public sealed record DashboardLiveSnapshotDto(
BoardResponse Board,
NotificationSnapshotDto Notifications,
LiveCursorDto Cursor
);
public sealed record DashboardLiveEventDto(
LiveUpdateEnvelope Envelope,
LiveCursorDto Cursor
);
+1 -1
View File
@@ -10,9 +10,9 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup>
</Project>
+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);
}
}
+15 -222
View File
@@ -1,234 +1,27 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Middleware;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Nexus.Api.Extensions;
var builder = WebApplication.CreateBuilder(args);
// --- JWT Configuration ---
var jwtKey = builder.Configuration["Jwt:Key"];
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
// --- Rate Limiting ---
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
// --- Forwarded Headers ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// --- Swagger & JSON ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// --- Database ---
builder.Services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
// --- HTTP Clients ---
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
// --- Application Services ---
builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAgentService, AgentService>();
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IProjectService, ProjectService>();
builder.Services.AddScoped<ITaskService, TaskService>();
builder.Services.AddScoped<IOperationsService, OperationsService>();
builder.Services.AddScoped<ITeamService, TeamService>();
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
builder.Services.AddSingleton<IMemoryService, MemoryService>();
builder.Services.AddSingleton<IIncidentService, IncidentService>();
builder.Services.AddSingleton<IDocService, DocService>();
builder.Services.AddScoped<ICalendarService, CalendarService>();
// --- Repositories ---
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
// --- Health Checks ---
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
// --- Controllers ---
// --- Service Registration ---
builder.Services.AddNexusAuth(builder.Configuration);
builder.Services.AddNexusRateLimiting();
builder.Services.AddNexusForwardedHeaders();
builder.Services.AddNexusSwagger();
builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddNexusApplicationServices();
builder.Services.AddNexusRepositories();
builder.Services.AddNexusHealthChecks(builder.Configuration);
builder.Services.AddControllers();
var app = builder.Build();
// --- Database Migration & Owner Seeding ---
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = builder.Configuration["Owner:Password"];
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
// --- Database Migration & Seeding ---
await app.EnsureDatabaseAsync();
// --- Middleware Pipeline ---
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseNexusPipeline(app.Environment);
app.MapMcp();
app.MapControllers();
app.Run();
// --- Helpers ---
static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
@@ -0,0 +1,84 @@
using System.Collections.Concurrent;
namespace Nexus.Api.RateLimiting;
/// <summary>
/// Simple in-memory tracking of login attempts per IP,
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
///
/// Provides remaining-attempt count that can be passed back to the frontend.
/// </summary>
public sealed class LoginAttemptTracker
{
private const int MaxAttempts = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
// IP → (count, windowStartTicks)
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
/// <summary>
/// Registers a failed attempt for the given IP.
/// Returns remaining attempts (0 = locked out until reset).
/// </summary>
public int RecordFailedAttempt(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
var (count, windowStart) = _store.AddOrUpdate(ip,
_ => (1, now),
(_, entry) =>
{
if (now - entry.WindowStartTicks >= windowTicks)
return (1, now);
return (entry.Count + 1, entry.WindowStartTicks);
});
return Math.Max(0, MaxAttempts - count);
}
/// <summary>
/// Returns the remaining attempts for the given IP without recording.
/// </summary>
public int GetRemaining(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (_store.TryGetValue(ip, out var entry))
{
if (now - entry.WindowStartTicks >= windowTicks)
return MaxAttempts;
return Math.Max(0, MaxAttempts - entry.Count);
}
return MaxAttempts;
}
/// <summary>
/// Returns the number of seconds until the rate-limit window resets,
/// or 0 if the window has already expired / no attempts recorded.
/// </summary>
public int GetRetryAfterSeconds(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (!_store.TryGetValue(ip, out var entry))
return 0;
var elapsed = now - entry.WindowStartTicks;
if (elapsed >= windowTicks)
return 0;
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
}
/// <summary>
/// Resets attempt count for the given IP (e.g. on success).
/// </summary>
public void Reset(string ip)
{
_store.TryRemove(ip, out _);
}
}
+35 -5
View File
@@ -3,11 +3,23 @@ using Nexus.Api.Data;
namespace Nexus.Api.Repositories;
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0)
return Task.FromResult(new List<ActivityEvent>());
return db.Activity.AsNoTracking()
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
{
@@ -27,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
return (items, totalCount);
}
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking()
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent")
public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
{
var candidateCount = Math.Max(take * 8, 100);
var recent = await db.Activity.AsNoTracking()
.OrderByDescending(x => x.CreatedAt)
.Take(take)
.Take(candidateCount)
.ToListAsync(ct);
return recent
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
.Take(take)
.ToList();
}
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity);
await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
activity.Id,
activity.Type,
activity.Message,
activity.TaskId,
activity.CreatedAt,
agentIds
}, "activity");
return activity;
}
}
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
public interface IActivityRepository
{
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
+1
View File
@@ -8,6 +8,7 @@ public interface ITaskRepository
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default);
+2
View File
@@ -7,8 +7,10 @@ public interface IUserRepository
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
Task<bool> AnyUsersAsync(CancellationToken ct = default);
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
+36
View File
@@ -27,9 +27,45 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
return task;
}
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (!db.Database.IsRelational())
{
var task = await db.Tasks
.FirstOrDefaultAsync(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore, ct);
if (task is null)
{
return false;
}
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
await db.SaveChangesAsync(ct);
return true;
}
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
return affectedRows > 0;
}
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
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)
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
public Task<bool> AnyUsersAsync(CancellationToken ct = default)
=> db.Users.AnyAsync(ct);
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
{
// Remove refresh tokens first
var tokens = await db.RefreshTokens
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
db.RefreshTokens.RemoveRange(tokens);
db.Users.Remove(user);
await db.SaveChangesAsync(ct);
}
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
=> db.RefreshTokens
.Include(r => r.User)
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public static class AgentActivityText
{
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
[
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
];
private static readonly Regex[] ResidualSensitivePatterns =
[
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
];
private static readonly string[] KnownActorIds =
[
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
"bao",
"nexus-system"
];
public static string RedactForDisplay(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return content ?? string.Empty;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var sanitized = lines[i];
foreach (var (pattern, replacement) in InlineRedactions)
{
sanitized = pattern.Replace(sanitized, replacement);
}
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
sanitized = "[redacted sensitive line]";
lines[i] = sanitized;
}
return string.Join('\n', lines).Trim();
}
public static bool MatchesAgent(string? content, string agentId)
{
if (string.IsNullOrWhiteSpace(agentId))
return false;
var normalized = agentId.Trim().ToLowerInvariant();
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
public static string[] ExtractAgentIds(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return [];
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var actorId in KnownActorIds)
{
if (BuildActorRegex(actorId).IsMatch(content))
matches.Add(actorId);
}
return matches
.Select(actorId => actorId.ToLowerInvariant())
.OrderBy(actorId => actorId, StringComparer.Ordinal)
.ToArray();
}
private static Regex BuildActorRegex(string actorId)
=> ActorPatternCache.GetOrAdd(actorId, static key =>
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
}
+88 -5
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
if (!AllowedFiles.Contains(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
@@ -37,18 +40,44 @@ public sealed class AgentConfigService : IAgentConfigService
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var fileKind = DetermineFileKind(fileName);
var validation = Validate(fileName, content, fileKind);
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
var reload = CreateReloadCheck();
if (validation.Errors.Count > 0)
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"workspace_not_found",
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
backup,
reload));
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null;
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"invalid_path",
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
backup,
reload));
var tempPath = safePath + ".tmp";
var backupPath = safePath + ".bak";
var backupCreated = false;
try
{
if (File.Exists(safePath))
{
File.Copy(safePath, backupPath, overwrite: true);
backupCreated = true;
}
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
}
var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
return new AgentConfigSaveAttempt(
new AgentConfigFileSaveResult(
fileName,
fi.Length,
fi.LastWriteTimeUtc,
new AgentConfigValidationResult("passed", fileKind, []),
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
CreateReloadCheck()),
null);
}
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
{
var errors = new List<string>();
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
errors.Add("Filename is invalid.");
else if (!AllowedFiles.Contains(fileName))
errors.Add("File is not allowed for Mission Control editing.");
if (content.IndexOf('\0') >= 0)
errors.Add("Content contains null bytes.");
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
{
try
{
JsonDocument.Parse(content);
}
catch (JsonException ex)
{
errors.Add($"JSON validation failed: {ex.Message}");
}
}
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
}
private static string DetermineFileKind(string fileName)
{
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return "json";
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return "markdown";
return "text";
}
private static AgentConfigReloadCheckResult CreateReloadCheck()
=> new(
"not_supported",
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
}
+44
View File
@@ -0,0 +1,44 @@
namespace Nexus.Api.Services;
public static class AgentIdentityCatalog
{
public static readonly string[] DefaultConfiguredAgentIds =
[
"main",
"iris",
"product-owner",
"programmer",
"programmer-fast",
"reviewer",
"architekt",
"researcher",
"executor"
];
private static readonly string[] WorkflowActorIds =
[
"bao",
"nexus-system"
];
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
foreach (var configuredAgentId in configuredAgentIds)
{
if (!string.IsNullOrWhiteSpace(configuredAgentId))
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
}
return ids;
}
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+93 -16
View File
@@ -20,7 +20,8 @@ public sealed record AgentConfig
public string? AgentDir { get; init; }
[JsonPropertyName("model")]
public string? Model { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; }
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
public string Theme { get; init; } = string.Empty;
}
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primaryModel = reader.GetString();
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo(
string Id,
string Name,
@@ -73,6 +128,7 @@ public interface IAgentService
{
Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken);
Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken);
Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken);
}
public sealed class AgentService(IConfiguration configuration, IAgentRuntime runtime) : IAgentService
@@ -93,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs)
{
var model = config.Model ?? "deepseek/deepseek-v4-flash";
var model = ResolveModel(config);
var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty;
@@ -140,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role,
Model: config.Model ?? "deepseek/deepseek-v4-flash",
Model: ResolveModel(config),
Status: runtimeStatus.Status,
LastSeen: now,
Workspace: config.Workspace,
@@ -151,33 +207,48 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
);
}
public async Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
{
var configs = await LoadAgentConfigsAsync(cancellationToken);
return configs
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
.Select(config => config.Id.Trim().ToLowerInvariant())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Orchestrator",
"product-owner" => "Product Owner",
"programmer" => "Developer",
"programmer-fast" => "Developer",
"reviewer" => "Reviewer",
"architekt" => "Architect",
"main" => "Assistant",
_ => "Custom"
};
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{
var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json";
if (!File.Exists(path))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement))
return Array.Empty<AgentConfig>();
return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
@@ -193,29 +264,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
// Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null)
config = config with { Model = defaults.Model.Primary };
if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace };
configs.Add(config);
}
return configs.AsReadOnly();
return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
}
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults
{
[JsonPropertyName("workspace")]
public string? Workspace { get; init; }
[JsonPropertyName("model")]
public AgentDefaultModel? Model { get; init; }
}
private sealed record AgentDefaultModel
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
[JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
}
}
+5
View File
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
user.LastLoginAt = DateTimeOffset.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow;
// Persist user changes (password upgrade, login timestamp) immediately.
// Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to
// trigger SaveChangesAsync is fragile — if zero tokens are expired the
// tracked changes might not be flushed before the response is produced.
await _users.UpdateAsync(user, ct);
await _users.RemoveExpiredTokensAsync(user.Id, ct);
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
}
+13
View File
@@ -112,6 +112,19 @@ public sealed class DashboardService(
}
}
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
try
{
return await gateway.GetGatewayInfoAsync(ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Gateway info fetch failed");
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
+29 -2
View File
@@ -4,11 +4,38 @@ public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime Mo
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
public sealed record AgentConfigFileSaveResult(
string FileName,
long Size,
DateTime ModifiedAt,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveFailure(
string Code,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveAttempt(
AgentConfigFileSaveResult? SaveResult,
AgentConfigSaveFailure? Failure
);
public interface IAgentConfigService
{
const int MaxConfigFileBytes = 500 * 1024;
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+1
View File
@@ -16,6 +16,7 @@ public interface IDashboardService
Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
+17
View File
@@ -0,0 +1,17 @@
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface ILiveUpdateService
{
Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default);
LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard");
long CurrentSequence { get; }
}
public sealed class LiveUpdateSubscription
{
public ChannelReader<LiveUpdateEnvelope> Reader { get; init; } = default!;
public long StartingSequence { get; init; }
}
+14
View File
@@ -0,0 +1,14 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface INotificationService
{
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
}
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService
{
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+134
View File
@@ -0,0 +1,134 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
/// <summary>
/// Structured backend bridge for agent/task commands.
/// Provides a clean, typed API for agents (Iris and sub-agents) to interact
/// with the task board, activity log, and delegation workflow.
///
/// This is the internal service layer — never exposed directly to the browser.
/// The GatewayBridgeController wraps this for agent-facing HTTP access.
/// </summary>
public interface ITaskBridgeService
{
// ── Task CRUD (Agent-Commands) ──
/// <summary>
/// Creates a new top-level task (parent or standalone).
/// Returns the created task DTO.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default);
/// <summary>
/// Creates a child task linked to an existing parent.
/// This is the primary delegation command: iris creates a child task,
/// assigns it to a sub-agent, and tracks it on the board.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default);
/// <summary>
/// Updates the status/state of a task.
/// Enforces CanChangeState rules (only iris/bao/nexus-system may change state).
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default);
/// <summary>
/// Appends an activity entry to a task (comment, status note, agent note).
/// Used by agents to annotate their progress on the board.
/// </summary>
Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default);
/// <summary>
/// Handles a task handoff: sets ExpectedFrom to the target agent,
/// appends a handoff activity entry, and optionally updates assigned-to.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default);
// ── Query (Read) ──
/// <summary>
/// Returns the full task board state (grouped by status column).
/// </summary>
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
/// <summary>
/// Returns a single task by ID.
/// </summary>
Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId,
CancellationToken ct = default);
/// <summary>
/// Returns all child tasks for a given parent task.
/// </summary>
Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId,
CancellationToken ct = default);
/// <summary>
/// Returns task activity history.
/// </summary>
Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId,
CancellationToken ct = default);
// ── Agent Workflow ──
/// <summary>
/// Returns the agent-workflow overview: who is expected to respond,
/// stale tasks, workload distribution.
/// </summary>
Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null,
CancellationToken ct = default);
}
/// <summary>
/// Result pattern for task-bridge operations.
/// WorkTask? is null on NotFound; state is stored in the Outcome.
/// </summary>
public sealed record TaskBridgeResult<T>(
TaskBridgeOutcome Outcome,
T? Data = default,
string? Error = null
);
public enum TaskBridgeOutcome
{
Success,
NotFound,
InvalidState,
Unauthorized,
ValidationError
}
+18 -2
View File
@@ -1,5 +1,6 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
@@ -21,9 +22,24 @@ public interface ITaskService
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = 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> StartCoordinationAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
// Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
// Agent Workflow Overview
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+87
View File
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using System.Threading.Channels;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class LiveUpdateService : ILiveUpdateService
{
private const int ReplayLimit = 256;
private readonly ConcurrentDictionary<Guid, Channel<LiveUpdateEnvelope>> _subscribers = new();
private readonly object _historyLock = new();
private readonly Queue<LiveUpdateEnvelope> _history = new();
private long _sequence;
public long CurrentSequence => Interlocked.Read(ref _sequence);
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<LiveUpdateEnvelope>(new UnboundedChannelOptions
{
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
var id = Guid.NewGuid();
_subscribers[id] = channel;
var replay = afterSequence.HasValue ? GetReplay(afterSequence.Value) : Array.Empty<LiveUpdateEnvelope>();
foreach (var envelope in replay)
{
channel.Writer.TryWrite(envelope);
}
ct.Register(() =>
{
if (_subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
});
return Task.FromResult(new LiveUpdateSubscription
{
Reader = channel.Reader,
StartingSequence = replay.LastOrDefault()?.Sequence ?? CurrentSequence
});
}
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
var envelope = new LiveUpdateEnvelope(
type,
DateTimeOffset.UtcNow,
payload,
Interlocked.Increment(ref _sequence),
channel);
lock (_historyLock)
{
_history.Enqueue(envelope);
while (_history.Count > ReplayLimit)
{
_history.Dequeue();
}
}
foreach (var (id, subscriber) in _subscribers)
{
if (!subscriber.Writer.TryWrite(envelope) && _subscribers.TryRemove(id, out var removed))
{
removed.Writer.TryComplete();
}
}
return envelope;
}
private LiveUpdateEnvelope[] GetReplay(long afterSequence)
{
lock (_historyLock)
{
return _history.Where(item => item.Sequence > afterSequence).ToArray();
}
}
}
+232
View File
@@ -0,0 +1,232 @@
using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
[McpServerToolType]
public sealed class NexusMcpTools(
ITaskBridgeService bridge,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetBoardAsync(ct);
}
[McpServerTool(Name = "nexus_agent_overview")]
[Description("Get agent workflow overview, including waiting and stale task groups.")]
public async Task<AgentWorkflowOverview> GetAgentOverview(
[Description("Stale threshold in hours. Defaults to 2.")]
int staleHours = 2,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
}
[McpServerTool(Name = "nexus_get_task")]
[Description("Get one Nexus task by ID.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
}
[McpServerTool(Name = "nexus_get_children")]
[Description("Get child tasks for a Nexus parent task.")]
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetChildTasksAsync(parentTaskId, ct);
}
[McpServerTool(Name = "nexus_get_activity")]
[Description("Get activity entries for a Nexus task.")]
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateTaskAsync(
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo ?? caller,
ct: ct);
return ToResponse(result, "nexus_create_task");
}
[McpServerTool(Name = "nexus_create_child_task")]
[Description("Create a visible child task under a Nexus parent task for delegation.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
Guid parentTaskId,
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo,
expectedFrom: expectedFrom ?? assignedTo,
startsInProgress: startsInProgress,
ct: ct);
return ToResponse(result, "nexus_create_child_task");
}
[McpServerTool(Name = "nexus_update_status")]
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
Guid taskId,
NexusMcpTaskState state,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
return ToResponse(result, "nexus_update_status");
}
[McpServerTool(Name = "nexus_append_activity")]
[Description("Append an activity/checkpoint entry to a Nexus task.")]
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
return ToActivityResponse(result, "nexus_append_activity");
}
[McpServerTool(Name = "nexus_handoff")]
[Description("Mark a task handoff to another known agent and append handoff activity.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
return ToResponse(result, "nexus_handoff");
}
private async Task<string> ResolveCallerAsync(CancellationToken ct)
{
var context = httpContextAccessor.HttpContext
?? throw new UnauthorizedAccessException("MCP request context is not available.");
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return normalizedHeader;
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
normalizedHeader,
context.Connection.RemoteIpAddress);
}
if (context.User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return normalizedClaim;
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
return "bao";
}
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
allowedActorIds.Contains("nexus-system"))
return "nexus-system";
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static string ToStateString(NexusMcpTaskState state) => state switch
{
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
};
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
TaskBridgeResult<ActivityEvent> result,
string command)
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Data is null
? null
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
}
public enum NexusMcpTaskState
{
Backlog,
InProgress,
Blocked,
Done,
Review
}
+86
View File
@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db, ILiveUpdateService liveUpdateService) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification
{
Type = type,
Title = title,
Message = message,
ForUser = forUser.ToLowerInvariant(),
TaskId = taskId
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return notification;
}
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var query = db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant());
if (unreadOnly)
query = query.Where(n => !n.IsRead);
return await query
.OrderByDescending(n => n.CreatedAt)
.Take(limit)
.ToListAsync(ct);
}
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
{
var notification = await db.Notifications.FindAsync([id], ct);
if (notification is null) return false;
notification.IsRead = true;
await db.SaveChangesAsync(ct);
await PublishSnapshotAsync(notification.ForUser, ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var count = await db.Notifications
.Where(n => n.ForUser == normalizedUser && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
await PublishSnapshotAsync(normalizedUser, ct);
return count;
}
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
{
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
public async Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var normalizedUser = forUser.ToLowerInvariant();
var notifications = await GetForUserAsync(normalizedUser, limit, unreadOnly, ct);
var unreadCount = await GetUnreadCountAsync(normalizedUser, ct);
return new NotificationSnapshotDto(
notifications.Select(MapToDto).ToList(),
unreadCount,
normalizedUser);
}
private async Task PublishSnapshotAsync(string forUser, CancellationToken ct)
{
var snapshot = await GetSnapshotAsync(forUser, ct: ct);
liveUpdateService.Publish("notifications.snapshot", snapshot, "notifications");
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+247 -17
View File
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
private static readonly string[] SensitiveMarkers =
[
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
];
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Extract activity from session_status
var isActive = false;
string? currentTask = null;
var statusText = status?["status"]?.GetValue<string>();
if (status is not null)
{
// Check explicit isActive field
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 8. Calculate workload from queue items
var workload = CalculateAgentWorkload(id, queueItems);
var statusKind = DeriveStatusKind(status, isActive);
var statusDetail = DeriveStatusDetail(status, statusKind);
agents.Add(new DashboardAgentInfo(
Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Workload: workload,
Goal: goal,
RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status),
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
StatusKind: statusKind,
StatusDetail: statusDetail,
Elapsed: FormatElapsed(status),
Think: null,
Next: DeriveNext(isActive, currentTask)
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
}
}
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
{
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
ApplyAuth(request);
using var response = await httpClient.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
? headerValues.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
version = TryGetString(root, "version")
?? TryGetString(root, "gatewayVersion")
?? TryGetString(root, "openclawVersion");
}
catch
{
// Health endpoint may be plain text.
}
}
version = NormalizeOptional(version);
var pinned = requiredVersion is not null;
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
var matches = versionStatus is "matched" or "unpinned";
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
return new GatewayRuntimeInfo(
response.IsSuccessStatusCode,
baseUrl,
version,
requiredVersion,
pinned,
response.IsSuccessStatusCode && matches,
versionStatus,
DateTimeOffset.UtcNow,
message,
warning);
}
catch
{
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
return new GatewayRuntimeInfo(
false,
baseUrl,
null,
requiredVersion,
requiredVersion is not null,
false,
"error",
DateTimeOffset.UtcNow,
"Gateway nicht erreichbar",
warning);
}
}
public async Task<bool> DeleteCronJobAsync(string id)
{
try
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
continue;
// Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200
? msg.Content[..200] + "…"
: msg.Content;
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
var text = redacted.Length > 200
? redacted[..200] + "…"
: redacted;
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text));
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
}
}
catch
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
_ => "badge-slate"
};
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
{
if (!isActive) return "Bereit";
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
return statusText switch
return statusKind switch
{
"thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert",
_ => "Arbeitet"
"connected" => isActive ? "Arbeitet" : "Verbunden",
"thinking" => "Plant",
"blocked" => "Blockiert",
"stale" => "Stale",
"error" => "Fehler",
"unsupported" => "Unsupported",
"ready" => "Bereit",
_ => statusText?.ToLowerInvariant() switch
{
"thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert",
_ => isActive ? "Arbeitet" : "Bereit"
}
};
}
private static string DeriveStatusKind(JsonNode? status, bool isActive)
{
if (status is null)
return "error";
var statusText = status["status"]?.GetValue<string>()?.Trim();
var errorText = status["error"]?.GetValue<string>()?.Trim()
?? status["message"]?.GetValue<string>()?.Trim();
var normalized = statusText?.ToLowerInvariant();
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
if (detail.Contains("unsupported", StringComparison.Ordinal))
return "unsupported";
if (!string.IsNullOrWhiteSpace(errorText)
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
return "error";
if (normalized is "blocked" or "block")
return "blocked";
if (normalized is "thinking" or "think")
return "thinking";
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
return "stale";
if (isActive || normalized is "active" or "running" or "connected" or "online")
return "connected";
return "ready";
}
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
{
if (status is null)
return "Gateway-Status nicht abrufbar";
var message = NormalizeOptional(status["message"]?.GetValue<string>())
?? NormalizeOptional(status["error"]?.GetValue<string>())
?? NormalizeOptional(status["detail"]?.GetValue<string>());
if (message is not null)
return message;
return statusKind switch
{
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
"error" => "Session-Status konnte nicht gelesen werden",
_ => null
};
}
private static string? FormatElapsed(JsonNode? status)
{
var lastActivity = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>();
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is null) return null;
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
var diff = DateTimeOffset.UtcNow - ts;
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
"main" => "Assistant",
_ => "Custom"
};
private static string? TryGetString(JsonElement root, string property)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
public static string RedactSensitiveText(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var lower = lines[i].ToLowerInvariant();
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
{
lines[i] = "[redacted sensitive line]";
}
}
return string.Join('\n', lines);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
{
var raw = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>()
?? status?["updatedAt"]?.GetValue<string>();
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
}
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
{
if (!reachable)
return "error";
if (requiredVersion is null)
return version is null ? "unknown" : "unpinned";
if (version is null)
return "missing";
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
}
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
{
if (!reachable)
return "Gateway nicht erreichbar";
return versionStatus switch
{
"matched" => "Gateway erreichbar und Version gepinnt",
"missing" => requiredVersion is null
? "Gateway erreichbar"
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
"unpinned" => "Gateway erreichbar",
"unknown" => "Gateway erreichbar, Version nicht erkannt",
_ => "Gateway erreichbar"
};
}
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
{
if (!reachable)
return fallback ?? "Gateway nicht erreichbar";
return versionStatus switch
{
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
_ => null
};
}
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
{
if (lastActivity is null)
return "Letzte Aktivität ist veraltet";
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalMinutes < 60)
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24)
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
}
}
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Primitives;
namespace Nexus.Api.Services;
public static class RequestAuthorizationHelper
{
public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized);
public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) =>
httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration);
public static bool IsPrivilegedUser(HttpContext httpContext) =>
httpContext.User.Identity?.IsAuthenticated == true &&
(httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin"));
public static async Task<string?> ResolveAllowedAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
public static async Task<AgentHeaderResolution> ResolveAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
{
var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false);
var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed);
return new AgentHeaderResolution(
normalized,
HeaderProvided: true,
IsRecognized: normalized is not null);
}
public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration)
{
var configuredApiKey = configuration["NexusApiKey"];
if (string.IsNullOrWhiteSpace(configuredApiKey))
return false;
if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey))
return false;
return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryBackgroundService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
if (resetCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Stale task recovery run failed.");
}
try
{
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default)
{
await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
}
}
@@ -0,0 +1,13 @@
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryOptions
{
public const string SectionName = "TaskRecovery";
public int StaleHours { get; set; } = 2;
public int IntervalMinutes { get; set; } = 30;
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
}
@@ -0,0 +1,209 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
{
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = await GetStaleTasksAsync(threshold, ct);
if (staleTasks.Count == 0)
return 0;
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
var now = DateTimeOffset.UtcNow;
var resetCount = 0;
foreach (var task in staleTasks)
{
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
continue;
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
currentTask.Id,
threshold,
now,
ct);
if (!updated)
continue;
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task",
Message = message,
TaskId = task.Id
}, ct);
resetCount++;
}
if (resetCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return resetCount;
}
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
return allTasks
.Where(task => IsStaleInProgress(task, threshold))
.ToList();
}
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
&& task.UpdatedAt < threshold;
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
IEnumerable<Guid> taskIds,
CancellationToken ct)
{
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
return activities
.Where(activity => activity.TaskId.HasValue)
.GroupBy(activity => activity.TaskId!.Value)
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
}
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList();
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
var backlog = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in allTasks)
{
var dto = MapToDtoWithChildren(task, allTasks, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": backlog.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: backlog.Add(dto); break;
}
}
backlog.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(backlog, inProgress, review, blocked, done);
}
private static DashboardTaskDto MapToDtoWithChildren(
WorkTask task,
IReadOnlyList<WorkTask> allTasks,
IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks
.Where(candidate => candidate.ParentTaskId == task.Id)
.OrderByDescending(candidate => candidate.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(entry => entry.TaskId == task.Id)
.OrderByDescending(entry => entry.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
task.ParentTaskId.HasValue || task.IsAgentTask);
}
private static string BuildActivityMessage(
WorkTask task,
TimeSpan staleThreshold,
DateTimeOffset now,
DateTimeOffset? lastActivityAt)
{
var staleAge = now - task.UpdatedAt;
var details = new List<string>
{
"reason=stale-recovery",
"previous status In progress",
$"stale reference {now:O}",
$"stale age {FormatDuration(staleAge)}",
$"threshold {FormatDuration(staleThreshold)}"
};
if (lastActivityAt.HasValue)
details.Add($"last activity {lastActivityAt.Value:O}");
details.Add($"last update {task.UpdatedAt:O}");
details.Add("new status Backlog");
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
}
private static string FormatDuration(TimeSpan duration)
=> duration.ToString(@"dd\.hh\:mm\:ss");
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
};
}
+264
View File
@@ -0,0 +1,264 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
/// <summary>
/// Concrete implementation of ITaskBridgeService.
/// Wraps ITaskService, IActivityRepository, INotificationService, and ILiveUpdateService
/// into structured, predictable commands for agent-facing usage.
///
/// All operations produce typed TaskBridgeResult<T> with explicit error codes,
/// making agent consumption safe and debuggable.
/// </summary>
public sealed class TaskBridgeService(
ITaskService taskService,
IAgentService agentService,
IActivityRepository activityRepo,
INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService
{
private static readonly HashSet<string> ValidStates =
new(TaskStateHelper.AllStates, StringComparer.OrdinalIgnoreCase);
// ──────────────────────────────── Create Task ────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateTaskAsync(
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
Guid? projectId = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source);
var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Create Child Task ──────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> CreateChildTaskAsync(
Guid parentTaskId,
string title,
string? detail = null,
string? source = "iris",
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(title))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
// Verify parent exists
var parent = await taskService.GetByIdAsync(parentTaskId, ct);
if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct);
// If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{
var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct);
if (parentTransition.Outcome != TaskOperationOutcome.Success)
{
return Error<DashboardTaskDto>(
TaskBridgeOutcome.InvalidState,
$"Parent task {parentTaskId} could not be moved to In progress for coordination.");
}
}
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Update Status ──────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> UpdateStatusAsync(
Guid taskId,
string state,
string? callerAgent = null,
CancellationToken ct = default)
{
if (!ValidStates.Contains(state))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError,
$"Invalid state '{state}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
// Check authorization
if (!TaskStateHelper.CanChangeState(callerAgent, task))
return Error<DashboardTaskDto>(TaskBridgeOutcome.Unauthorized,
$"Agent '{callerAgent}' is not authorized to change task state. Only iris and bao may move tasks.");
var result = await taskService.UpdateStatusAsync(taskId, state, ct);
if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
return Success(dto);
}
// ──────────────────────────────── Append Activity ────────────────────────────
public async Task<TaskBridgeResult<ActivityEvent>> AppendActivityAsync(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(message))
return Error<ActivityEvent>(TaskBridgeOutcome.ValidationError, "Message is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<ActivityEvent>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var ev = new ActivityEvent
{
Type = type ?? "comment",
Message = message.Trim(),
TaskId = taskId
};
await activityRepo.AddAsync(ev, ct);
// Trigger live update so the board refreshes
var board = await taskService.GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board);
return Success(ev);
}
// ──────────────────────────────── Handoff ────────────────────────────────────
public async Task<TaskBridgeResult<DashboardTaskDto>> HandoffAsync(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(targetAgent))
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Target agent is required.");
var task = await taskService.GetByIdAsync(taskId, ct);
if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
if (normalizedTarget is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}";
// Update expected-from and optionally assigned-to
task.ExpectedFrom = normalizedTarget;
// If this is a child task (has parent), keep assigned-to on the child
// If standalone, set assigned-to to the target
if (!task.ParentTaskId.HasValue)
task.AssignedTo = normalizedTarget;
await taskService.UpdateDashboardTaskAsync(
taskId, title: null, detail: null, source: null,
priority: null, assignedTo: task.AssignedTo, dueDate: null, ct);
// Append handoff activity
await AppendActivityAsync(taskId, handoffNote, "handoff", ct);
// Notify the target
await notificationService.CreateAsync(
"task_assigned",
$"Handoff: {task.Title}",
handoffNote,
normalizedTarget,
task.Id,
ct);
var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto);
}
// ──────────────────────────────── Query ──────────────────────────────────────
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
=> await taskService.GetBoardAsync(ct);
public async Task<TaskBridgeResult<DashboardTaskDto>> GetTaskAsync(
Guid taskId, CancellationToken ct = default)
{
var dto = await taskService.GetDashboardTaskByIdAsync(taskId, ct);
return dto is null
? Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.")
: Success(dto);
}
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default)
{
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default)
=> await taskService.GetTaskActivityAsync(taskId, ct);
public async Task<AgentWorkflowOverview> GetAgentOverviewAsync(
TimeSpan? staleThreshold = null, CancellationToken ct = default)
{
var threshold = staleThreshold ?? TimeSpan.FromHours(2);
return await taskService.GetAgentWorkflowOverviewAsync(threshold, ct);
}
// ──────────────────────────────── Helpers ────────────────────────────────────
private static TaskBridgeResult<T> Success<T>(T data) =>
new(TaskBridgeOutcome.Success, data);
private static TaskBridgeResult<T> Error<T>(TaskBridgeOutcome outcome, string error) =>
new(outcome, Data: default, Error: error);
private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
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);
}
+502 -32
View File
@@ -1,12 +1,18 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ITaskService
IActivityRepository activityRepo,
INotificationService notificationService,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
{
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
@@ -14,6 +20,16 @@ public sealed class TaskService(
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var allTasks = (await taskRepo.GetAllAsync(ct)).ToList();
var task = allTasks.FirstOrDefault(t => t.Id == id);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync(allTasks.Select(t => t.Id), ct);
return MapToDtoWithChildren(task, allTasks, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
@@ -26,7 +42,8 @@ public sealed class TaskService(
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
@@ -40,7 +57,8 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -54,7 +72,8 @@ public sealed class TaskService(
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -66,10 +85,11 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
@@ -77,15 +97,28 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(request.Title))
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\"");
task.Title = request.Title.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
}
if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}");
task.Priority = request.Priority.Trim();
}
if (request.ProjectId.HasValue)
{
changes.Add("Projekt-ID geändert");
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
}
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -97,13 +130,12 @@ public sealed class TaskService(
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
@@ -112,36 +144,213 @@ public sealed class TaskService(
.ToList();
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
.ThenByDescending(t => t.UpdatedAt)
.ToList();
}
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithChildren(task, all, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
}));
var staleTasks = map(agentTasks
.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, staleTasks, staleThreshold);
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
if (parent is null)
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
}
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Source = normalizedSource,
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = assignedTo?.Trim()
AssignedTo = normalizedAssignee,
ParentTaskId = parentTaskId,
IsAgentTask = isVisibleDelegation
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
var activityMessages = new List<string> { $"Task \"{task.Title}\" created ({task.Source})" };
if (parentTaskId.HasValue)
{
activityMessages.Add($"Sichtbare Delegation erstellt: Child-Task von {parentTaskId.Value}.");
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Board-first Delegation: Child-Task \"{task.Title}\" für {normalizedAssignee ?? task.Source} sichtbar angelegt.",
TaskId = parentTaskId.Value
}, ct);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = string.Join(" ", activityMessages), TaskId = task.Id }, ct);
if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_assigned",
$"Neue Aufgabe: {task.Title}",
detail,
"bao",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return task;
}
public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
{
var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = normalizedExpectedFrom;
task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "agent_task",
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
TaskId = task.Id
}, ct);
if (parentTaskId.HasValue)
{
await activityRepo.AddAsync(new ActivityEvent
{
Type = "delegation",
Message = $"Parent-/Child-Delegation sichtbar: Parent {parentTaskId.Value}, Child {task.Id}, wartet auf {task.ExpectedFrom ?? task.AssignedTo ?? "unbekannt"}.",
TaskId = parentTaskId.Value
}, ct);
}
await notificationService.CreateAsync(
"agent_task_created",
$"Neuer Agent-Task: {task.Title}",
detail,
"iris",
task.Id,
ct);
await PublishBoardSnapshotAsync(ct);
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
Guid id, string? title, string? detail, string? source,
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
{
var caller = ResolveCaller();
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
task.Title = title.Trim();
}
if (detail is not null)
{
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
{
changes.Add("Beschreibung aktualisiert");
task.Detail = newDetail;
}
}
if (!string.IsNullOrWhiteSpace(source))
task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
task.Priority = priority.Trim();
}
if (assignedTo is not null)
{
var validated = await NormalizeActorAsync(assignedTo, ct);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
task.AssignedTo = validated;
}
}
if (dueDate.HasValue && task.DueDate?.Date != dueDate.Value.Date)
{
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}",
TaskId = task.Id
}, ct);
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
changeSummary,
"iris",
task.Id,
ct);
}
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -153,11 +362,29 @@ public sealed class TaskService(
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
}
public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(
task,
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
actor: "nexus-system",
activityType: "delegation",
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
ct: ct);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
@@ -167,7 +394,8 @@ public sealed class TaskService(
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
@@ -185,7 +413,249 @@ public sealed class TaskService(
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in all)
{
var dto = MapToDtoWithChildren(task, all, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": offen.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: offen.Add(dto); break;
}
}
offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(offen, inProgress, review, blocked, done);
}
private async Task PublishBoardSnapshotAsync(CancellationToken ct = default)
{
var board = await GetBoardAsync(ct);
liveUpdateService.Publish("tasks.board.snapshot", board, "board");
}
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.BoardGroupToState(newState);
if (canonical is null)
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{
var normalizedHours = Math.Max(1, staleHours);
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{
var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList();
}
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity, IReadOnlyList<WorkTask>? _allTasks = null)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
t.ParentTaskId.HasValue || t.IsAgentTask);
}
private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{
var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
}
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
{
if (!string.IsNullOrWhiteSpace(initialState))
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
if (canonical is not null)
return canonical;
}
return startsInProgress
? TaskStateHelper.ToStateString(TaskState.InProgress)
: TaskStateHelper.ToStateString(TaskState.Backlog);
}
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
WorkTask task,
string canonical,
string actor,
string activityType,
string? activityMessage,
CancellationToken ct)
{
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = activityType,
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
TaskId = task.Id
}, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
{
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_review",
$"Task zur Überprüfung: {task.Title}",
$"Status auf Review geändert von {caller}",
"bao",
task.Id,
ct);
}
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_blocked",
$"Aufgabe blockiert: {task.Title}",
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
"iris",
task.Id,
ct);
}
else
{
if (caller == "bao")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Bao hat Status geändert: {task.Title}",
$"Status → {canonical}",
"iris",
task.Id,
ct);
}
else if (caller == "iris")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Iris hat Status geändert: {task.Title}",
$"Status → {canonical}",
"bao",
task.Id,
ct);
}
}
}
}
+5
View File
@@ -5,6 +5,7 @@
"Integrations": {
"OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789",
"RequiredVersion": "",
"Token": "",
"Password": ""
},
@@ -21,5 +22,9 @@
"AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7
},
"TaskRecovery": {
"StaleHours": 2,
"IntervalMinutes": 30
},
"AllowedHosts": "*"
}
+60 -31
View File
@@ -1,10 +1,25 @@
name: nexus
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
command:
- postgres
- -c
- archive_mode=off
- -c
- archive_command=
restart: always
deploy:
resources:
limits:
memory: 384M
reservations:
memory: 96M
environment:
POSTGRES_INITDB_ARGS: --data-checksums
POSTGRES_DB: ${POSTGRES_DB:-nexus}
POSTGRES_USER: ${POSTGRES_USER:-nexus}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
@@ -22,17 +37,19 @@ services:
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./backend
restart: unless-stopped
args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
resources:
limits:
memory: 512M
reservations:
memory: 128M
environment:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
@@ -40,32 +57,33 @@ services:
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
Jwt__Issuer: ${JWT_ISSUER:-nexus}
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
Owner__Password: ${OWNER_PASSWORD:-}
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
# Initial owner password is generated once at first seed and then lives only in the DB.
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-}
NexusApiKey: ${NEXUS_API_KEY:-}
extra_hosts:
- host.docker.internal:host-gateway
depends_on:
postgres:
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1"]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes:
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
- /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
- /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
networks:
- nexus
- openclaw_default
@@ -74,39 +92,50 @@ services:
options:
max-size: "10m"
max-file: "3"
web:
build:
context: ./frontend
restart: unless-stopped
args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy:
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
resources:
limits:
memory: 128M
reservations:
memory: 32M
labels:
- "traefik.enable=true"
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
- "traefik.http.routers.nexus.tls=true"
- "traefik.http.routers.nexus.tls.certresolver=letsencrypt"
- "traefik.http.services.nexus.loadbalancer.server.port=80"
ports:
- "127.0.0.1:18880:80"
depends_on:
api:
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks: [nexus]
networks:
- nexus
- proxy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
nexus:
openclaw_default:
external: true
proxy:
external: true
volumes:
nexus-postgres:
@@ -0,0 +1,498 @@
# Nexus Board-First Orchestration & Sichere OpenClaw-Integration
> Gegenprüfung: Architekt, 2026-06-22
> Gegenstand: Sicherster Pfad für Board-first-Agent-Orchestrierung und MCP-artige/strukturierte
> OpenClaw-Integration im Nexus-Backend
> Kein Frontend-direkter MCP-Pfad. Kein Deploy.
## 1. Executive Summary
### 1.1 Prüfergebnis
Der eingeschlagene Pfad ist **architektonisch korrekt und sicher**. Das Board-first-Modell mit
Nexus-Backend als zentraler Brücke zwischen Benutzer, Board und OpenClaw-Gateway ist der richtige
Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren.
### 1.2 Kernbewertung
| Aspekt | Status | Bewertung |
|--------|--------|-----------|
| Board-first Architektur | ✅ Umsetzung läuft | Parent/Child-Modell korrekt implementiert |
| Kein Frontend-direkter Gateway-Zugriff | ✅ Eingehalten | Frontend spricht NUR mit Nexus-Backend |
| Backend als sichere Brücke | ✅ Eingehalten | API-Container proxyt alle Gateway-Calls |
| Auth-/Rechte-Modell | ✅ Solide | JWT + ApiKey + X-Agent-Id Enforcement |
| Gateway-Security | ⚠️ Verbesserbar | `loopback`-Bind muss auf `lan` für Docker |
| Migration-Reihenfolge | 📋 Vorgeschlagen | (siehe Abschnitt 8) |
## 2. Ist-Architektur: Wer spricht mit wem?
```
┌──────────────────────────────────────────────────────────────────┐
│ Browser (Bao/Iris) │
│ auth.ts → JWT Access Token (15m) + HttpOnly Refresh Cookie │
│ api.ts → fetch /api/v1/* → Authorization: Bearer <JWT> │
└───────────────────────────┬──────────────────────────────────────┘
│ HTTPS :443 (Traefik/npm)
┌──────────────────────────────────────────────────────────────────┐
│ Nexus web (nginx container) │
│ location /api/ → proxy_pass http://api:8080 │
│ location / → SPA (index.html) │
│ CSP: connect-src 'self' — kein externer Gateway-Call möglich │
└───────────────────────────┬──────────────────────────────────────┘
│ HTTP :8080 (internal network)
┌──────────────────────────────────────────────────────────────────┐
│ Nexus API (.NET 10 Container) ← SICHERE BRÜCKE │
│ │
│ Auth-Middleware (JWT → Controller) │
│ ApiKey-Middleware (X-Nexus-Api-Key → Role=Service) │
│ SecurityHeaders-Middleware (HSTS, CSP, XFO) │
│ Rate Limiter (auth: 5/min/caller, agents: 30/min/caller) │
│ │
│ IOpenClawGatewayClient │
│ ├─ InvokeToolAsync("session_status", ...) │
│ ├─ InvokeToolAsync("sessions_list", ...) │
│ ├─ InvokeToolAsync("sessions_history", ...) │
│ ├─ InvokeToolAsync("memory_search", ...) │
│ ├─ InvokeToolAsync("sessions_send", ...) │
│ └─ InvokeToolAsync("cron", ...) │
│ │
│ GatewayBridgeController (/api/bridge/*) <- NEU (2026-06-22) │
│ └─ ITaskBridgeService │
│ create_task / create_child_task / update_status / │
│ append_activity / handoff / get_board / get_agent_overview │
│ │
│ IAgentRuntime (OpenClawRuntime) │
│ └─ POST /v1/chat/completions (OpenAI-compat) │
│ │
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
└──────────────┬──────────────────────────────┬────────────────────┘
│ openclaw-gateway-bao:18789 │
│ (internes Docker-DNS) │
▼ │
┌──────────────────────────────┐ │
│ OpenClaw Gateway Container │ │
│ Port 18789 │ │
│ Auth: password │ │
│ Bind: loopback (127.0.0.1) │ │
│ │ │
│ HTTP Deny-List (default): │ │
│ exec, spawn, shell, │ │
│ fs_write, fs_delete, │ │
│ fs_move, apply_patch, │ │
│ sessions_spawn, sessions_send│ │
│ cron, gateway, nodes, │ │
│ whatsapp_login │ │
└───────────────────────────────┴──────────────┘
```
### 2.1 Wichtig: Das Frontend sieht den Gateway NICHT
```
Browser ─── [Nexus API] ─── Gateway
+─ Gateway-Passwort lebt NUR im Backend
+─ CSP: connect-src 'self' blockiert jeden Direktzugriff
+─ Gateway HTTP Deny-List blockiert alle RCE-Tools
```
**Das ist die korrekte Architektur.** Kein Frontend-komponenten-direkter MCP-Pfad existiert
und keiner sollte eingeführt werden.
## 3. Board-First Orchestrierung: Ist-Stand & Bewertung
### 3.1 Das Parent/Child-Task-Modell (Phase 3)
```
Parent-Task (Owner: Iris)
├── Child-Task A (AssignedTo: programmer)
├── Child-Task B (AssignedTo: reviewer)
└── Child-Task C (AssignedTo: architekt)
```
**Status:** ✅ Implementiert (2026-06-21)
**Datenmodell (WorkTask):**
- `ParentTaskId` (Guid?) — verknüpft Child mit Parent
- `IsAgentTask` (bool) — markiert programmatisch erstellte Agent-Tasks
- `ExpectedFrom` (string?) — wer als nächstes antworten soll
- `AssignedTo` (string?) — operativer Owner der Task
**State Machine:**
```
Parent: Backlog → InProgress → Review → Done
↘ Blocked → Backlog
Child: Backlog → InProgress → Done
↘ Blocked → Backlog
```
**Rules (aus TaskStateHelper.CanChangeState):**
- **Nur Iris & Bao** dürfen State-Änderungen vornehmen
- Sub-Agenten (programmer, reviewer, architekt, researcher, executor) **niemals**
- `nexus-system` als technischer Fallback für Cron/Reset-Stale
### 3.2 Bewertung: Architektonisch korrekt
| Kriterium | Bewertung | Begründung |
|-----------|-----------|------------|
| Board als Single Source of Truth | ✅ | Task Board = sichtbare Aufgabenwahrheit |
| Delegation sichtbar | ✅ | Child-Tasks statt unsichtbarem Delegations-Status |
| Feingliedrige Berechtigung | ✅ | State-Change nur durch Iris/Bao |
| Agenten arbeiten gegen Child-Tasks | ✅ | Klare Ownership durch `AssignedTo` |
| Parent bleibt bei Iris | ✅ | Parent in `InProgress` während Koordination |
| Review-Gate für Bao | ✅ | Parent erst in `Review`, dann Bao-Entscheidung |
### 3.3 Offene Punkte im Board-Modell
1. **Keine automatische Child-Task-Erstellung** — Das Board-Modell setzt voraus, dass Iris manuell
Child-Tasks anlegt. Ein strukturierter Workflow für automatische Child-Task-Erstellung bei
`spawn`/Subagent-Aufrufen fehlt.
2. **Keine Task→Session-Verknüpfung** — Es gibt keine direkte Verknüpfung zwischen einer
Child-Task und der OpenClaw-Subagent-Session, die sie bearbeitet. Der `AgentService` kennt
Sessions, `TaskService` kennt Tasks — aber sie sind nicht verknüpft.
3. **Reset-Stale ist ungeschützt**`POST /api/v1/tasks/reset-stale` hat `[AllowAnonymous]`
und kann von jedem aufgerufen werden (siehe Risiko #1).
## 4. Auth- und Rechte-Modell
### 4.1 Authentifizierungsebenen
```
Ebene 1: Browser-JWT (Access Token 15m, Refresh Token HttpOnly Cookie)
Ebene 2: X-Nexus-Api-Key (Service-zu-Service, Role=Service)
Ebene 3: Gateway-Password (Backend → Gateway, Bearer Authorization)
Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
```
### 4.2 Berechtigungsmatrix
| Aktion | Bao | Iris | Sub-Agent | nexus-system | Service (ApiKey) |
|--------|-----|------|-----------|--------------|-------------------|
| Task State ändern | ✅ | ✅ | ❌ | ✅ (intern) | ❌ |
| Task Inhalt editieren | ✅ | ✅ | ✅ | ✅ | ✅ |
| Agent-Config lesen | ✅ | ✅ | ❌ | ❌ | ✅ |
| Gateway-Tool aufrufen | ❌ | ❌ | ❌ | ❌ | ✅ (intern) |
| Dashboard-Metriken sehen | ✅ | ✅ | ❌ | ❌ | ✅ |
### 4.3 Bewertung
**Positiv:**
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
- Refresh-Token-Reuse-Detection verhindert Token-Theft
- Rate-Limiting auf Login und Refresh
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
**Kritisch:**
- `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` — siehe Risiko-Analyse
- Kein Scoped-ApiKey — der `X-Nexus-Api-Key` gibt volle Service-Rechte
- Keine Audit-Protokollierung für ApiKey-Nutzung
## 5. Gateway-Integration: Sicherheitsanalyse
### 5.1 Tool-Invoke-Pfad (Ist-Stand)
```
POST /api/v1/operations/snapshot
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
→ POST http://openclaw-gateway-bao:18789/tools/invoke
Authorization: Bearer <Gateway-Password>
```
**Aufgerufene Tools (durch Nexus-Backend):**
- `session_status` — Agent-Status abfragen (read-only)
- `sessions_list` — Session-Liste (read-only)
- `sessions_history` — Chat-Verlauf (read-only)
- `memory_search` — Memory-Suche (read-only)
- `sessions_send` — Chat-Nachricht senden (write, aber kontrolliert)
- `cron` — Cron-Jobs verwalten (write)
**Gateway HTTP Deny-List blockiert:**
- Alle Exec-Tools (exec, spawn, shell)
- Alle Filesystem-Tools (fs_write, fs_delete, fs_move, apply_patch)
- Gateway-Control-Plane (gateway)
- Node-Relay (nodes)
- Session-Orchestrierung (sessions_spawn, sessions_send)
### 5.2 Docker-Netzwerk & Gateway-Bind
**Aktueller Stand (2026-07-09):**
```
compose.yaml:
api:
extra_hosts:
- host.docker.internal:host-gateway
networks:
- nexus
- openclaw_default
Gateway-Konfiguration:
gateway.bind: "lan"
Nexus-Konfiguration:
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
```
**Ergebnis:**
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
Produktive Einstellung:
```yaml
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
```
Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
### 5.3 MCP-artige Integration: Bewertung
**Das Gateway `/tools/invoke` ist bereits MCP-artig:**
- JSON-RPC-ähnliche Aufrufe mit `tool` + `args` + `sessionKey`
- Strukturierte Responses mit `{ ok, result, error }`
- Tool-Discovery via Policy (Deny/Allow-List)
- Request/Response mit eindeutiger Fehlersemantik
**Was fehlt für ein vollständiges MCP-Interface:**
- Keine Tool-Listing/Discovery über API (kein `tools/list`)
- Keine Schema-Validierung für Tool-Arguments
- Keine Structured Outputs (function-calling-ähnliches Format)
**Empfehlung: NICHT ein MCP-Protokoll zwischen Nexus und Gateway einführen.**
Stattdessen den bestehenden `/tools/invoke`-Pfad weiter nutzen und strukturieren.
### 5.4 Neue Backend-Bridge (Implementiert 2026-06-22)
Der Nexus-eigene strukturierte Kommando-Adapter wurde eingeführt:
```
Nexus Backend
├─ GatewayToolClient (bestehender OpenClawGatewayClient)
│ └─ POST /tools/invoke (Gateway)
├─ GatewayBridgeController (NEU — /api/bridge/)
│ ├─ POST /api/bridge/tasks (create_task)
│ ├─ POST /api/bridge/tasks/{id}/children (create_child_task)
│ ├─ PATCH /api/bridge/tasks/{id}/status (update_status)
│ ├─ POST /api/bridge/tasks/{id}/activity (append_activity)
│ ├─ POST /api/bridge/tasks/{id}/handoff (handoff)
│ ├─ GET /api/bridge/board (get_board)
│ ├─ GET /api/bridge/tasks/{id} (get_task)
│ ├─ GET /api/bridge/tasks/{id}/children (get_children)
│ ├─ GET /api/bridge/tasks/{id}/activity (get_activity)
│ └─ GET /api/bridge/agent-overview (get_agent_overview)
├─ ITaskBridgeService / TaskBridgeService (NEU)
│ └─ Typisierte TaskBridgeResult<T> mit Outcome: Success/NotFound/InvalidState/Unauthorized/ValidationError
├─ BoardOrchestrationService (offen)
│ ├─ Tasks erstellen/aktualisieren
│ ├─ Session-Status abfragen
│ └─ Agent-Progress berechnen
└─ AgentDelegationService (offen)
├─ Subagent-Task anlegen
├─ Session verfolgen
└─ Ergebnis integrieren
```
**Auth-Modell für /api/bridge:**
- Primär: `X-Agent-Id` Header (Agent-Identität vom Gateway)
- Fallback: JWT (Browser-authenticated user → bao)
- Fallback: `X-Nexus-Api-Key` (Backend-zu-Backend Service-Identität)
- Rate-Limiting: 30 Requests/Minute (agents-Policy)
**Das Frontend sieht /api/bridge NICHT.** Der Pfad ist ausschließlich für Agent-zu-Backend-Kommunikation.
## 6. Risikoanalyse
### 6.1 KRITISCH — `[AllowAnonymous]` auf Board-Endpunkten ✅ BEHOBEN (2026-06-22)
**Betroffen (vorher):**
```csharp
// TasksController.cs
[AllowAnonymous]
[HttpGet("board")] // Gab ALLE Tasks zurück — inkl. Detail-Texte
[AllowAnonymous]
[HttpPost("reset-stale")] // Konnte Tasks zurücksetzen — datenändernd
```
**Fix angewandt:**
- `[AllowAnonymous]` entfernt
- Inline-Auth-Check: `X-Agent-Id` Header, ApiKey-Rolle, oder JWT erforderlich
- `reset-stale` erfordert zusätzlich `X-Agent-Id: iris` Identität (nur Iris darf)
- Neuer `/api/bridge/` Pfad als sauberer Agent-zu-Backend-Adapter
- Rate-Limiting (agents-Policy: 30/min) auf Bridge-Endpunkte
### 6.2 MITTEL — Keine Task→Session-Verknüpfung
Wenn ein Subagent eine Child-Task bearbeitet, gibt es keine technische Verknüpfung zwischen
der Child-Task und der OpenClaw-Session. Das bedeutet:
- Keine automatische Status-Aktualisierung bei Session-Abschluss
- Keine Sitzungs-Historie direkt von der Task aus erreichbar
- Iris muss manuell prüfen, ob ein Agent fertig ist
**Empfehlung:**
- `WorkTask` um `SessionKey` (string?) erweitern
- Bei Child-Task-Erstellung Session-Key speichern
- Status-Polling: Wenn Session inaktiv, Child-Task auf Done/Blocked prüfen
### 6.3 MITTEL — Gateway-Passwort in Config-Dateien
Das Gateway-Passwort `ieDmOjBiVfbbDM0ibrEebPAg` ist:
- In `.env` auf dem Host (OK)
- In `gateway-api-research.md` (maskiert: `ieDm...PAg`)
- Im `openclaw.json` auf dem Host (OK)
- In der API-Container-Umgebungsvariable (notwendig)
**Empfehlung:**
- Gateway-Rate-Limiting aktiv halten (10 attempts/60s → 5min lockout)
- Gateway-Bind auf `lan` ändern (nicht öffentlich exponiert)
- `gateway-api-research.md` aus dem öffentlichen Repo entfernen oder Passwort entfernen
### 6.4 NIEDRIG — Keine Scoped API-Keys
Der `X-Nexus-Api-Key` gibt volle Service-Rechte. Es gibt keine Möglichkeit, verschiedene
API-Keys mit unterschiedlichen Rechten zu vergeben.
**Empfehlung (spätere Phase):**
- API-Key-Scopes einführen (read, write, admin)
- Rate-Limiting pro API-Key
- Audit-Log für ApiKey-Nutzung
### 6.5 NIEDRIG — Kein Structured Output vom Gateway
Die `/tools/invoke`-Responses sind JSON, aber ohne Schema-Garantie. Die Backend-Logik
extrahiert Felder mit vielen Fallbacks (`??`-Kettenantworten).
**Empfehlung:**
- Response-Typen für jedes Tool definieren (DTOs)
- Deserialisierung mit Schema-Validierung
- Fallback-Logik zentralisieren
## 7. Migrationsreihenfolge (Vorschlag)
### Phase 3b — Sichere Board-Grundlage (JETZT)
```
1. [AllowAnonymous] auf /tasks/board und /tasks/reset-stale fixen
→ ApiKey-Auth plus X-Agent-Id-Validierung
→ README/Iris-Doku aktualisieren
2. Gateway-Bind von loopback auf lan ändern
→ API-Container über openclaw_default Netzwerk ansprechen
→ host.docker.internal-Fallback entfernen
```
### Phase 4 — Strukturierte Orchestrierung
```
3. Task→Session-Verknüpfung einführen
→ WorkTask.SessionKey (string?)
→ Bei Child-Task-Erstellung Session speichern
4. AgentDelegationService
→ Zentralisierte Subagent-Task-Erstellung
→ Session-Status-Monitoring
→ Automatische Status-Propagation (Session done → Task done)
5. BoardOrchestrationService
→ Refresh-Intervall für Agent-Progress
→ Stale-Erkennung mit Session-Status
→ Priorisierung nach Workload
```
### Phase 5 — Erweiterte Integration
```
6. Structured Tool Responses
→ DTOs für jedes Gateway-Tool
→ Schema-Validierung
→ Caching für häufige Abfragen
7. API-Key-Scopes
→ read/write/admin Scopes
→ Audit-Log für ApiKey-Nutzung
8. Automatische Child-Task-Erstellung
→ Bei spawn/subagent-Aufrufen automatisch Child-Task anlegen
→ Session-Key verknüpfen
→ Activity-Feed erweitern
```
## 8. Sichere Brücke: Architekturprinzipien
### 8.1 Das Backend ist die einzige Brücke
```
Browser ←→ Nexus API ←→ OpenClaw Gateway
↑ ↑
JWT Auth Gateway Password
(pro User) (nur im Backend)
```
**Niemals:**
- Gateway-Passwort im Frontend
- Direkter Browser→Gateway API-Call
- MCP-Protokoll zwischen Frontend und Gateway
- Agent-Sessions direkt aus dem Frontend steuern
### 8.2 Prinzipien für jede neue Integration
1. **Neue Endpunkte immer im Nexus-Backend**
2. **Auth über bestehendes JWT/ApiKey-System**
3. **Gateway-Calls immer serverseitig mit Gateway-Passwort**
4. **Kein Gateway-Tool direkt aus dem Frontend aufrufen**
5. **State-Änderungen nur durch Iris/Bao (via Backend-Enforcement)**
6. **Activity/Audit für jede State-Änderung**
### 8.3 Strukturierte OpenClaw-Integration (MCP-artig)
Der Gateway `/tools/invoke`-Endpunkt ist bereits strukturell MCP-artig. Eine formale
MCP-Implementierung zwischen Nexus und Gateway ist **nicht notwendig**. Stattdessen:
```
Nexus.Backend.Services
├── IOpenClawGatewayClient (Gateway-Tool-Abstraktion)
│ └── InvokeToolAsync(tool, args) → JsonNode?
├── IBoardOrchestrationService (NEU)
│ ├── CreateDelegateTask(agentId, title, detail) → WorkTask
│ ├── WatchAgentSession(agentId) → SessionWatcher
│ └── SyncAgentProgress() → Progress[]
└── IAgentDelegationService (NEU)
├── DelegateToAgent(parentTaskId, agentId, instruction)
├── CollectResult(subTaskId) → AgentResult
└── HandleBlocker(subTaskId, reason) → void
```
## 9. Zusammenfassung
### Was gut ist (nicht ändern):
- Board-first-Ansatz mit Parent/Child-Tasks
- Backend als einzige Gateway-Brücke
- JWT + Gateway-Password-Trennung
- State-Change-Restriktion auf Iris/Bao
- HTTP Deny-List des Gateways
- CSP im Frontend (kein externer Connect)
### Was verbessert werden muss:
- `[AllowAnonymous]` auf Board-Endpunkten → ApiKey-Auth
- Gateway-Bind loopback → lan (oder Netzwerk-Routing korrigieren)
- Task→Session-Verknüpfung fehlt
### Was später kommen kann:
- Automatische Child-Task-Erstellung bei Subagent-Aufrufen
- Structured Gateway Responses mit Schema-Validierung
- API-Key-Scopes und Audit
- Session-gesteuertes Progress-Tracking
### Was niemals kommen darf:
- Gateway-Passwort im Frontend
- Direkter MCP-Pfad Browser→Gateway
- Agent-Session-Steuerung aus dem Frontend
- Task-State-Änderung durch Sub-Agenten
+19 -14
View File
@@ -1,7 +1,12 @@
# Gateway API Research
> Generated: 2026-06-10
> Generated: 2026-06-10 | Updated: 2026-06-22
> Auth mode: password (not token)
>
> ⚠️ **Security note:** Diese Datei enthält Infrastruktur-Details zur Gateway-Integration.
> Sie gehört nicht ins öffentliche Repository. Bis zur Bereinigung: Gateway-Passwort
> maskiert als `ieDm...PAg`. Vollständige Architektur-Analyse in
> [`architecture-board-first-orchestration.md`](architecture-board-first-orchestration.md).
## 1. Authentication
@@ -285,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
```yaml
api:
extra_hosts:
- host.docker.internal:host-gateway
environment:
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
networks:
- nexus
- openclaw_default
```
The API container:
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
- Has `extra_hosts` configured for `host.docker.internal`
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
- Does not depend on a published host port for the Gateway
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
### Known Issue: Gateway Bind = loopback
### Resolved Routing Issue (2026-07-09)
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
| Scenario | Works? | Why |
|----------|--------|-----|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
The Gateway must listen on its container interface (`gateway.bind: "lan"`):
```json5
{
@@ -320,8 +325,8 @@ The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it onl
**Test command (from Nexus API container):**
```bash
curl -s http://host.docker.internal:18789/health
# Expected: 200 if gateway bind is lan/container IP is reachable
curl -s http://openclaw-gateway-bao:18789/
# Expected: HTTP 200 from inside nexus-api-1
```
### Required .env Vars for Nexus
+338
View File
@@ -0,0 +1,338 @@
# OpenClaw ↔ Nexus Task Board Flow
> Letzte Aktualisierung: 2026-06-21
> Status: kanonische Arbeitsbeschreibung für Iris, Sub-Agenten und das Nexus Task Board
Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
- **Bao** als Auftraggeber
- **Iris** als Chief of Staff / Koordinatorin
- **Sub-Agenten** als ausführende Spezialisten
- **OpenClaw** als Agent-Runtime
- **Nexus Task Board** als sichtbare Aufgabenquelle
- **MCP `/mcp`** als Agent Data Plane fuer Board-Operationen
---
## 1. Kurzfassung
**Eine Hauptaufgabe gehört Iris.**
Wenn Iris Arbeit delegiert, wird diese Delegation **nicht unsichtbar im Chat** geführt, sondern als **sichtbare Child-Task** im Nexus Task Board angelegt.
Das bedeutet:
- **Parent-Task** = Verantwortung von Iris
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
- **OpenClaw** = Ausführungspfad für Agentenarbeit
- **MCP** = bevorzugter Agentenpfad zu Nexus; `/api/bridge` bleibt
kompatible interne Fassade, `/api/dashboard` bleibt UI/Admin
---
## 2. Die Hauptidee
Früher war Delegation leicht unsichtbar oder lief über einen separaten `Delegated`-Status.
Der neue Flow ersetzt das durch:
1. **Iris übernimmt eine Parent-Task**
2. **Iris zerlegt die Arbeit bei Bedarf in Subtasks**
3. **Jede echte Delegation wird als Child-Task auf dem Board angelegt**
4. **Der zuständige Agent arbeitet gegen diese Child-Task**
5. **Iris integriert die Ergebnisse zurück in die Parent-Task**
6. **Erst wenn alles fertig ist, geht die Parent-Task in Review**
---
## 3. Systembild
```mermaid
flowchart LR
Bao[Bao\nAuftraggeber]
Iris[Iris\nChief of Staff]
Board[Nexus Task Board\nParent + Child Tasks]
OC[OpenClaw Runtime]
Agents[Sub-Agenten\nDeveloper / Reviewer / Architekt / ...]
Bao -->|Auftrag / Priorisierung| Iris
Iris -->|legt Parent-Task an / übernimmt Task| Board
Iris -->|delegiert konkrete Arbeit| OC
OC -->|führt Agenten-Task aus| Agents
Iris -->|legt Child-Tasks an| Board
Agents -->|MCP Tools /mcp| Board
Agents -->|liefern Ergebnis / melden Blocker| Iris
Iris -->|integriert Ergebnis| Board
Board -->|Review für Bao| Bao
```
---
## 4. Rollen und Verantwortlichkeiten
### Bao
- gibt Aufgaben inhaltlich vor
- priorisiert und nimmt fertige Arbeit ab
- verschiebt fertige Hauptaufgaben aus **Review** nach **Done** oder zurück
### Iris
- übernimmt die Parent-Task
- analysiert, zerlegt, delegiert und reviewed
- hält die Hauptaufgabe auf dem Board aktuell
- erstellt sichtbare Child-Tasks für delegierte Arbeit
- entscheidet, ob etwas **In Progress**, **Blocked** oder **Review** ist
### Sub-Agenten
- arbeiten **nicht** direkt gegen eine diffuse Hauptaufgabe
- arbeiten gegen eine **konkret zugewiesene Child-Task**
- melden Fortschritt, Ergebnisse und Blocker an Iris
### OpenClaw
- führt die Agentenarbeit technisch aus
- liefert Nachrichten, Status und Arbeitsergebnisse zurück
- ersetzt nicht das Board als Aufgabenwahrheit
### MCP Agent Data Plane
- stellt `nexus_get_board`, `nexus_agent_overview`, Task-, Child-,
Activity-, Status-, Checkpoint- und Handoff-Tools bereit
- nutzt nur kanonische States: `Backlog`, `In progress`, `Blocked`,
`Done`, `Review`
- ist Fassade ueber `ITaskBridgeService`, keine zweite Board-Domaenenlogik
### Nexus Task Board
- ist die **sichtbare operative Quelle** für Aufgaben
- zeigt Parent-Task, Child-Tasks, Ownership und Status
- dokumentiert den tatsächlichen Arbeitsfluss
---
## 5. Parent-Task vs. Child-Task
| Ebene | Zweck | Owner | Sichtbarkeit |
|---|---|---|---|
| Parent-Task | Hauptauftrag / Koordination | Iris | Board |
| Child-Task | Delegierter Arbeitsblock | zuständiger Agent | Board |
### Parent-Task-Regeln
- bleibt bei Iris
- bleibt in der Regel **In Progress**, solange Koordination läuft
- geht erst auf **Review**, wenn alle nötigen Child-Tasks erledigt und integriert sind
- geht nur auf **Blocked**, wenn Iris insgesamt nicht weiterkommt
### Child-Task-Regeln
- repräsentiert eine echte delegierte Teilaufgabe
- hat klare Ownership (`AssignedTo`)
- zeigt sichtbar, welcher Agent woran arbeitet
- wird nicht für triviale Mini-Schritte missbraucht
---
## 6. Zustandsmodell
### Parent-Task-Lifecycle
```mermaid
stateDiagram-v2
[*] --> Backlog
Backlog --> InProgress: Iris übernimmt
InProgress --> InProgress: Child-Tasks anlegen / koordinieren
InProgress --> Blocked: Gesamtblocker
InProgress --> Review: alles integriert
Review --> Done: Bao nimmt ab
Review --> Backlog: Bao gibt zurück
Blocked --> Backlog: Blocker gelöst
```
### Child-Task-Lifecycle
```mermaid
stateDiagram-v2
[*] --> Backlog
Backlog --> InProgress: Agent startet
InProgress --> Done: Ergebnis geliefert
InProgress --> Blocked: Agent kommt nicht weiter
Blocked --> Backlog: neu geplant / entsperrt
Blocked --> InProgress: Iris stößt Weiterarbeit an
```
---
## 7. Der konkrete Arbeitsablauf
### Fall A: Bao gibt Iris einen neuen Auftrag
1. Bao formuliert einen Auftrag
2. Iris prüft Ziel, Scope, Risiko und Umgebung
3. Iris übernimmt oder erstellt die **Parent-Task**
4. Parent-Task geht auf **In Progress**
5. Wenn nötig zerlegt Iris die Arbeit in **Child-Tasks**
6. Child-Tasks werden passenden Agenten zugewiesen
7. Agenten arbeiten die Child-Tasks ab
8. Iris sammelt Ergebnisse ein und integriert sie
9. Parent-Task geht auf **Review**
10. Bao entscheidet: **Done** oder zurück nach **Backlog**
### Fall B: Agent meldet einen Blocker
1. Agent meldet Blocker an Iris
2. Iris prüft, ob der Blocker lokal lösbar ist
3. Wenn nein: die betroffene **Child-Task** geht auf **Blocked**
4. Falls nötig entsteht eine neue Ursachen-Task / neue Child-Task
5. Parent-Task bleibt **In Progress**, solange der Gesamtauftrag noch koordiniert wird
6. Nur wenn die Hauptaufgabe insgesamt feststeckt, geht die **Parent-Task** auf **Blocked**
---
## 8. OpenClaw- und Board-Interaktion
```mermaid
sequenceDiagram
participant Bao
participant Iris
participant Board as Nexus Task Board
participant OpenClaw
participant Agent as Sub-Agent
Bao->>Iris: Auftrag
Iris->>Board: Parent-Task übernehmen / anlegen
Iris->>Board: Child-Task anlegen
Iris->>OpenClaw: Agentenauftrag starten
OpenClaw->>Agent: Task ausführen
Agent-->>Iris: Ergebnis / Rückfrage / Blocker
Iris->>Board: Child-Task aktualisieren
Iris->>Board: Parent-Task integrieren
Iris->>Board: Parent auf Review setzen
Board-->>Bao: Review sichtbar
```
---
## 9. Regeln für gutes Schneiden von Child-Tasks
Eine Child-Task ist sinnvoll, wenn sie:
- einen **klaren Arbeitsblock** darstellt
- einen **eigenen Verantwortlichen** hat
- ein **eigenes Ergebnis** liefern soll
- unabhängig als **Done** oder **Blocked** sichtbar sein kann
Keine gute Child-Task ist:
- „Datei öffnen"
- „kurz nachschauen"
- „eine Kleinigkeit prüfen"
Faustregel:
> **Eine Child-Task soll ein echter delegierbarer Arbeitsauftrag sein, kein Mikro-Schritt.**
---
## 10. Board-Sicht: was sichtbar sein soll
Im Board soll erkennbar sein:
- welche Parent-Task Iris gerade steuert
- welche Child-Tasks darunter existieren
- welcher Agent welche Child-Task besitzt
- welche Child-Task blockiert ist
- welche Parent-Task in Review auf Bao wartet
Im Task-Detail sollen sichtbar sein:
- Parent/Child-Beziehung
- `AssignedTo`
- Status
- erwarteter nächster Beitrag / letzter Aktivitätshinweis
- Child-Task-Liste direkt unter der Parent-Task
---
## 11. Kanonische Regeln
### Regel 1 — Das Board ist die sichtbare Aufgabenwahrheit
Chat und Agentenläufe ergänzen das Board, ersetzen es aber nicht.
### Regel 2 — Iris bleibt Ownerin der Hauptaufgabe
Delegation verschiebt Verantwortung nicht automatisch auf den Agenten.
### Regel 3 — Delegation ist sichtbar
Jede echte delegierte Arbeit wird als Child-Task abgebildet.
### Regel 4 — Kein künstlicher Wartezustand auf Parent-Ebene
Die Parent-Task bleibt **In Progress**, solange Iris aktiv koordiniert.
### Regel 5 — Blocker präzise markieren
Wenn nur ein Arbeitspaket hängt, blockiert zuerst die **Child-Task**, nicht automatisch die ganze Parent-Task.
### Regel 6 — Review ist Bao-Gate
Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Done** oder zurück.
---
## 12. Beispiel
### Parent-Task
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
### Mögliche Child-Tasks
- **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner`
- **Schnelle Voranalyse / kleiner Patch** — Owner: `programmer-fast`
- **Backend-State-Handling anpassen** — Owner: `programmer`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `programmer`
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
So sieht Bao später nicht nur „Iris arbeitet daran“, sondern konkret:
- welcher Teil erledigt ist
- welcher Teil noch läuft
- welcher Teil blockiert ist
- worauf Iris gerade wartet
---
## 13. Anti-Patterns
Diese Muster sollen vermieden werden:
- Parent-Task auf einen bloßen **Delegated**-Wartestatus schieben
- Delegation nur im Chat sichtbar machen
- Child-Tasks ohne klare Ownership anlegen
- Blocker nur mündlich erwähnen, aber nicht im Board markieren
- zehn Mikro-Subtasks für einen Mini-Arbeitsschritt erzeugen
---
## 14. Entscheidungsregel für Iris
Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
**Child-Task anlegen**, wenn mindestens einer der Punkte zutrifft:
- anderer Agent übernimmt echte Arbeit
- eigener Status muss sichtbar verfolgt werden
- eigener Blocker ist möglich
- Bao soll Transparenz über diesen Teil sehen
---
## 15. Technische Leitplanken
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
- `AssignedTo` zeigt den operativen Owner
- Child-Tasks dürfen geplant in `Backlog` erstellt werden; nur aktiv gestartete Delegationen beginnen direkt in `In progress`
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
- UI und Doku müssen dieselbe Sprache sprechen
- Mission-Control-Gateway-Daten bleiben read-only im Browser: Nexus proxyt Status,
Version und redigierte Activity; Gateway-Token und direkte Gateway-URLs bleiben
im Backend.
- Config-Writes sind Bao/Owner-only, legen vor dem Austausch ein `.bak` an und
schreiben einen `config_audit` Activity-Eintrag.
---
## 16. Merksatz
> **Iris koordiniert die Hauptaufgabe. Agenten erledigen sichtbare Child-Tasks. Das Board zeigt die Wahrheit. OpenClaw führt aus.**
@@ -0,0 +1 @@
{"locator":{"name":"pnpm","reference":"10.12.1"},"bin":{"pnpm":"./bin/pnpm.cjs","pnpx":"./bin/pnpx.cjs"},"hash":"sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"}
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
Copyright (c) 2016-2025 Zoltan Kochan and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,212 @@
[简体中文](https://pnpm.io/zh/) |
[日本語](https://pnpm.io/ja/) |
[한국어](https://pnpm.io/ko/) |
[Italiano](https://pnpm.io/it/) |
[Português Brasileiro](https://pnpm.io/pt/)
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://i.imgur.com/qlW1eEG.png">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/qlW1eEG.png">
<img src="https://i.imgur.com/qlW1eEG.png" alt="pnpm">
</picture>
Fast, disk space efficient package manager:
* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
* **[Great for monorepos](https://pnpm.io/workspaces).**
* **Strict.** A package can access only dependencies that are specified in its `package.json`.
* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
* **Works as a Node.js version manager.** See [pnpm env use](https://pnpm.io/cli/env).
* **Works everywhere.** Supports Windows, Linux, and macOS.
* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/users) since 2016.
* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
To quote the [Rush](https://rushjs.io/) team:
> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and 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"
}
}
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.pnpm-store/
.pnpm-home/
.corepack-home/
.git/
.gitignore
.env
*.log

Some files were not shown because too many files have changed in this diff Show More