Compare commits

...

114 Commits

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

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

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

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

Auth: Admin creates accounts, users log in with existing /api/v1/auth/login.
Login/Settings deliver visible Galaxy-consistent design with nexus-tokens.css tokens.
2026-06-20 14:24:40 +02:00
devops dcc8450c62 feat: Phase 2 — Delegated State, Auth, Review-Gate, Notifications, Zombie-Reset
CI - Build & Test / Backend (.NET) (push) Successful in 37s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 24s
CI - Build & Test / Security Check (push) Successful in 4s
2026-06-18 23:47:41 +02:00
devops 12998170e3 fix: update DEPLOY_PATH in all workflows from /opt/openclaw to /home/projekte_bao/openclaw
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:44:33 +02:00
devops 691152f889 fix: volume paths from /opt/openclaw to /home/projekte_bao/openclaw
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:41:32 +02:00
devops 74ef58d274 fix: add Traefik labels and proxy network for nexus.noveria.net routing
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-18 21:40:17 +02:00
devops 5e7d074593 feat: Linear-style Task Board mit Drag&Drop
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-18 21:34:07 +02:00
iris c496608c86 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris c040696d91 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris 7ba0bd26fa docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
iris 4b1d140b53 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
developer e0c88238da refactor: extract DI, helpers from Program.cs into extension classes
CI - Build & Test / Backend (.NET) (push) Successful in 1m18s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 48s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-16 16:52:17 +02:00
AzuTear b0e65e3980 style: strengthen flow lines and tighten modal demo parity
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:57:12 +02:00
devops 648a5d2151 refactor: move landingpage to separate repo bao/noveria-landing
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:53:00 +02:00
devops 1a024eef96 feat: noveria.net landingpage template
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:45:23 +02:00
devops 6280e87078 infra: landingpage compose + nginx config
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:51 +02:00
AzuTear 64459ccdb3 feat: wire dashboard v2 to backend data
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:05 +02:00
devops 38dc2efc6c docs: devops deploy-actor documentation
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:41:38 +02:00
AzuTear 390bffa208 fix: detect drag state on pointer release
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 15:33:51 +02:00
AzuTear e034883abd fix: open agent cards only on click
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:23:05 +02:00
AzuTear 6d4e8e7927 refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:11:05 +02:00
reviewer 0f8939306d feat: mobile-responsive dashboard v2
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 12:16:06 +02:00
reviewer 58675f0c69 ops: enhanced deploy verification with web-recovery + incident docs
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 11:31:46 +02:00
reviewer 88cafc7b8e review: remove version-bump from deploy workflow — VERSION is read-only source of truth
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 11:31:04 +02:00
reviewer 485357c6dc review: error-handling for config file write + compose resource limits
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
- AgentsController.SaveConfigFile: catch UnauthorizedAccessException and IOException
  instead of letting them bubble up unhandled; return clean 500 with logged message
- compose.yaml: add deploy.resources.limits.memory and reservations.memory for
  api (512M/128M), web (128M/32M), postgres (256M/64M)
2026-06-14 11:30:25 +02:00
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
devops f271602f31 chore: bump version to 0.2.55 [skip ci] 2026-06-14 07:29:01 +00:00
reviewer 63319e1046 fix: stream deploy env into docker cli
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-14 09:27:56 +02:00
devops b730fa1518 chore: bump version to 0.2.54 [skip ci] 2026-06-14 07:21:34 +00:00
reviewer fadb5d75c4 Fix AgentService tests fixture path
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 3s
2026-06-14 09:20:28 +02:00
reviewer 45a39d319f Fix operations CI and snapshots
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:14:24 +02:00
reviewer 5ea7aa9611 fix(ops): mount temp env directory for compose
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 08:48:23 +02:00
devops a6fabb90b0 chore: bump version to 0.2.53 [skip ci] 2026-06-14 06:46:55 +00:00
reviewer db62354c97 fix(ops): pass temp env via compose --env-file
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 08:44:42 +02:00
devops 20dedcd6fa chore: bump version to 0.2.52 [skip ci] 2026-06-14 06:42:37 +00:00
reviewer 4ad0f9e493 refactor: SOLID architecture — backend service layer + frontend V2 components
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
## Backend — Service Layer & Repository Refactoring

### Neue Services (21 neue Dateien)

**Interfaces & Implementierungen:**
- `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse)
- `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert
- `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP)
- `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController)
- `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation
- `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController
- `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController
- `IMemoryService` / `MemoryService` — File-I/O aus MemoryController
- `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController
- `IDocService` / `DocService` — Directory-Scan aus DocsController
- `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController

### Repository-Fixes

**IUserRepository / UserRepository:**
- `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern)
- `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges
- `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges
- `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save)

### AuthService-Fixes
- `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()`
- `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync`
- `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync`

### Bug-Fix
- `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt)

### Controller (16 Stück — alle slim)
Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben.
Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log).

**Program.cs — neue Registrierungen:**
- `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse)
- Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService
- Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService

---

## Frontend — Dashboard V2 Components

**AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:**
- V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente
- Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern
- NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:34:58 +02:00
devops ac4e1cd3cf chore: bump version to v0.2.51 [skip ci] 2026-06-13 19:04:36 +00:00
reviewer 01c9bda339 fix(ops): change api healthcheck from curl to wget (curl not in base dotnet-aspnet image) [skip ci] 2026-06-13 21:04:29 +02:00
devops 1b11793dad chore: bump version to v0.2.50 [skip ci] 2026-06-13 19:03:48 +00:00
reviewer 98f98b55d5 fix(ops): change web healthcheck from wget to curl (wget IPv6 causes false unhealthy on nginx-alpine) [skip ci] 2026-06-13 21:03:41 +02:00
devops f28c398d16 chore: bump version to v0.2.49 [skip ci] 2026-06-13 19:00:26 +00:00
reviewer 358ec3e65d feat(frontend): Dashboard V2 — FlowCanvas, AgentModal, IrisChat, Stores, PlaceholderViews, Icon-Library
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-13 20:58:53 +02:00
devops 5f3d04f44c chore: bump version to v0.2.48 [skip ci] 2026-06-13 18:04:57 +00:00
reviewer d169cbe9d5 feat(ops): production resilience — healthchecks, restart_policy, log-rotation, --wait deploy [skip ci] 2026-06-13 20:04:42 +02:00
reviewer 6cedd8410f refactor(frontend): deduplicate CSS keyframes, unify types, extract format utils, add UI states, trim mock data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Remove duplicate @keyframes pulse-* from 3 component files (already in nexus-tokens.css)
- Rename AgentDetail → AgentDetailData in dashboard types to avoid collision with types/agent.ts
- Extract shared formatNumber/initials/formatTime to utils/format.ts
- Simplify FlowBoard: use agentStore modal/selection getters instead of duplicating local state
- Add error banner + empty state to IrisChat; add loading skeleton + error/empty states to TaskStrip
- Remove 105-line unused mockAgents array from useFlowLayout
- Reduce operations store fallbacks from hardcoded preview data to minimal safe defaults
- Update operations store tests to match lean fallback structure
- Net: -73 lines, cleaner imports, fewer magic strings
2026-06-12 17:02:50 +02:00
developer 9033ff2973 feat(v2): live sidebar counts, /dashboard = V2 default route, remove V1 dead code
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 01:01:50 +02:00
developer 676dbd7589 feat(v2): Pinia stores (agents/tasks/chat) + live backend integration, remove mock data
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:57:28 +02:00
developer 9330de7af0 feat(v2): AgentDetailModal — metrics grid, thinking feed, model dropdown, keyboard nav
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:52:59 +02:00
developer 6023b5ea24 fix(v2): reviewer bugfixes — scroll, block-status, NaN guard, dead code cleanup
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:51:42 +02:00
developer 166c9f9051 feat(v2): IrisChat + TaskStrip components, mock data integration
CI - Build & Test / Backend (.NET) (push) Failing after 20s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:48:13 +02:00
developer 2d6e3537e8 feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:24:56 +02:00
developer 3672e56994 feat(v2): NexusLayout, Sidebar, NavGroup, NavItem, Topbar, FlowBoard placeholder
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:20:27 +02:00
developer f378d7aed4 feat(v2): design tokens, fonts (Manrope/Space Grotesk/JetBrains Mono), galaxy background
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:16:01 +02:00
developer 1a7bf8ca11 revert: remove Claude models (API not working)
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 18:11:02 +02:00
developer 3907548a1d feat(models): add Claude Sonnet 4.6 + Opus 4.8, mount openclaw.json in API container
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 17:11:59 +02:00
developer b1888bd8ef feat(dashboard): AgentModal live working feed & thinking stream
CI - Build & Test / Backend (.NET) (push) Failing after 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 16:13:28 +02:00
developer c29740a466 feat(dashboard): dynamic agent metrics + model list from config, no more hardcoded data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 16:01:33 +02:00
devops 45c6b24928 chore: bump version to v0.2.47 [skip ci] 2026-06-11 13:59:03 +00:00
developer 5fb62bef8a feat(queue): erweiterte Queue mit Cron-Jobs + Tasks, Prioritäten, Delete/Priority API
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 15:58:12 +02:00
devops 068b0d31b8 chore: bump version to v0.2.46 [skip ci] 2026-06-11 13:55:20 +00:00
developer 97b8588dc3 feat(dashboard): multi-agent operations feed aggregating all agent sessions
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 15:54:32 +02:00
devops 6150ea96af chore: bump version to v0.2.45 [skip ci] 2026-06-11 13:52:39 +00:00
developer 81af81fb6f feat(dashboard): task system with DB persistence, CRUD endpoints, frontend API integration
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 15:51:48 +02:00
devops 2877035c5c chore: bump version to v0.2.44 [skip ci] 2026-06-11 13:49:27 +00:00
developer 6a1366b472 feat(dashboard): dynamic agent data from gateway sessions instead of hardcoded list
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 15:48:33 +02:00
devops adecfea432 chore: bump version to v0.2.43 [skip ci] 2026-06-11 09:33:58 +00:00
developer b7b44494f0 fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
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 2s
2026-06-11 10:06:58 +02:00
devops a538025049 chore: bump version to v0.2.42 [skip ci] 2026-06-09 23:28:15 +00:00
devops 6d7454a7c1 chore: bump version to v0.2.41 [skip ci] 2026-06-09 23:27:51 +00:00
devops 3c72e807da chore: bump version to v0.2.40 [skip ci] 2026-06-09 23:27:24 +00:00
devops 702692cf0c chore: bump version to v0.2.39 [skip ci] 2026-06-09 23:14:57 +00:00
developer 51d1917a7b fix: GetSessionHistory parst content[] blocks korrekt
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Messages aus result.details.messages extrahieren
- Content = Array von {type, text} blocks → nur text extrahieren
- Thinking-Blocks + REPLY_SKIP + ANNOUNCE_SKIP herausfiltern
2026-06-10 01:14:08 +02:00
devops 85f3400076 chore: bump version to v0.2.38 [skip ci] 2026-06-09 23:12:40 +00:00
developer a5cbe98f25 fix: Chat-Messages Merge + Session-Key agent:iris:main
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- fetchChatMessages merged statt replace (verhindert Poll-wipe)
- Chat/Send bereits korrekt via agentId=iris
- Chat/Messages nutzt jetzt agent:iris:main als Session-Key
- Cron-Job deaktiviert (verhinderte Selbst-Konversation)
2026-06-10 01:11:52 +02:00
devops 5b0e3a19f6 chore: bump version to v0.2.37 [skip ci] 2026-06-09 22:58:55 +00:00
developer e1d6b1eeb3 fix: Chat via agentId statt sessionKey + reply aus details parsen
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- SendChatMessageAsync: sessions_send nutzt agentId (nicht sessionKey)
- Reply parsen aus result.details.reply (sessions_send Antwort-Struktur)
- ChatRequest.Model: SessionKey → AgentId
- Controller default: 'iris' → Agent-ID (nicht Session-Key)
2026-06-10 00:58:04 +02:00
devops afcbf941a9 chore: bump version to v0.2.36 [skip ci] 2026-06-09 22:39:42 +00:00
developer 49b9778872 feat: Dashboard Frontend – Echte API-Integration
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- useDashboardData: Mock-Daten durch API-Calls ersetzt
- fetchStatus/Agents/Operations/ChatMessages/Queue via /api/dashboard/*
- sendChatMessage via POST /api/dashboard/chat/send
- Polling: Status 5s, Chat 3s, Agents/Queue 10s
- Agent Catalog mit static Fields + API-Daten
- ChatPanel direkt mit sendChatMessage verdrahtet
- Build: 0 errors, vue-tsc + vite 
2026-06-10 00:38:50 +02:00
devops 6d0dab4889 chore: bump version to v0.2.35 [skip ci] 2026-06-09 22:34:38 +00:00
developer dd509a75be fix: Agents hartcodiert mit File-Activity-Detection
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Tools/invoke Visibility scoped auf agent:main → keine Iris-Sessions sichtbar
- Hardcoded 6 Agenten mit korrekten Models
- Activity: checkt /mnt/workspace-{id}/memory Dateizeitstempel
2026-06-10 00:33:49 +02:00
devops e0fc305832 chore: bump version to v0.2.34 [skip ci] 2026-06-09 22:31:24 +00:00
developer c120155170 fix: GatewayClient robuste Response-Parsing + Isolierte Try/Catch
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- ExtractToolData: unwraps content[0].text JSON + details
- GetStatusAsync: separates try/catch per step (ping, session, agents, queue)
- GetAgentsAsync: parses gateway agents[] array from sessions_list
- GetQueueAsync: extracts from cron response data.jobs
- gatewayOk no longer overridden by downstream tool errors
2026-06-10 00:30:36 +02:00
devops 0241130c2f chore: bump version to v0.2.33 [skip ci] 2026-06-09 22:27:10 +00:00
developer 889af65ae7 fix: GatewayClient Tool-Namen + Response-Unwrapping
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- sub_agents_list → subagents (action: list)
- cron_list → cron (action: list)
- Ping / → /health
- Unwrap {ok, result} envelope in InvokeToolAsync
2026-06-10 00:26:22 +02:00
devops bdd75c9224 chore: bump version to v0.2.32 [skip ci] 2026-06-09 22:21:37 +00:00
developer f707dceb98 feat: Dashboard Backend Proxy – OpenClaw Gateway Integration
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
- DashboardController: /api/dashboard/status, agents, operations, chat/send, chat/messages, queue
- OpenClawGatewayClient: typed HttpClient mit Gateway tools/invoke
- Dashboard DTOs: DashboardAgentInfo, ChatRequest, ChatResponse, FeedEntry, QueueItem
- Gateway auth: Bearer-Password via Integrations:OpenClaw:Password
- Gateway-Down → graceful degradation (HTTP 200, leere Daten)
- Build: 0 errors, Tests: 3/3 passed
2026-06-10 00:20:49 +02:00
devops 96a44233c0 chore: bump version to v0.2.31 [skip ci] 2026-06-09 21:47:39 +00:00
developer 191cb5cbd2 fix: FeedDetailModal v-if fehlte – immer sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- v-if='modelValue' auf Overlay-Div
- Modal nur noch an wenn showDetailModal=true
- Overlay-Klick + X-Button schließen korrekt
2026-06-09 23:46:51 +02:00
devops 12e629432c chore: bump version to v0.2.30 [skip ci] 2026-06-09 21:43:48 +00:00
developer 47f0f1d786 feat: Iris Chat Expand-Button + Modal
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Maximize2-Button im Chat-Header
- Expand-Modal (700px, 70vh) mit vollem Chat
- Teleport-Overlay, X-Close, Overlay-Klick
- Separater chatModalListRef für Modal-Scrolling
2026-06-09 23:42:59 +02:00
devops bf60b8b064 chore: bump version to v0.2.29 [skip ci] 2026-06-09 21:41:01 +00:00
developer b8498f47bb [Reviewer] Dashboard Review: Interface-Bereinigung, SVG-Composable-Extraktion, Komponenten-Renaming, CSS-Fix
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- AgentNodeData: Remove redundant fields task/runtime (dup of currentTask/runtimeSeconds)
- useTeamNetworkSvg: Extract SVG layout, path computation + pulse animation from TeamNetwork
- TeamNetwork: Use AgentNodeData type, fix undefined pulseElements2/storePulseRef2, remove unused props
- Rename MissionCard.vue → TaskCard.vue (matches actual usage)
- Extract FeedDetailModal from OperationsFeed (eliminates :global() CSS conflict with AgentModal)
- DashboardView: Fix type import path (../../ → ../), remove dead TeamNetwork props
- AgentModal: Remove unused thinkingStreamRef template ref

Build: vue-tsc --noEmit 0 errors, vite build ✓
2026-06-09 23:38:23 +02:00
devops f037aa2eeb chore: bump version to v0.2.28 [skip ci] 2026-06-09 21:30:00 +00:00
developer e6520fc26d fix: Queue → Chat Queue umbenannt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-09 23:29:11 +02:00
devops c9d8852609 chore: bump version to v0.2.27 [skip ci] 2026-06-09 21:22:19 +00:00
developer 11e9a257a1 fix: AgentNodeData um tags, task, runtime erweitert – Cards wieder sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
- Interface: tags:string[], task:string, runtime:string, hero?:boolean
- Alle 5 Agenten mit Tags, Task, Runtime, Hero befüllt
2026-06-09 23:21:31 +02:00
devops ead202ad8b chore: bump version to v0.2.26 [skip ci] 2026-06-09 21:16:23 +00:00
developer effc86e15b feat: LLM Model + Glassmorphism + Doppel-Pulse + Task-Board
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
- Agent Cards: aktives LLM Model unter Runtime
- Glassmorphism: rgba-BG + backdrop-filter:blur
- Bézier-Linien: 2 Pulse pro Verbindung (Offset 50%)
- TaskCard: 'Zum Task Board' Button mit Pfeil
2026-06-09 23:15:33 +02:00
devops 0f9809e423 chore: bump version to v0.2.25 [skip ci] 2026-06-09 21:01:16 +00:00
developer c2736d20c1 feat: Cards, Offene Aufgaben, Feed – Komplettumbau
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
TeamNetwork: Footer→Arrow, Current Task+Runtime inline
Missions→Offene Aufgaben (TaskCard) mit +New Task, Iris/Bao-Quelle
OperationsFeed: Text-Wrap, 5 Items, Mehr-Button→Tag-Navigation-Modal
2026-06-09 23:00:26 +02:00
188 changed files with 20336 additions and 5022 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(npx vite *)"
]
}
}
+156
View File
@@ -0,0 +1,156 @@
name: Database Backup
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: Manual (workflow_dispatch) + optional schedule.
#
# Strategy:
# 1. Connects to the live PostgreSQL container via docker exec.
# 2. Runs pg_dumpall (full cluster dump, single file).
# 3. Compresses with gzip.
# 4. Uploads as a Gitea Action artifact (or writes to host path).
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
#
# Rotation: Gitea artifact expiration handles old backups automatically.
# For longer retention, configure an external cron job or use the
# host_path output to copy the backup elsewhere.
#
# Restoration: See phases/deployment.md for step-by-step instructions.
# ───────────────────────────────────────────────────────
concurrency:
group: db-backup
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
keep_on_host:
description: 'Also copy backup to host path?'
required: false
default: false
type: boolean
host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)'
required: false
default: '/home/projekte_bao/openclaw/backups'
type: string
# Optional: uncomment to enable nightly automatic backups
# schedule:
# - cron: '0 3 * * *' # Every night at 03:00 UTC
jobs:
backup:
name: Backup PostgreSQL
runs-on: ubuntu-latest
env:
ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1
steps:
# ═══════════════════════════════════════════════════
# Step 1: Generate backup filename
# ═══════════════════════════════════════════════════
- name: Generate backup identifier
id: meta
run: |
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
echo "📅 Backup ID: ${TIMESTAMP}"
# ═══════════════════════════════════════════════════
# Step 2: Dump PostgreSQL via docker exec
# ═══════════════════════════════════════════════════
- name: Dump database
run: |
set -euo pipefail
echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
| gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
# ═══════════════════════════════════════════════════
# Step 3: Upload backup as Gitea artifact
# ═══════════════════════════════════════════════════
- name: Upload backup artifact
uses: actions/upload-artifact@v4
with:
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
path: ${{ steps.meta.outputs.filename }}
retention-days: 90
compression-level: 0 # already gzipped
# ═══════════════════════════════════════════════════
# Step 4: Optional — copy to host filesystem
# ═══════════════════════════════════════════════════
- name: Copy backup to host (optional)
if: inputs.keep_on_host == true
run: |
set -euo pipefail
HOST_PATH="${{ inputs.host_backup_path }}"
# Create host dir if it doesn't exist
docker run --rm \
-v "${HOST_PATH}:/backup-target" \
-v "${{ gitea.workspace }}:/src:ro" \
alpine:latest \
sh -c "
mkdir -p /backup-target && \
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
"
# ═══════════════════════════════════════════════════
# Step 5: Verify backup integrity
# ═══════════════════════════════════════════════════
- name: Verify backup integrity
run: |
echo "🔍 Verifying backup integrity..."
if gzip -t "${{ steps.meta.outputs.filename }}"; then
echo "✅ Backup gzip integrity check passed"
else
echo "❌ Backup file is corrupted!"
exit 1
fi
# Quick content check: should start with PostgreSQL dump header
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
echo "✅ Backup content header check passed"
else
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
fi
# ═══════════════════════════════════════════════════
# Step 6: Backup Summary
# ═══════════════════════════════════════════════════
- name: Backup Summary
if: always()
run: |
STATUS="${{ job.status }}"
echo ""
echo "═══════════════════════════════════════"
echo " 💾 Database Backup Summary"
echo "═══════════════════════════════════════"
echo " File: ${{ steps.meta.outputs.filename }}"
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
echo " Triggered: @${{ gitea.actor }}"
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
echo " Status: ${STATUS}"
echo "═══════════════════════════════════════"
if [ "${STATUS}" = "success" ]; then
echo ""
echo "💡 Restore command (manual, on host):"
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
fi
+21 -8
View File
@@ -27,14 +27,13 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Restore - name: Restore
run: dotnet restore backend/Nexus.Api.csproj run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
- name: Build - name: Build
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
- name: Test - name: Test
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
continue-on-error: true
# ─── Frontend ────────────────────────────────── # ─── Frontend ──────────────────────────────────
frontend: frontend:
@@ -54,16 +53,18 @@ jobs:
corepack enable corepack enable
corepack prepare pnpm@latest --activate corepack prepare pnpm@latest --activate
# --prefer-offline: use cached packages if available in the runner image
# Lockfile IS committed — regenerated on changes via pnpm install.
- name: Install dependencies - name: Install dependencies
run: pnpm install --no-frozen-lockfile --prefer-offline run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend
- name: Type check - name: Type check
run: pnpm exec vue-tsc --noEmit run: pnpm exec vue-tsc --noEmit
working-directory: frontend working-directory: frontend
- name: Test
run: pnpm test
working-directory: frontend
- name: Build - name: Build
run: pnpm build run: pnpm build
working-directory: frontend working-directory: frontend
@@ -79,8 +80,20 @@ jobs:
- name: Check for .env leaks - name: Check for .env leaks
run: | run: |
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then echo "🔍 Scanning for potential secrets in source code..."
echo "⚠️ Warning: Potential secrets in source code (review manually)" HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
echo "$HITS"
echo ""
echo "Remove these values and use environment variables or a secrets manager instead."
exit 1
fi
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$LOOSE" ]; then
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
echo "$LOOSE"
else else
echo "✅ No obvious secrets found" echo "✅ No obvious secrets found"
fi fi
+294 -123
View File
@@ -1,169 +1,314 @@
name: Deploy to Production name: Deploy to Production
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }} run-name: 🚀 Deploy by @${{ gitea.actor }}
# ── Concurrency: one deploy at a time, cancel queued ones ── # ───────────────────────────────────────────────────────
# Why: prevents race conditions when CI triggers deploy while # Owner: DevOps (Architekt)
# a manual deploy is still running. The latest deploy wins. # CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version Management:
# The VERSION file in the repo root is the single source of truth.
# Version bumps happen in the Dev workflow BEFORE merge to main.
# The deploy workflow only reads, validates, and logs the version.
# The [skip ci] filter remains as a safety layer for auto-triggers.
# ───────────────────────────────────────────────────────
concurrency: concurrency:
group: deploy-production group: deploy-production
cancel-in-progress: false cancel-in-progress: false
# ───────────────────────────────────────────────────
# Trigger: automatic after CI success, or manual dispatch.
# Runner: uses ubuntu-latest label (consistently present on
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
# Standard labels avoid custom-label matching edge cases.
# ───────────────────────────────────────────────────
on: on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run: workflow_run:
workflows: ["CI - Build & Test"] workflows: ["CI - Build & Test"]
types: [completed] types: [completed]
branches: [main] branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump_version:
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
required: false
default: 'patch'
type: string
options:
- 'patch'
- 'minor'
- 'major'
service: service:
description: 'Service to deploy (empty = all)' description: 'Service to deploy (empty = all)'
required: false required: false
default: '' default: ''
type: string type: string
no_cache: no_cache:
description: 'Disable build cache' description: 'Disable Docker build cache'
required: false required: false
default: false default: false
type: boolean type: boolean
git_ref:
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
required: false
default: 'main'
type: string
jobs: jobs:
deploy: deploy:
name: Deploy Nexus name: Deploy Nexus
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }} if: |
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
# ── Env for the deploy target path ──
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
# This ensures the password stays consistent across deploys and the DB is the
# single source of truth after initial seed (enforced by SeedAudit guard).
steps: steps:
# ── Step 1: Checkout ───────────────────── # ═══════════════════════════════════════════════════
- name: Checkout latest code # Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
# ── Step 2: Version bump (race-free) ───── # ═══════════════════════════════════════════════════
# Derives current version from git tags (not VERSION file) to # Step 2: Set up Git identity
# avoid race conditions where tag exists but VERSION is stale. # ═══════════════════════════════════════════════════
# Uses --force on tag+push to handle retries after failed runs. - name: Configure Git
- name: Version Bump run: |
git config user.email "devops@noveria.net"
git config user.name "DevOps"
# ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version
#
# Reads VERSION from repo root — the single source of truth.
# Validates semver format, logs version + git metadata.
# No git mutation: version bumps happen in the Dev workflow.
# ═══════════════════════════════════════════════════
- name: Resolve Version
id: version
run: | run: |
set -euo pipefail set -euo pipefail
# Source of truth: latest git tag # 1. Check VERSION exists
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") if [ ! -f VERSION ]; then
CURRENT_VERSION="${TAG#v}" echo "❌ VERSION file not found"
echo "📦 Current version (from git tags): $CURRENT_VERSION" exit 1
fi
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1) # 2. Read and validate semver format
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) VERSION=$(cat VERSION | tr -d '[:space:]')
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1
fi
case "${{ inputs.bump_version }}" in # 3. Log version, git ref, and describe
major) GIT_REF=$(git rev-parse --short HEAD)
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; GIT_DESCRIBE=$(git describe --always --dirty)
minor)
MINOR=$((MINOR + 1)); PATCH=0 ;;
patch|*)
PATCH=$((PATCH + 1)) ;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" echo "📦 Deploy version: v${VERSION}"
echo "🏷️ New version: $NEW_VERSION" echo "🔖 Git ref: ${GIT_REF}"
echo "$NEW_VERSION" > VERSION echo "🏷️ Git describe: ${GIT_DESCRIBE}"
git config user.email "devops@noveria.net" # 4. Set outputs for downstream steps
git config user.name "DevOps" echo "version=${VERSION}" >> "$GITEA_OUTPUT"
git add VERSION echo "mutated_main=false" >> "$GITEA_OUTPUT"
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
# --force avoids "tag already exists" when re-running after a failed attempt # ═══════════════════════════════════════════════════
git tag -f "v${NEW_VERSION}" # Step 4: Build .env from secrets + host .env (SAFE)
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags #
echo "✅ Version bumped to v${NEW_VERSION}" # Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# ── Step 3: Sync code + .env to host ────── # the host. The temp file is deleted immediately after
# Creates .env from Gitea secrets in the workspace, then syncs # compose operations complete.
# everything (except .git) to the host deploy path via DIND. #
- name: Sync code + .env to host # OWNER_PASSWORD is read from the host's persistent .env
# to ensure it stays the single source of truth. Other
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
# come from Gitea secrets.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file)
run: | run: |
# Create .env from Gitea secrets in the workspace set -euo pipefail
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
echo " The host .env is the single source of truth for the owner password."
echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying."
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline # Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea secrets → do not edit manually on the host # Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus POSTGRES_DB=nexus
POSTGRES_USER=nexus POSTGRES_USER=nexus
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${{ secrets.ENV_JWT_KEY }} JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }} OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME= OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD= OPENCLAW_GATEWAY_PASSWORD=
ENVEOF EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
# Sync everything (except .git) from workspace to host
docker run --rm \ docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \ -v "${{ gitea.workspace }}:/src:ro" \
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \ -v "${DEPLOY_PATH}:/dest" \
alpine:latest \ alpine:latest \
sh -c " sh -c "
cd /src && \ cd /src && \
find . -mindepth 1 -maxdepth 1 \ find . -mindepth 1 -maxdepth 1 \
! -name .git \ ! -name .git \
-exec cp -a {} /dest/ \; -exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
" "
echo "✅ Code + .env synced to host deploy path"
# ── Step 4: Docker Buildx ───────────────── echo "✅ Code synced to ${DEPLOY_PATH}"
- name: Set up Docker Buildx
run: docker buildx create --use 2>/dev/null || true
# ── Step 5: Build & Deploy ──────────────── # ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy - name: Build & Deploy
run: | run: |
set -euo pipefail
# Auto-deploy: always use cache. Manual: respect no_cache input.
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
BUILD_ARGS="" BUILD_ARGS=""
if [ "${{ inputs.no_cache }}" = "true" ]; then if [ "$NO_CACHE" = "true" ]; then
BUILD_ARGS="--no-cache" BUILD_ARGS="--no-cache"
fi fi
docker run --rm \ SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
if [ -n '${{ inputs.service }}' ]; then
echo '🚀 Deploying service: ${{ inputs.service }}'
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
docker compose up -d --force-recreate ${{ inputs.service }}
else
echo '🚀 Deploying all services'
docker compose build ${BUILD_ARGS}
docker compose up -d --force-recreate
fi
"
# ── Step 6: Health Check (backoff) ──────── # Write the deploy script to a file to avoid nested quoting issues
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total). cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
# Why: cold-start containers need variable warmup time; #!/bin/sh
# fixed 5s intervals either wait too long or give up too early. set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT
cat > /tmp/nexus-deploy-env
# ── Clean up zombie containers ──
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true
# ── WAL recovery ──
PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)
if [ -n "$PG_VOL" ]; then
echo "Checking postgres WAL integrity..."
docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" \
--entrypoint sh postgres:17-alpine -c "
echo 'Resetting WAL...'
pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'
" 2>&1 || echo 'pg_resetwal failed (may be benign)'
else
echo 'Postgres volume not found - will be created fresh'
fi
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
SERVICE="${DEPLOY_SERVICE:-}"
if [ -n "$SERVICE" ]; then
echo "Deploying service: $SERVICE"
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS $SERVICE
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate $SERVICE
else
echo 'Deploying all services'
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate
fi
echo 'Waiting for services to become healthy (up to 180s)...'
for i in $(seq 1 36); do
STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)
if echo "$STATUS" | grep -q 'unhealthy'; then
echo " [$i/36] Unhealthy containers - failing fast"
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=30
exit 1
elif echo "$STATUS" | grep -q 'starting'; then
echo " [$i/36] Still starting..."
sleep 5
else
echo 'All containers healthy'
docker compose --env-file /tmp/nexus-deploy-env ps -a
exit 0
fi
done
echo 'Timeout waiting for services'
docker compose --env-file /tmp/nexus-deploy-env ps -a
docker compose --env-file /tmp/nexus-deploy-env logs --tail=20
exit 1
DEPLOYSCRIPT
docker run --rm \
-e "DEPLOY_BUILD_ARGS=${BUILD_ARGS:-}" \
-e "DEPLOY_SERVICE=${SERVICE_ARG:-}" \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /tmp/nexus-deploy-script.sh:/deploy.sh:ro \
-w /workspace/nexus \
-i \
docker:cli \
sh /deploy.sh < "${ENV_TMPFILE}"
rm -f /tmp/nexus-deploy-script.sh
echo "✅ Docker compose up completed"
# ═══════════════════════════════════════════════════
# Step 7: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 8: Health Check (exponential backoff)
# ═══════════════════════════════════════════════════
- name: Health Check - name: Health Check
run: | run: |
echo "🏥 Health check..." echo "🏥 Health check..."
@@ -186,11 +331,10 @@ jobs:
echo "❌ Health check failed after $MAX attempts" echo "❌ Health check failed after $MAX attempts"
exit 1 exit 1
# ── Step 7: Smoke test (multi-endpoint) ─── # ═══════════════════════════════════════════════════
# Tests multiple endpoints to catch partial failures. # Step 9: Smoke Test
# Why: a single /dashboard check can miss backend-only outages; # ═══════════════════════════════════════════════════
# /health tests the API + database + runtime status. - name: Smoke Test
- name: Verify (smoke test)
run: | run: |
echo "🔍 Smoke test..." echo "🔍 Smoke test..."
PASS=0 PASS=0
@@ -199,7 +343,8 @@ jobs:
check() { check() {
local path="$1" label="$2" expected="${3:-200}" local path="$1" label="$2" expected="${3:-200}"
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}") local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}" printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then if [ "$code" = "$expected" ]; then
echo " ✅" echo " ✅"
@@ -210,8 +355,9 @@ jobs:
fi fi
} }
check "/dashboard" "Dashboard" 200 check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200 check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo "" echo ""
echo "Results: $PASS passed, $FAIL failed" echo "Results: $PASS passed, $FAIL failed"
@@ -219,25 +365,50 @@ jobs:
echo "❌ Smoke test failed!" echo "❌ Smoke test failed!"
exit 1 exit 1
fi fi
echo "✅ Deployment verified" echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ── Step 8: Rollback hint ──────────────── # ═══════════════════════════════════════════════════
# On any failure, prints the previous deploy tag for quick manual rollback. # Step 10: Deployment Summary
# Why: reduces MTTR (mean time to recovery) by providing the exact # ═══════════════════════════════════════════════════
# git tag to roll back to without needing to look it up manually. - name: Deployment Summary
- name: Rollback hint if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}"
echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 11: Failure → Reviewer Handoff
#
# On failure: DevOps (Architekt) analyses the log,
# notifies Reviewer (Code-Fixer) with the exact error.
# This output provides a ready-to-copy message.
# ═══════════════════════════════════════════════════
- name: 🔴 Failure — Reviewer Handoff
if: failure() if: failure()
run: | run: |
echo "" echo ""
echo "🔙 ─── Rollback Instructions ─── 🔙" echo "─────────────────────────────────────────────────────────────┐"
echo "" echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo " # 1. Checkout previous version:" echo "├─────────────────────────────────────────────────────────────┤"
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')" echo "│ │"
echo "" echo "│ Version: v${{ steps.version.outputs.version }}"
echo " # 2. Redeploy:" echo " Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo " cd /opt/openclaw/data/openclaw/workspace/nexus" echo "│ │"
echo " docker compose up -d --force-recreate" echo " → DevOps (Architekt) analysiert den Fehler │"
echo "" echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo " # 3. Or trigger rollback via Gitea:" echo "│ → DevOps verifiziert mit neuem Deploy │"
echo " Trigger 'Deploy to Production' workflow with the previous tag" echo " "
echo "" echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+286
View File
@@ -0,0 +1,286 @@
name: Rollback to Previous Version
run-name: 🔙 Rollback by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
#
# This workflow reverts the deploy path to the code at a
# given git tag/ref, then rebuilds and redeploys the stack.
#
# Strategy: git checkout <tag> → docker compose up -d --build
# This is a "full restart rollback" — safest for containerized
# apps where DB schema changes may need the matching API binary.
#
# DB migrations: the API runs MigrateAsync on startup. If the
# rollback-tag's migration history is a prefix of the current DB,
# EF Core handles this gracefully (no-op for already-applied
# migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
target_tag:
description: 'Git tag to roll back to (e.g. v0.2.49)'
required: true
type: string
confirm:
description: 'Type "ROLLBACK" to confirm'
required: true
type: string
jobs:
rollback:
name: Rollback Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
# ═══════════════════════════════════════════════════
# Step 0: Safety gate — require explicit confirmation
# ═══════════════════════════════════════════════════
- name: Safety Gate
run: |
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
echo " You entered: '${{ inputs.confirm }}'"
exit 1
fi
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
# ═══════════════════════════════════════════════════
# Step 1: Checkout target tag
# ═══════════════════════════════════════════════════
- name: Checkout target tag
uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.target_tag }}
fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════
# Step 2: Verify tag exists
# ═══════════════════════════════════════════════════
- name: Verify tag
run: |
set -euo pipefail
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$ACTUAL_TAG" ]; then
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
echo " Available tags:"
git tag -l 'v*' | sort -V | tail -20
exit 1
fi
echo "✅ Checked out: $ACTUAL_TAG"
echo " Commit: $(git rev-parse --short HEAD)"
echo " Message: $(git log -1 --oneline)"
# Read version from VERSION file at this tag
if [ -f VERSION ]; then
VERSION=$(cat VERSION | tr -d '[:space:]')
echo " VERSION: $VERSION"
fi
# ═══════════════════════════════════════════════════
# Step 3: Prepare .env from secrets + host .env (safe temp file)
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file)
run: |
set -euo pipefail
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${HOST_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 4: Sync rollback code to host
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 5: Rebuild & Redeploy
# ═══════════════════════════════════════════════════
- name: Rebuild & Redeploy
run: |
set -euo pipefail
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
echo "✅ Rollback redeploy completed"
# ═══════════════════════════════════════════════════
# Step 6: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 7: Health Check
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check after rollback..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 8: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test after rollback..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
# ═══════════════════════════════════════════════════
# Step 9: Rollback Summary
# ═══════════════════════════════════════════════════
- name: Rollback Summary
if: always()
run: |
echo ""
echo "═══════════════════════════════════════"
echo " 🔙 Rollback Summary"
echo "═══════════════════════════════════════"
echo " Rolled to: ${{ inputs.target_tag }}"
echo " Triggered: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 10: Failure → Reviewer Handoff
# ═══════════════════════════════════════════════════
- name: 🔴 Rollback Failed — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Target: ${{ inputs.target_tag }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+7
View File
@@ -30,4 +30,11 @@ docker-compose.override.yml
*.tmp *.tmp
*.bak *.bak
# Crash artefacts / Core dumps
**/core
**/core.*
# pnpm (lockfile IS committed for reproducible CI builds) # pnpm (lockfile IS committed for reproducible CI builds)
# Claude local config (per-developer, not repo-shared)
.claude/
+81 -19
View File
@@ -3,7 +3,11 @@
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
adapter-backed agent runtime, not a dependency of the frontend or domain model. adapter-backed agent runtime, not a dependency of the frontend or domain model.
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy. > CI runs automatically on every push. CD can run **automatically after successful CI**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
> stay read-only. Rollback and database backup are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation ## Current foundation
@@ -11,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
- ASP.NET Core 10 REST API (Minimal API pattern) - ASP.NET Core 10 REST API (Minimal API pattern)
- Entity Framework Core and PostgreSQL - Entity Framework Core and PostgreSQL
- JWT owner authentication with rotating refresh sessions - JWT owner authentication with rotating refresh sessions
- `IAgentRuntime` abstraction with an OpenClaw adapter - `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
- `IModelProvider` abstractions for Ollama and NVIDIA
- Responsive dark-mode operations dashboard - Responsive dark-mode operations dashboard
- Container-only entry point on `127.0.0.1:18880` - Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
## Local/container start ## Local/container start
@@ -27,12 +30,11 @@ curl http://127.0.0.1:18880/health
``` ```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`, On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14 `OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
characters. Existing databases are never overwritten by the bootstrap process. characters. Existing databases are never overwritten by the bootstrap process.
The web service is loopback-only. Public reverse-proxy activation for The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
`nexus.noveria.net` remains a separate infrastructure change and must terminate Health checks, rate limiting, and security headers are active.
TLS before forwarding to port `18880`.
## Workspace mounts ## Workspace mounts
@@ -41,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
| Host path | Container mount | | Host path | Container mount |
|---|---| |---|---|
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
## Frontend architecture ## Frontend architecture
@@ -279,12 +281,72 @@ Backlog → Blocked → In progress / Done
provider key. Conversation IDs are stable per browser and Iris is the default provider key. Conversation IDs are stable per browser and Iris is the default
agent target. agent target.
The configured model-routing policy is: The configured model-routing policy routes through the OpenClaw Gateway only.
Ollama and NVIDIA providers have been removed. Currently active models:
1. `qwen3:4b` through Ollama for routine and monitoring work | Agent | Model |
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work |-------|-------|
3. `gpt-5.5` through OpenClaw for strategic and critical review | Iris | `openai/gpt-5.4` |
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
The Settings module reports runtime and provider state without exposing The Settings module reports runtime and provider state without exposing
credentials. credentials.
# Trigger CI
## CI/CD
### CI — Automatic
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
- **Backend**: .NET restore → build → test
- **Frontend**: pnpm install → type-check → test → build
- **Security**: Scan for hardcoded secrets in source code
CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3)
Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
- Uses safe defaults: `patch` bump, all services, main ref
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
#### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`)
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
4. Health check + smoke test verify the rollback
#### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions
2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
#### Failure Handling
When deploy or rollback fails:
- **DevOps (Architekt)** analyses the error
- **Reviewer (Code-Fixer)** fixes the problem
- **DevOps** re-deploys to verify the fix
The workflow outputs a formatted handoff message with the job URL.
Full CD documentation: [phases/deployment.md](phases/deployment.md)
+1 -1
View File
@@ -1 +1 @@
0.2.24 0.2.56
+52 -18
View File
@@ -11,12 +11,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentsAsync_ReturnsCorrectCount() public async Task GetAgentsAsync_ReturnsCorrectCount()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -27,12 +23,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Iris_ReturnsOrchestrator() public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -44,18 +36,60 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Unknown_ReturnsNull() public async Task GetAgentAsync_Unknown_ReturnsNull()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None); var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
Assert.Null(agent); Assert.Null(agent);
} }
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath
})
.Build();
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{
"id": "iris",
"name": "iris"
},
{
"id": "programmer",
"name": "programmer"
},
{
"id": "reviewer",
"name": "reviewer"
},
{
"id": "architekt",
"name": "architekt"
}
]
}
}
""");
return path;
}
} }
public sealed class FakeRuntime : IAgentRuntime public sealed class FakeRuntime : IAgentRuntime
+146
View File
@@ -0,0 +1,146 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class OperationsSnapshotTests
{
[Fact]
public void GetSnapshot_RequiresAuthorization()
{
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
}
[Fact]
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
{
var guard = new RepositoryConcurrencyGuard();
var runtime = new SnapshotRuntimeStub();
var agentService = new SnapshotAgentServiceStub();
var projectRepo = new GuardedProjectRepository(guard);
var taskRepo = new GuardedTaskRepository(guard);
var activityRepo = new GuardedActivityRepository(guard);
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
await service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, guard.MaxConcurrentCalls);
}
}
internal sealed class RepositoryConcurrencyGuard
{
private readonly Lock sync = new();
private int currentCalls;
public int MaxConcurrentCalls { get; private set; }
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
{
lock (sync)
{
currentCalls++;
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
}
try
{
await Task.Delay(25, ct);
return value;
}
finally
{
lock (sync)
{
currentCalls--;
}
}
}
}
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
{
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<Project>
{
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
}, ct);
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
{
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<WorkTask>
{
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
}, ct);
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>
{
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>(), ct);
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotRuntimeStub : IAgentRuntime
{
public string Name => "stub";
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotAgentServiceStub : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
[
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
+253
View File
@@ -0,0 +1,253 @@
using Nexus.Api.Data;
using Xunit;
namespace Nexus.Api.Tests;
public class TaskBoardTests
{
// ── TaskStateHelper: BoardGroupKey ──
[Theory]
[InlineData("Backlog", "offen")]
[InlineData("In progress", "inProgress")]
[InlineData("Delegated", "delegated")]
[InlineData("Review", "review")]
[InlineData("Blocked", "blocked")]
[InlineData("Done", "done")]
[InlineData("backlog", "offen")]
[InlineData("in progress", "inProgress")]
[InlineData("delegated", "delegated")]
[InlineData("review", "review")]
[InlineData("blocked", "blocked")]
[InlineData("done", "done")]
[InlineData("", "offen")]
[InlineData(null, "offen")]
[InlineData("unknown", "offen")]
public void BoardGroupKey_ReturnsExpectedGroup(string? state, string expected)
{
var result = TaskStateHelper.BoardGroupKey(state);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: BoardGroupToState ──
[Theory]
[InlineData("offen", "Backlog")]
[InlineData("inProgress", "In progress")]
[InlineData("inprogress", "In progress")]
[InlineData("delegated", "Delegated")]
[InlineData("review", "Review")]
[InlineData("blocked", "Blocked")]
[InlineData("done", "Done")]
[InlineData("Offen", "Backlog")]
[InlineData("", null)]
[InlineData(null, null)]
[InlineData("unknown", null)]
public void BoardGroupToState_ReturnsExpectedState(string? groupKey, string? expected)
{
var result = TaskStateHelper.BoardGroupToState(groupKey);
Assert.Equal(expected, result);
}
// ── TaskStateHelper: AllStates has 6 entries ──
[Fact]
public void AllStates_ContainsAllSixStates()
{
var states = TaskStateHelper.AllStates;
Assert.Equal(6, states.Length);
Assert.Contains("Backlog", states);
Assert.Contains("In progress", states);
Assert.Contains("Delegated", states);
Assert.Contains("Review", states);
Assert.Contains("Blocked", states);
Assert.Contains("Done", states);
}
// ── TaskStateHelper: IsValidState ──
[Theory]
[InlineData("Backlog", true)]
[InlineData("In progress", true)]
[InlineData("Delegated", true)]
[InlineData("Review", true)]
[InlineData("Blocked", true)]
[InlineData("Done", true)]
[InlineData("backlog", true)]
[InlineData("offen", false)]
[InlineData("", false)]
[InlineData(null, false)]
[InlineData("unknown", false)]
public void IsValidState_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsValidState(state));
}
// ── TaskStateHelper: IsInProgressOrBlocked ──
[Theory]
[InlineData("In progress", true)]
[InlineData("Blocked", true)]
[InlineData("Backlog", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)]
[InlineData("Done", false)]
[InlineData(null, false)]
public void IsInProgressOrBlocked_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsInProgressOrBlocked(state));
}
// ── TaskStateHelper: IsDoneOrBacklog ──
[Theory]
[InlineData("Done", true)]
[InlineData("Backlog", true)]
[InlineData("In progress", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)]
[InlineData("Blocked", false)]
[InlineData(null, false)]
public void IsDoneOrBacklog_ReturnsCorrectResult(string? state, bool expected)
{
Assert.Equal(expected, TaskStateHelper.IsDoneOrBacklog(state));
}
// ── TaskStateHelper: ToDisplayString ──
[Theory]
[InlineData("Backlog", "Offen")]
[InlineData("In progress", "In Bearbeitung")]
[InlineData("Delegated", "Delegiert")]
[InlineData("Review", "Review")]
[InlineData("Blocked", "Blockiert")]
[InlineData("Done", "Erledigt")]
[InlineData("backlog", "Offen")]
[InlineData("", "")]
[InlineData(null, "")]
[InlineData("unknown", "unknown")]
public void ToDisplayString_ReturnsGermanLabel(string? state, string expected)
{
Assert.Equal(expected, TaskStateHelper.ToDisplayString(state));
}
// ── TaskState helper: ToStateString and ToTaskState roundtrip ──
[Fact]
public void ToStateString_And_ToTaskState_RoundTrip()
{
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Delegated, TaskState.Review, TaskState.Blocked, TaskState.Done };
foreach (var state in states)
{
var str = state.ToStateString();
var parsed = str.ToTaskState();
Assert.Equal(state, parsed);
}
}
[Fact]
public void ToTaskState_DefaultsToBacklog_ForUnknownString()
{
Assert.Equal(TaskState.Backlog, "unknown".ToTaskState());
}
// ── TaskStateHelper: CanChangeState (Iris + Bao policy) ──
[Fact]
public void CanChangeState_Iris_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("iris", agentTask));
Assert.True(TaskStateHelper.CanChangeState("iris", normalTask));
}
[Fact]
public void CanChangeState_Bao_CanChangeAnyTask()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
var normalTask = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.True(TaskStateHelper.CanChangeState("bao", agentTask));
Assert.True(TaskStateHelper.CanChangeState("bao", normalTask));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false, Source = "bao" };
Assert.False(TaskStateHelper.CanChangeState("programmer", task));
Assert.False(TaskStateHelper.CanChangeState("reviewer", task));
Assert.False(TaskStateHelper.CanChangeState("architekt", task));
}
[Fact]
public void CanChangeState_SubAgents_NeverAllowed_EvenForAgentTasks()
{
var agentTask = new WorkTask { Title = "test", IsAgentTask = true, Source = "iris" };
Assert.False(TaskStateHelper.CanChangeState("programmer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("reviewer", agentTask));
Assert.False(TaskStateHelper.CanChangeState("architekt", agentTask));
}
[Fact]
public void CanChangeState_NexusSystem_IsAllowed()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", task));
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.True(TaskStateHelper.CanChangeState("nexus-system", agentTask));
}
[Fact]
public void CanChangeState_UnknownCaller_Rejected()
{
var task = new WorkTask { Title = "test", IsAgentTask = false };
var agentTask = new WorkTask { Title = "test", IsAgentTask = true };
Assert.False(TaskStateHelper.CanChangeState("", task));
Assert.False(TaskStateHelper.CanChangeState("", agentTask));
Assert.False(TaskStateHelper.CanChangeState("unknown", task));
Assert.False(TaskStateHelper.CanChangeState(null, task));
}
// ── TaskStateHelper: CanEditContent ──
[Fact]
public void CanEditContent_Iris_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("iris"));
}
[Fact]
public void CanEditContent_Bao_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("bao"));
}
[Fact]
public void CanEditContent_SubAgents_AreAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("programmer"));
Assert.True(TaskStateHelper.CanEditContent("reviewer"));
Assert.True(TaskStateHelper.CanEditContent("architekt"));
}
[Fact]
public void CanEditContent_NexusSystem_IsAllowed()
{
Assert.True(TaskStateHelper.CanEditContent("nexus-system"));
}
[Fact]
public void CanEditContent_UnknownCaller_Rejected()
{
Assert.False(TaskStateHelper.CanEditContent(""));
Assert.False(TaskStateHelper.CanEditContent(null));
Assert.False(TaskStateHelper.CanEditContent(" "));
}
}
+184
View File
@@ -0,0 +1,184 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
/// <summary>
/// Admin/User-Management erreichbar für owner und admin-Rollen.
///
/// Sicherheitsregeln:
/// - Nur owner und admin dürfen User verwalten.
/// - Die Rolle "owner" kann weder vergeben noch überschrieben werden sie ist
/// eine Sonderrolle, die nur bei der initialen Seed-Erstellung gesetzt wird.
/// - Über die API sind nur die Rollen "admin", "user" und "viewer" wählbar.
/// </summary>
[ApiController]
[Route("api/v1/admin")]
[Authorize(Roles = "owner,admin")]
public class AdminController(
IUserRepository userRepository,
ILogger<AdminController> logger) : ControllerBase
{
private static readonly string[] SettableRoles = ["admin", "user", "viewer"];
/// <summary>
/// Alle registrierten User auflisten.
/// </summary>
[HttpGet("users")]
public async Task<IResult> GetUsers(CancellationToken ct)
{
var users = await userRepository.GetAllAsync(ct);
var result = users.Select(u => new AdminUserInfo
{
Id = u.Id,
Email = u.Email,
DisplayName = u.DisplayName,
Role = u.Role,
CreatedAt = u.CreatedAt,
LastLoginAt = u.LastLoginAt,
}).ToList();
return Results.Ok(result);
}
/// <summary>
/// Neuen User anlegen.
/// Die Rolle "owner" kann NICHT gesetzt werden.
/// </summary>
[HttpPost("users")]
public async Task<IResult> CreateUser([FromBody] AdminCreateUserRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["request"] = ["Email and password are required."]
});
if (request.Password.Length < 10)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["password"] = ["Password must be at least 10 characters."]
});
// Role validieren owner ist nicht über API setzbar
var targetRole = string.IsNullOrWhiteSpace(request.Role) ? "user" : request.Role.Trim().ToLowerInvariant();
if (!SettableRoles.Contains(targetRole))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["role"] = [$"Invalid role. Valid roles: {string.Join(", ", SettableRoles)}."]
});
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
if (existing is not null)
return Results.Conflict(new { error = "A user with this email already exists." });
var user = new NexusUser
{
Email = request.Email.Trim(),
NormalizedEmail = normalizedEmail,
DisplayName = string.IsNullOrWhiteSpace(request.DisplayName)
? request.Email.Split('@')[0]
: request.DisplayName.Trim(),
PasswordHash = PasswordSecurity.Hash(request.Password),
Role = targetRole,
};
await userRepository.AddAsync(user, ct);
logger.LogInformation("User {Role} created user {Email} with role {Role}", UserRole(), user.Email, user.Role);
return Results.Created($"/api/v1/admin/users/{user.Id}", new AdminUserInfo
{
Id = user.Id,
Email = user.Email,
DisplayName = user.DisplayName,
Role = user.Role,
CreatedAt = user.CreatedAt,
});
}
/// <summary>
/// User löschen. Eigene owner-User und der eigene Account sind geschützt.
/// </summary>
[HttpDelete("users/{id:guid}")]
public async Task<IResult> DeleteUser(Guid id, CancellationToken ct)
{
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
if (user.Id.ToString() == CurrentUserId())
return Results.Problem("You cannot delete your own account.", statusCode: 403);
await userRepository.DeleteAsync(user, ct);
logger.LogInformation("User {Role} deleted user {Email}", UserRole(), user.Email);
return Results.NoContent();
}
/// <summary>
/// Rolle eines Users ändern. "owner" kann weder gesetzt noch überschrieben werden.
/// </summary>
[HttpPatch("users/{id:guid}/role")]
public async Task<IResult> UpdateUserRole(Guid id, [FromBody] AdminUpdateRoleRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Role))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["role"] = ["Role is required."]
});
var newRole = request.Role.Trim().ToLowerInvariant();
if (!SettableRoles.Contains(newRole))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["role"] = [$"Invalid role. Valid: {string.Join(", ", SettableRoles)}. Owner is reserved."]
});
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
// Niemals owner überschreiben
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
return Results.Problem("Owner role cannot be modified via API.", statusCode: 403);
// admin darf andere admins nicht ändern (nur owner)
var callerRole = UserRole();
if (callerRole == "admin" && string.Equals(user.Role, "admin", StringComparison.OrdinalIgnoreCase))
return Results.Problem("Admin users can only be managed by the owner.", statusCode: 403);
// admin darf sich nicht selbst herabstufen
if (callerRole == "admin" && user.Id.ToString() == CurrentUserId() && newRole != "admin")
return Results.Problem("You cannot demote yourself.", statusCode: 403);
user.Role = newRole;
user.UpdatedAt = DateTimeOffset.UtcNow;
await userRepository.UpdateAsync(user, ct);
logger.LogInformation("User {Role} changed role for {Email} from {OldRole} to {NewRole}",
callerRole, user.Email, user.Role, newRole);
return Results.Ok(new AdminUserInfo
{
Id = user.Id,
Email = user.Email,
DisplayName = user.DisplayName,
Role = user.Role,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
});
}
/// <summary>Liefert die Rolle des aufrufenden Users.</summary>
private string UserRole()
=> User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value?.ToLowerInvariant() ?? "unknown";
/// <summary>Liefert die Subject-ID des aufrufenden Users.</summary>
private string? CurrentUserId()
=> User.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value;
}
+28 -60
View File
@@ -1,8 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Helpers;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
@@ -15,6 +13,7 @@ public class AgentsController(
IAgentService agentService, IAgentService agentService,
IAgentRuntime runtime, IAgentRuntime runtime,
IActivityRepository activityRepo, IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
ILogger<AgentsController> logger) : ControllerBase ILogger<AgentsController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -22,8 +21,7 @@ public class AgentsController(
{ {
var agents = await agentService.GetAgentsAsync(ct); var agents = await agentService.GetAgentsAsync(ct);
return Results.Ok(agents.Select(a => new AgentListResponse( return Results.Ok(agents.Select(a => new AgentListResponse(
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
)));
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -34,8 +32,7 @@ public class AgentsController(
return Results.Ok(new AgentDetailResponse( return Results.Ok(new AgentDetailResponse(
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
agent.SubAgents, agent.IdentityName agent.SubAgents, agent.IdentityName));
));
} }
[HttpGet("{id}/activity")] [HttpGet("{id}/activity")]
@@ -58,9 +55,7 @@ public class AgentsController(
try try
{ {
var result = await runtime.ChatAsync(message, conversationId, id, ct); var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
} }
catch (Exception exception) catch (Exception exception)
@@ -73,79 +68,52 @@ public class AgentsController(
} }
} }
// ========== Agent Config Editor ========== // ── Config Editor ──
[HttpGet("{id}/config")] [HttpGet("{id}/config")]
public IResult GetConfig(string id) public IResult GetConfig(string id)
{ => Results.Ok(agentConfigService.GetConfigFiles(id));
var workspacePath = $"/mnt/workspace-{id}";
if (!Directory.Exists(workspacePath))
return Results.Ok(Array.Empty<object>());
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
var files = Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => allowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new
{
fileName = f.Name,
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
return Results.Ok(files);
}
[HttpGet("{id}/config/{fileName}")] [HttpGet("{id}/config/{fileName}")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct) public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); return file is null
? Results.NotFound()
var workspacePath = $"/mnt/workspace-{id}"; : Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
[HttpPut("{id}/config/{fileName}")] [HttpPut("{id}/config/{fileName}")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
if (request.Content is null) if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." }); return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024) if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
var workspacePath = $"/mnt/workspace-{id}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return Results.NotFound();
var tempPath = safePath + ".tmp";
try try
{ {
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct); var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
System.IO.File.Move(tempPath, safePath, overwrite: true); return result is null
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
} }
catch catch (UnauthorizedAccessException ex)
{ {
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
throw; return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
}
catch (IOException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
} }
var fi = new FileInfo(safePath);
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
} }
+51 -3
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
@@ -14,7 +15,8 @@ public class AuthController(
IAuthService authService, IAuthService authService,
IAntiforgery antiforgery, IAntiforgery antiforgery,
IConfiguration config, IConfiguration config,
IHostEnvironment env) : ControllerBase IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{ {
[HttpGet("csrf")] [HttpGet("csrf")]
public IActionResult GetCsrfToken() public IActionResult GetCsrfToken()
@@ -30,11 +32,38 @@ public class AuthController(
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["credentials"] = ["Email and password are required."] });
var session = await authService.LoginAsync(request, ct); var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
if (session is null) return Results.Unauthorized();
var session = await authService.LoginAsync(request, ct);
if (session is null)
{
var remaining = attemptTracker.RecordFailedAttempt(ip);
var retryAfterSeconds = attemptTracker.GetRetryAfterSeconds(ip);
// Attach remaining info to the 401 response via headers only
// (the frontend can also parse the 429 body)
HttpContext.Response.Headers["X-RateLimit-Remaining"] = remaining.ToString();
HttpContext.Response.Headers["X-RateLimit-Limit"] = "5";
if (retryAfterSeconds > 0)
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
}
// Success — reset attempt counter
attemptTracker.Reset(ip);
SetRefreshCookie(Response, session.RefreshToken); SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store"; Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session)); return Results.Ok(ToAuthResponse(session));
} }
@@ -54,6 +83,8 @@ public class AuthController(
SetRefreshCookie(Response, session.RefreshToken); SetRefreshCookie(Response, session.RefreshToken);
Response.Headers.CacheControl = "no-store"; Response.Headers.CacheControl = "no-store";
Response.Headers["X-RateLimit-Remaining"] = "5";
Response.Headers["X-RateLimit-Limit"] = "5";
return Results.Ok(ToAuthResponse(session)); return Results.Ok(ToAuthResponse(session));
} }
@@ -91,6 +122,23 @@ public class AuthController(
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role }); : Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
} }
[HttpPost("admin-reset-password")]
[EnableRateLimiting("agents")]
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.NewPassword) || string.IsNullOrWhiteSpace(request.AdminToken))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["request"] = ["Email, new password, and admin token are required."] });
if (request.NewPassword.Length < 10)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["newPassword"] = ["New password must be at least 10 characters."] });
var success = await authService.AdminResetPasswordAsync(request.Email, request.NewPassword, request.AdminToken, ct);
if (!success)
return Results.Problem("Password reset failed. Check the admin token, email, and that the user exists.", statusCode: 400);
return Results.Ok(new { message = "Password reset successfully." });
}
[HttpPost("change-password")] [HttpPost("change-password")]
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct) public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
{ {
+4 -67
View File
@@ -1,80 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/calendar")] [Route("api/v1/calendar")]
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase public class CalendarController(ICalendarService calendarService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
{ => Results.Ok(await calendarService.GetCronJobsAsync(ct));
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return Results.Ok(data ?? new List<CronJobEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
}
var fallbackJobs = new List<object>
{
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
};
return Results.Ok(fallbackJobs);
}
[HttpGet("upcoming")] [HttpGet("upcoming")]
public async Task<IResult> GetUpcoming(CancellationToken ct) public async Task<IResult> GetUpcoming(CancellationToken ct)
{ => Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return Results.Ok(data ?? new List<UpcomingCronEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
}
var now = DateTimeOffset.UtcNow;
var fallback = new List<object>
{
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
};
return Results.Ok(fallback);
}
} }
+353
View File
@@ -0,0 +1,353 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard")]
public class DashboardController(
IDashboardService dashboardService,
ITaskService taskService,
IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor) : ControllerBase
{
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
=> await dashboardService.GetStatusAsync();
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
=> await dashboardService.GetAgentsAsync();
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations(
[FromQuery] int limit = 20,
[FromQuery] string? agent = null)
=> await dashboardService.GetOperationsAsync(limit, agent);
[HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required");
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
}
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
[HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct);
[HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
return result.Outcome switch
{
QueueDeleteOutcome.Deleted => NoContent(),
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => StatusCode(500, new { error = "Internal error" })
};
}
[HttpPut("queue/{id}/priority")]
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
{
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
return result.Outcome switch
{
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => Ok(new { status = "ok", priority = result.NewPriority })
};
}
[HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
{
var info = await dashboardService.GetAgentModelAsync(id);
return info is null
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
: Ok(info);
}
[HttpPut("agents/{id}/model")]
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Model))
return BadRequest(new { error = "Model is required" });
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
}
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
=> await dashboardService.GetAgentActivityAsync(id, limit);
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
=> Ok(dashboardService.GetAvailableModels());
// ── Task Endpoints ──
[HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
}
[HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
try
{
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var result = await taskService.UpdateDashboardTaskAsync(
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, request.DueDate, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
[HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
{
var result = await taskService.DeleteAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
};
}
[HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
// Enforce workflow rules based on caller agent
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
{
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
}
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
// ── Task Board Endpoints ──
[HttpGet("tasks/board")]
public async Task<BoardResponse> GetBoard(CancellationToken ct)
=> await taskService.GetBoardAsync(ct);
[HttpPatch("tasks/{id:guid}/move")]
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
Guid id, [FromBody] MoveTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.State))
return BadRequest(new { error = "State is required." });
// Enforce workflow rules based on caller agent
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
// Resolve caller agent from header or JWT
var callerAgent = ResolveCallerAgent();
// Nur Iris und Bao dürfen Status ändern
if (!TaskStateHelper.CanChangeState(callerAgent, currentTask))
{
return StatusCode(403, new { error = "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben." });
}
var result = await taskService.MoveTaskAsync(id, request.State, ct);
return result.Outcome switch
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported state: '{request.State}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly).
/// </summary>
private string ResolveCallerAgent()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "";
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
// ── New Endpoints: Reset Stale, Children, Activity ──
[HttpPost("tasks/reset-stale")]
public async Task<ActionResult<ResetStaleResponse>> ResetStale(
[FromBody] ResetStaleRequest request, CancellationToken ct)
{
var threshold = TimeSpan.FromHours(Math.Max(1, request.StaleHours));
var count = await taskService.ResetStaleInProgressTasksAsync(threshold, ct);
return Ok(new ResetStaleResponse(count));
}
[HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{
var children = await taskService.GetChildTasksAsync(id, ct);
return Ok(children.Select(MapToDto).ToList());
}
[HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
{
var task = await taskService.GetDashboardTaskByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
return Ok(task);
}
[HttpGet("tasks/{id:guid}/activity")]
public async Task<ActionResult<List<ActivityEvent>>> GetTaskActivity(Guid id, CancellationToken ct)
{
var events = await taskService.GetTaskActivityAsync(id, ct);
return Ok(events);
}
[HttpPost("tasks/{id:guid}/activity")]
public async Task<ActionResult<ActivityEvent>> PostTaskActivity(
Guid id, [FromBody] PostActivityRequest request, CancellationToken ct)
{
var task = await taskService.GetByIdAsync(id, ct);
if (task is null) return NotFound(new { error = "Task not found." });
if (string.IsNullOrWhiteSpace(request.Message))
return BadRequest(new { error = "Message is required." });
var ev = new ActivityEvent
{
Type = request.Type ?? "comment",
Message = request.Message.Trim(),
TaskId = id
};
await activityService.AddAsync(ev, ct);
return Created($"/api/dashboard/tasks/{id}/activity/{ev.Id}", ev);
}
// ── Agent Workflow Endpoints (Iris Overview) ──
/// <summary>
/// Returns agent-tasks that are still open and waiting for input.
/// Iris uses this to see who she is waiting for.
/// </summary>
[HttpGet("tasks/agent-waiting")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetAgentWaitingTasks(CancellationToken ct)
{
var waiting = await taskService.GetWaitingTasksAsync(ct);
return Ok(waiting.Select(MapToDto).ToList());
}
/// <summary>
/// Returns a complete agent-workflow overview grouped by expected respondent
/// + stale detection. This is the main Iris dashboard data.
/// </summary>
[HttpGet("tasks/agent-overview")]
public async Task<ActionResult<AgentWorkflowOverview>> GetAgentOverview(
CancellationToken ct, [FromQuery] int staleHours = 2)
{
var threshold = TimeSpan.FromHours(Math.Max(1, staleHours));
return Ok(await taskService.GetAgentWorkflowOverviewAsync(threshold, ct));
}
/// <summary>
/// Creates an agent-task: a task that is tracked as originating from the agent workflow.
/// Sub-agents (programmer, reviewer) can only CREATE, not move state.
/// </summary>
[HttpPost("tasks/agent")]
public async Task<ActionResult<DashboardTaskDto>> CreateAgentTask(
[FromBody] CreateAgentTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
try
{
var task = await taskService.CreateAgentTaskAsync(
request.Title, request.Detail, request.Source ?? "iris",
request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
}
+5 -51
View File
@@ -1,47 +1,15 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/docs")] [Route("api/v1/docs")]
public class DocsController : ControllerBase public class DocsController(IDocService docService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public IResult GetAll()
{ => Results.Ok(docService.GetAll());
var workspaceRoot = "/mnt/workspace-iris";
var results = new List<object>();
void ScanDir(string dir, string category)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
continue;
var fi = new FileInfo(file);
results.Add(new
{
name = fi.Name,
path = file.Replace(workspaceRoot, "").TrimStart('/'),
category,
type = ext.Replace(".", ""),
size = fi.Length,
modifiedAt = fi.LastWriteTimeUtc
});
}
}
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
ScanDir("/mnt/workspace-iris/skills", "skills");
ScanDir("/mnt/workspace-iris", "workspace");
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
}
[HttpGet("{**path}")] [HttpGet("{**path}")]
public async Task<IResult> GetFile(string path) public async Task<IResult> GetFile(string path)
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required."); return Results.BadRequest("Path required.");
string? resolvedPath = null; var file = await docService.GetFileAsync(path);
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) return file is null ? Results.NotFound() : Results.Ok(file);
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
} }
+8
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
@@ -7,6 +8,13 @@ namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{ {
[AllowAnonymous]
[HttpGet("/health/live")]
public IResult Live()
{
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
}
[HttpGet("/health")] [HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct) public async Task<IResult> Get(CancellationToken ct)
{ {
+5 -85
View File
@@ -1,100 +1,20 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
using System.Text.RegularExpressions;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/incidents")] [Route("api/v1/incidents")]
public class IncidentsController : ControllerBase public class IncidentsController(IIncidentService incidentService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await incidentService.GetAllAsync());
var basePath = "/mnt/workspace-iris/memory/incidents";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var incidents = new List<object>();
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await System.IO.File.ReadAllTextAsync(file);
var title = name;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
var severity = "unknown";
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
if (severityMatch.Success)
severity = severityMatch.Groups[1].Value.Trim();
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0
? content[..excerptEnd].Trim()
: content[..Math.Min(300, content.Length)].Trim();
if (excerpt.Length > 200)
excerpt = excerpt[..200] + "\u2026";
incidents.Add(new
{
name = Path.GetFileName(file),
title,
date,
severity,
excerpt,
size = fi.Length
});
}
return Results.Ok(incidents);
}
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetOne(string name) public async Task<IResult> GetOne(string name)
{ {
var basePath = "/mnt/workspace-iris/memory/incidents"; var incident = await incidentService.GetByNameAsync(name);
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath)) return incident is null ? Results.NotFound() : Results.Ok(incident);
return Results.BadRequest("Invalid filename.");
if (!System.IO.File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(basePath, name + ".md");
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
}
var content = await System.IO.File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = fileName;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
return Results.Ok(new
{
name = fileName,
title,
date,
content,
size = fi.Length
});
} }
} }
+7 -86
View File
@@ -1,40 +1,15 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/memory")] [Route("api/v1/memory")]
public class MemoryController : ControllerBase public class MemoryController(IMemoryService memoryService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await memoryService.GetAllAsync());
var basePath = "/mnt/workspace-iris/memory";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var files = Directory.GetFiles(basePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new
{
name = f.Name,
path = f.FullName.Replace(basePath, "").TrimStart('/'),
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
var fi = new FileInfo(longTermPath);
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
}
return Results.Ok(files);
}
[HttpGet("search")] [HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q) public async Task<IResult> Search([FromQuery] string q)
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
if (string.IsNullOrWhiteSpace(q) || q.Length < 2) if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters."); return Results.BadRequest("Query must be at least 2 characters.");
var basePath = "/mnt/workspace-iris/memory"; return Results.Ok(await memoryService.SearchAsync(q));
var results = new List<object>();
const int maxFiles = 50;
const int maxFileSize = 1_000_000;
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
foreach (var file in files)
{
var fi = new FileInfo(file);
if (fi.Length > maxFileSize) continue;
string content;
using (var reader = new StreamReader(file))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
}
}
}
await SearchDir(basePath);
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
string content;
using (var reader = new StreamReader(longTermPath))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
}
}
return Results.Ok(results);
} }
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetFile(string name) public async Task<IResult> GetFile(string name)
{ {
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) var file = await memoryService.GetFileAsync(name);
return Results.BadRequest("Invalid filename."); return file is null ? Results.NotFound() : Results.Ok(file);
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
filePath = longTermPath;
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(filePath!);
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
} }
} }
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/dashboard/notifications")]
public class NotificationsController(INotificationService notificationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<NotificationDto>>> GetNotifications(
[FromQuery] string forUser = "bao",
[FromQuery] int limit = 50,
[FromQuery] bool unreadOnly = false,
CancellationToken ct = default)
{
var notifications = await notificationService.GetForUserAsync(forUser, limit, unreadOnly, ct);
return Ok(notifications.Select(MapToDto).ToList());
}
[HttpGet("unread-count")]
public async Task<ActionResult<UnreadCountDto>> GetUnreadCount(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.GetUnreadCountAsync(forUser, ct);
return Ok(new UnreadCountDto(count));
}
[HttpPatch("{id:guid}/read")]
public async Task<ActionResult> MarkAsRead(Guid id, CancellationToken ct = default)
{
var ok = await notificationService.MarkAsReadAsync(id, ct);
return ok ? NoContent() : NotFound(new { error = "Notification not found." });
}
[HttpPatch("read-all")]
public async Task<ActionResult> MarkAllAsRead(
[FromQuery] string forUser = "bao",
CancellationToken ct = default)
{
var count = await notificationService.MarkAllAsReadAsync(forUser, ct);
return Ok(new { marked = count });
}
private static NotificationDto MapToDto(Notification n) => new(
n.Id, n.Type, n.Title, n.Message,
n.ForUser, n.TaskId, n.IsRead, n.CreatedAt);
}
+4 -60
View File
@@ -1,71 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/operations")] [Route("api/v1/operations")]
public class OperationsController( public class OperationsController(IOperationsService operationsService) : ControllerBase
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ControllerBase
{ {
[HttpGet("snapshot")] [HttpGet("snapshot")]
[Authorize]
public async Task<IResult> GetSnapshot(CancellationToken ct) public async Task<IResult> GetSnapshot(CancellationToken ct)
{ => Results.Ok(await operationsService.GetSnapshotAsync(ct));
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
var projectsTask = projectRepo.GetAllAsync(ct);
var tasksTask = taskRepo.GetAllAsync(ct);
var activityTask = activityRepo.GetRecentAsync(20, ct);
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
var tasks = tasksTask.Result;
var projects = projectsTask.Result;
var agents = agentsTask.Result;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = runtimeTask.Result;
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
var projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
};
return Results.Ok(new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth,
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
});
}
} }
+19 -46
View File
@@ -1,17 +1,23 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/projects")] [Route("api/v1/projects")]
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase public class ProjectsController(IProjectService projectService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectRepo.GetAllAsync(ct)); => Results.Ok(await projectService.GetAllAsync(ct));
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectService.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPost] [HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct) public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var project = new Project var project = await projectService.CreateAsync(request, ct);
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
return Results.Created($"/api/v1/projects/{project.Id}", project); return Results.Created($"/api/v1/projects/{project.Id}", project);
} }
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectRepo.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var project = await projectService.UpdateAsync(id, request, ct);
if (project is null) return Results.NotFound(); return project is null ? Results.NotFound() : Results.Ok(project);
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return Results.Ok(project);
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct) public async Task<IResult> Delete(Guid id, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var result = await projectService.DeleteAsync(id, ct);
if (project is null) return Results.NotFound(); return result.Outcome switch
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
if (hasTasks)
{ {
project.Status = OperationalStatus.Offline; ProjectDeleteOutcome.NotFound => Results.NotFound(),
await projectRepo.UpdateAsync(project, ct); ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct); _ => Results.NoContent()
return Results.Ok(project); };
}
await projectRepo.DeleteAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
return Results.NoContent();
} }
} }
+76 -70
View File
@@ -1,17 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/tasks")] [Route("api/v1/tasks")]
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase public class TasksController(ITaskService taskService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await taskRepo.GetAllAsync(ct)); => Results.Ok(await taskService.GetAllAsync(ct));
[HttpPost] [HttpPost]
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct) public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
@@ -19,107 +21,111 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = new WorkTask var task = await taskService.CreateAsync(request, ct);
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
return Results.Created($"/api/v1/tasks/{task.Id}", task); return Results.Created($"/api/v1/tasks/{task.Id}", task);
} }
[HttpGet("pending-approval")] [HttpGet("pending-approval")]
public async Task<IResult> GetPendingApproval(CancellationToken ct) public async Task<IResult> GetPendingApproval(CancellationToken ct)
{ {
var pending = await taskRepo.GetPendingApprovalAsync(ct); var pending = await taskService.GetPendingApprovalAsync(ct);
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
} }
[HttpPost("{id:guid}/approve")] [HttpPost("{id:guid}/approve")]
public async Task<IResult> Approve(Guid id, CancellationToken ct) public async Task<IResult> Approve(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.ApproveAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Approval denied", title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
task.State = TaskStateHelper.ToStateString(TaskState.Done); };
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
return Results.Ok(task);
} }
[HttpPost("{id:guid}/reject")] [HttpPost("{id:guid}/reject")]
public async Task<IResult> Reject(Guid id, CancellationToken ct) public async Task<IResult> Reject(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.RejectAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Rejection denied", title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
task.State = TaskStateHelper.ToStateString(TaskState.Backlog); };
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
return Results.Ok(task);
} }
[HttpPatch("{id:guid}/state")] [HttpPatch("{id:guid}/state")]
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct) public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
{ {
var allowedStates = TaskStateHelper.AllStates; if (!TaskStateHelper.IsValidState(request.State))
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateStateAsync(id, request.State, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); {
await taskRepo.UpdateAsync(task, ct); TaskOperationOutcome.NotFound => Results.NotFound(),
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); TaskOperationOutcome.InvalidState => Results.Problem(
return Results.Ok(task); title: "Action denied",
} detail: "Statusänderungen sind nur Iris und Bao vorbehalten. Sub-Agenten können Tasks nicht verschieben.",
statusCode: StatusCodes.Status403Forbidden),
[HttpDelete("{id:guid}")] _ => Results.Ok(result.Task)
public async Task<IResult> Delete(Guid id, CancellationToken ct) };
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return Results.NoContent();
} }
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateAsync(id, request, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
};
}
if (!string.IsNullOrWhiteSpace(request.Title)) [HttpDelete("{id:guid}")]
task.Title = request.Title.Trim(); public async Task<IResult> Delete(Guid id, CancellationToken ct)
if (!string.IsNullOrWhiteSpace(request.Priority)) {
task.Priority = request.Priority.Trim(); var result = await taskService.DeleteAsync(id, ct);
if (request.ProjectId.HasValue) return result.Outcome switch
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; {
TaskOperationOutcome.NotFound => Results.NotFound(),
TaskOperationOutcome.InvalidState => Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.NoContent()
};
}
await taskRepo.UpdateAsync(task, ct); // ── Board & Stale-Reset (für Iris Autonomous Worker) ──
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
return Results.Ok(task); /// <summary>
/// Gibt das Task-Board zurück (gruppiert nach Status, priorisiert sortiert).
/// Wird vom Iris Autonomous Worker genutzt.
/// </summary>
[AllowAnonymous]
[HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct)
=> Results.Ok(await taskService.GetBoardAsync(ct));
/// <summary>
/// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt.
/// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count));
} }
} }
+2 -29
View File
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/team")] [Route("api/v1/team")]
public class TeamController(IAgentService agentService) : ControllerBase public class TeamController(ITeamService teamService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetTeam(CancellationToken ct) public async Task<IResult> GetTeam(CancellationToken ct)
{ => Results.Ok(await teamService.GetTeamAsync(ct));
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<object>();
foreach (var agent in agents)
{
string identity = "";
string workspace = agent.Workspace ?? "";
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
{
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (System.IO.File.Exists(identityFile))
{
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
identity = string.Join("\n", lines);
}
}
team.Add(new
{
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity
});
}
return Results.Ok(team);
}
} }
+13
View File
@@ -0,0 +1,13 @@
namespace Nexus.Api.DTOs;
public sealed record AdminResetPasswordRequest
{
/// <summary>The email of the user whose password should be reset.</summary>
public required string Email { get; init; }
/// <summary>The new password to set.</summary>
public required string NewPassword { get; init; }
/// <summary>Admin reset token from configuration (Admin:ResetToken).</summary>
public required string AdminToken { get; init; }
}
+23
View File
@@ -26,6 +26,29 @@ public sealed record UserInfo
public string Role { get; init; } = string.Empty; public string Role { get; init; } = string.Empty;
} }
public sealed record AdminUserInfo
{
public Guid Id { get; init; }
public string Email { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string Role { get; init; } = string.Empty;
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset? LastLoginAt { get; init; }
}
public sealed record AdminCreateUserRequest
{
public string Email { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty;
public string? DisplayName { get; init; }
public string? Role { get; init; }
}
public sealed record AdminUpdateRoleRequest
{
public string Role { get; init; } = string.Empty;
}
public sealed record UpdateProfileRequest public sealed record UpdateProfileRequest
{ {
[MaxLength(100)] [MaxLength(100)]
+1
View File
@@ -12,3 +12,4 @@ public sealed record IncidentInfoDto(
string? Title, string? Title,
DateTimeOffset? Since DateTimeOffset? Since
); );
+130 -4
View File
@@ -18,8 +18,10 @@ public enum TaskState
{ {
Backlog, Backlog,
InProgress, InProgress,
Delegated,
Blocked, Blocked,
Done Done,
Review
} }
public static class TaskStateHelper public static class TaskStateHelper
@@ -28,20 +30,35 @@ public static class TaskStateHelper
{ {
[TaskState.Backlog] = "Backlog", [TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress", [TaskState.InProgress] = "In progress",
[TaskState.Delegated] = "Delegated",
[TaskState.Blocked] = "Blocked", [TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done" [TaskState.Done] = "Done",
[TaskState.Review] = "Review"
}; };
private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, TaskState> StringToState = new(StringComparer.OrdinalIgnoreCase)
{ {
["Backlog"] = TaskState.Backlog, ["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress, ["In progress"] = TaskState.InProgress,
["Delegated"] = TaskState.Delegated,
["Blocked"] = TaskState.Blocked, ["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done ["Done"] = TaskState.Done,
["Review"] = TaskState.Review
};
/// <summary>Mapping from state string to display label.</summary>
private static readonly Dictionary<string, string> DisplayLabels = new(StringComparer.OrdinalIgnoreCase)
{
["Backlog"] = "Offen",
["In progress"] = "In Bearbeitung",
["Delegated"] = "Delegiert",
["Review"] = "Review",
["Blocked"] = "Blockiert",
["Done"] = "Erledigt"
}; };
/// <summary>Valid task-state string values for API validation.</summary> /// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done"]; public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"];
/// <summary>Convert a TaskState enum to its API string representation.</summary> /// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state]; public static string ToStateString(this TaskState state) => StateToString[state];
@@ -54,6 +71,10 @@ public static class TaskStateHelper
public static bool IsValidState(string? state) => public static bool IsValidState(string? state) =>
!string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state); !string.IsNullOrWhiteSpace(state) && StringToState.ContainsKey(state);
/// <summary>Returns the German display label for a state string.</summary>
public static string ToDisplayString(string? state) =>
state is not null && DisplayLabels.TryGetValue(state, out var label) ? label : state ?? "";
public static bool IsInProgressOrBlocked(string? state) => public static bool IsInProgressOrBlocked(string? state) =>
string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase) string.Equals(state, "In progress", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase); || string.Equals(state, "Blocked", StringComparison.OrdinalIgnoreCase);
@@ -61,6 +82,77 @@ public static class TaskStateHelper
public static bool IsDoneOrBacklog(string? state) => public static bool IsDoneOrBacklog(string? state) =>
string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase) string.Equals(state, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase); || string.Equals(state, "Backlog", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Returns true if the caller is allowed to change this task's state.
/// POLICY:
/// - **Iris und Bao** dürfen Status ändern / verschieben.
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern.
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
/// - Jeder andere (unbekannt, leer) wird abgewiesen.
/// </summary>
public static bool CanChangeState(string? callerAgent, WorkTask task)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
// Sub-agents must never move state
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" };
if (subAgents.Contains(caller)) return false;
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
if (caller == "nexus-system") return true;
// Iris und Bao dürfen Status ändern
return caller == "iris" || caller == "bao";
}
/// <summary>
/// Returns true if the caller is allowed to edit a task's content fields
/// (title, detail, priority, assignedTo, dueDate).
/// POLICY:
/// - Alle (iris, bao, sub-agents, nexus-system) dürfen inhaltlich bearbeiten.
/// - Nur unbekannte/leere Caller werden abgewiesen.
/// </summary>
public static bool CanEditContent(string? callerAgent)
{
var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
if (string.IsNullOrWhiteSpace(caller)) return false;
return true;
}
/// <summary>Group key for board responses (lowercased English state).</summary>
public static string BoardGroupKey(string? state)
{
if (string.IsNullOrWhiteSpace(state)) return "offen";
var lower = state.ToLowerInvariant();
return lower switch
{
"backlog" => "offen",
"in progress" => "inProgress",
"delegated" => "delegated",
"review" => "review",
"blocked" => "blocked",
"done" => "done",
_ => "offen"
};
}
/// <summary>Map a board group key back to the canonical state string.</summary>
public static string? BoardGroupToState(string? groupKey)
{
if (string.IsNullOrWhiteSpace(groupKey)) return null;
var lower = groupKey.ToLowerInvariant();
return lower switch
{
"offen" => "Backlog",
"inprogress" => "In progress",
"delegated" => "Delegated",
"review" => "Review",
"blocked" => "Blocked",
"done" => "Done",
_ => null
};
}
} }
public sealed class Project public sealed class Project
@@ -77,16 +169,50 @@ public sealed class WorkTask
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; } public required string Title { get; set; }
public string? Detail { get; set; }
public string State { get; set; } = "Backlog"; public string State { get; set; } = "Backlog";
public string Priority { get; set; } = "Normal"; public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; }
/// <summary>
/// True if this task was created programmatically by an agent (not manually by Bao).
/// Agent-tasks in the board are subject to stricter workflow rules.
/// </summary>
public bool IsAgentTask { get; set; } = false;
/// <summary>
/// Which agent/user is expected to respond next.
/// Helps Iris see who she is waiting for.
/// </summary>
public string? ExpectedFrom { get; set; }
public Guid? ParentTaskId { get; set; }
public WorkTask? ParentTask { get; set; }
public ICollection<WorkTask> ChildTasks { get; set; } = new List<WorkTask>();
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public DateTimeOffset? DueDate { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
public sealed class Notification
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Type { get; set; } // "task_assigned", "task_review", "task_blocked"
public required string Title { get; set; } // "Neue Aufgabe: Memory-Index reparieren"
public string? Message { get; set; } // Detailtext
public required string ForUser { get; set; } // "bao" oder "iris"
public Guid? TaskId { get; set; } // Verknüpfte Task
public bool IsRead { get; set; } = false;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class ActivityEvent public sealed class ActivityEvent
{ {
public long Id { get; init; } public long Id { get; init; }
public required string Type { get; set; } public required string Type { get; set; }
public required string Message { get; set; } public required string Message { get; set; }
public Guid? TaskId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
+15
View File
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Nexus.Api.Data; namespace Nexus.Api.Data;
@@ -28,6 +29,20 @@ public class NexusUser
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>(); public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
} }
/// <summary>
/// Tracks one-time seed operations so they are never re-executed — even
/// if the underlying data is deleted. This is the single guard that
/// prevents owner-password drift after DB resets or volume recreations.
/// </summary>
public class SeedAudit
{
[Key]
[MaxLength(80)]
public string Key { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class RefreshToken public class RefreshToken
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -0,0 +1,240 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260611154800_AddTaskDetailFields")]
partial class AddTaskDetailFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDetailFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AssignedTo",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "Tasks",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "NOW()");
migrationBuilder.AddColumn<string>(
name: "Detail",
table: "Tasks",
type: "character varying(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Source",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: false,
defaultValue: "bao");
migrationBuilder.CreateIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks",
column: "AssignedTo");
migrationBuilder.CreateIndex(
name: "IX_Tasks_Source",
table: "Tasks",
column: "Source");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_Source",
table: "Tasks");
migrationBuilder.DropColumn(
name: "AssignedTo",
table: "Tasks");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Detail",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Source",
table: "Tasks");
}
}
}
@@ -0,0 +1,311 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618214335_AddNotifications")]
partial class AddNotifications
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Notifications",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Type = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Title = table.Column<string>(type: "character varying(240)", maxLength: 240, nullable: false),
Message = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
ForUser = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
TaskId = table.Column<Guid>(type: "uuid", nullable: true),
IsRead = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Notifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Notifications_ForUser_IsRead_CreatedAt",
table: "Notifications",
columns: new[] { "ForUser", "IsRead", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Notifications");
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskParentChild : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ParentTaskId",
table: "Tasks",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDueDate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "DueDate",
table: "Tasks",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DueDate",
table: "Tasks");
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddActivityTaskReference : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "TaskId",
table: "Activity",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Activity_TaskId",
table: "Activity",
column: "TaskId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Activity_TaskId",
table: "Activity");
migrationBuilder.DropColumn(
name: "TaskId",
table: "Activity");
}
}
}
@@ -0,0 +1,270 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260618233003_AddDelegatedState")]
partial class AddDelegatedState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddDelegatedState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delegated state is a pure code change to the TaskState enum and
// TaskStateHelper. No schema change required since the State column
// is already a free-form string column.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// No schema to revert.
}
}
}
@@ -0,0 +1,322 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260620174200_AddAgentTaskFields")]
partial class AddAgentTaskFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddAgentTaskFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAgentTask",
table: "Tasks",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ExpectedFrom",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks",
column: "IsAgentTask");
migrationBuilder.CreateIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks",
column: "ExpectedFrom");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_IsAgentTask",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ExpectedFrom",
table: "Tasks");
migrationBuilder.DropColumn(
name: "IsAgentTask",
table: "Tasks");
}
}
}
@@ -0,0 +1,336 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260621081500_AddSeedAudit")]
partial class AddSeedAudit
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddSeedAudit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SeedAudit",
columns: table => new
{
Key = table.Column<string>(type: "character varying(80)", maxLength: 80, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SeedAudit", x => x.Key);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SeedAudit");
}
}
}
@@ -38,12 +38,19 @@ namespace Nexus.Api.Migrations
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("character varying(1000)"); .HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Type") b.Property<string>("Type")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("TaskId");
b.ToTable("Activity"); b.ToTable("Activity");
}); });
@@ -93,6 +100,47 @@ namespace Nexus.Api.Migrations
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("Nexus.Api.Data.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ForUser")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsRead")
.HasColumnType("boolean");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<Guid?>("TaskId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.HasKey("Id");
b.HasIndex("ForUser", "IsRead", "CreatedAt");
b.ToTable("Notifications");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b => modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -166,12 +214,50 @@ namespace Nexus.Api.Migrations
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("Nexus.Api.Data.SeedAudit", b =>
{
b.Property<string>("Key")
.HasMaxLength(80)
.HasColumnType("character varying(80)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Key");
b.ToTable("SeedAudit");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b => modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<DateTimeOffset?>("DueDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExpectedFrom")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<bool>("IsAgentTask")
.HasColumnType("boolean");
b.Property<Guid?>("ParentTaskId")
.HasColumnType("uuid");
b.Property<string>("Priority") b.Property<string>("Priority")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -179,6 +265,11 @@ namespace Nexus.Api.Migrations
b.Property<Guid?>("ProjectId") b.Property<Guid?>("ProjectId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State") b.Property<string>("State")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -193,6 +284,16 @@ namespace Nexus.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("ExpectedFrom");
b.HasIndex("IsAgentTask");
b.HasIndex("ParentTaskId");
b.HasIndex("Source");
b.ToTable("Tasks"); b.ToTable("Tasks");
}); });
@@ -207,10 +308,25 @@ namespace Nexus.Api.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.HasOne("Nexus.Api.Data.WorkTask", "ParentTask")
.WithMany("ChildTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b => modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{ {
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
}); });
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Navigation("ChildTasks");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+32 -2
View File
@@ -6,15 +6,45 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
{ {
public DbSet<Project> Projects => Set<Project>(); public DbSet<Project> Projects => Set<Project>();
public DbSet<WorkTask> Tasks => Set<WorkTask>(); public DbSet<WorkTask> Tasks => Set<WorkTask>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<ActivityEvent> Activity => Set<ActivityEvent>(); public DbSet<ActivityEvent> Activity => Set<ActivityEvent>();
public DbSet<NexusUser> Users => Set<NexusUser>(); public DbSet<NexusUser> Users => Set<NexusUser>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>(); public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<SeedAudit> SeedAudits => Set<SeedAudit>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160); modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240); modelBuilder.Entity<WorkTask>(entity =>
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000); {
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Detail).HasMaxLength(2000);
entity.Property(x => x.Source).HasMaxLength(60);
entity.Property(x => x.AssignedTo).HasMaxLength(60);
entity.Property(x => x.ExpectedFrom).HasMaxLength(60);
entity.HasIndex(x => x.Source);
entity.HasIndex(x => x.AssignedTo);
entity.HasIndex(x => x.IsAgentTask);
entity.HasIndex(x => x.ExpectedFrom);
entity.HasOne(x => x.ParentTask)
.WithMany(x => x.ChildTasks)
.HasForeignKey(x => x.ParentTaskId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Notification>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Message).HasMaxLength(1000);
entity.Property(x => x.Type).HasMaxLength(60);
entity.Property(x => x.ForUser).HasMaxLength(60);
entity.HasIndex(x => new { x.ForUser, x.IsRead, x.CreatedAt });
});
modelBuilder.Entity<ActivityEvent>(entity =>
{
entity.Property(x => x.Message).HasMaxLength(1000);
entity.HasIndex(x => x.TaskId);
});
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique(); modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique(); modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId }); modelBuilder.Entity<RefreshToken>().HasIndex(r => new { r.UserId, r.FamilyId });
+1
View File
@@ -8,6 +8,7 @@ RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN apk add --no-cache curl
USER $APP_UID USER $APP_UID
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["dotnet", "Nexus.Api.dll"] ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
@@ -0,0 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Helpers;
using Nexus.Api.Middleware;
using Nexus.Api.Services;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for configuring the Nexus application pipeline and startup.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// Uses a <see cref="SeedAudit"/> guard so the owner is never re-created even if all users
/// are deleted — the DB is the single source of truth for the owner password after first seed.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
var configuration = app.Configuration;
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
const string seedKey = "owner_created";
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
// Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync();
}
}
/// <summary>
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
/// </summary>
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseApiKeyAuthentication();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (env.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
return app;
}
}
@@ -0,0 +1,249 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for registering Nexus application services in the DI container.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
var jwtKey = configuration["Jwt:Key"];
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
/// <summary>
/// Configures rate limiting policies (auth and agents).
/// </summary>
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.ContentType = "application/json";
var retryAfterSeconds = 60;
// Try to read retry-after info from the metadata
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
retryAfterSeconds = (int)retryAfter.TotalSeconds;
}
// Set standard headers
context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString();
context.HttpContext.Response.Headers["X-RateLimit-Remaining"] = "0";
context.HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
var body = new
{
error = "rate_limit_exceeded",
message = $"Too many attempts. Try again in {retryAfterSeconds} second(s).",
remaining = 0,
retryAfterSeconds
};
await context.HttpContext.Response.WriteAsJsonAsync(body, ct);
};
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
return services;
}
/// <summary>
/// Configures forwarded headers for reverse proxy scenarios.
/// </summary>
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
return services;
}
/// <summary>
/// Configures Swagger and JSON serialization options.
/// </summary>
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
return services;
}
/// <summary>
/// Registers the Entity Framework Core DbContext with Npgsql.
/// </summary>
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
return services;
}
/// <summary>
/// Registers typed and named HTTP clients for OpenClaw integration.
/// </summary>
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
return services;
}
/// <summary>
/// Registers application domain services (transient, scoped, singleton).
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>();
return services;
}
/// <summary>
/// Registers data repositories.
/// </summary>
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
return services;
}
/// <summary>
/// Configures health checks (PostgreSQL connectivity and runtime status).
/// </summary>
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
namespace Nexus.Api.Helpers;
/// <summary>
/// Helper methods for password generation and name construction.
/// </summary>
public static class PasswordHelper
{
/// <summary>
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
/// </summary>
public static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
/// <summary>
/// Builds a human-readable display name from an email address.
/// </summary>
public static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
}
+39
View File
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace Nexus.Api.Middleware;
/// <summary>
/// Middleware that authenticates requests via the X-Nexus-Api-Key header.
/// On match, sets a ClaimsPrincipal with role "Service".
/// On mismatch or absent header, passes through to next middleware (JWT auth).
/// </summary>
public sealed class ApiKeyMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
var configuration = context.RequestServices.GetRequiredService<IConfiguration>();
var apiKey = configuration["NexusApiKey"];
if (!string.IsNullOrWhiteSpace(apiKey) &&
context.Request.Headers.TryGetValue("X-Nexus-Api-Key", out var providedKey) &&
string.Equals(apiKey, providedKey, StringComparison.Ordinal))
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "service"),
new Claim(ClaimTypes.Name, "ApiService"),
new Claim(ClaimTypes.Role, "Service")
};
var identity = new ClaimsIdentity(claims, "ApiKey");
context.User = new ClaimsPrincipal(identity);
}
await next(context);
}
}
public static class ApiKeyMiddlewareExtensions
{
public static IApplicationBuilder UseApiKeyAuthentication(this IApplicationBuilder builder)
=> builder.UseMiddleware<ApiKeyMiddleware>();
}
+185
View File
@@ -0,0 +1,185 @@
namespace Nexus.Api.Models;
public sealed record DashboardAgentInfo(
string Id,
string Name,
string Role,
string Model,
bool IsActive,
string? CurrentTask,
string? Description,
string[] Tags,
int Progress = 0,
int Workload = 0,
string? Goal = null,
string RoleBadge = "badge-slate",
string StatusLabel = "Bereit",
string? Elapsed = null,
string? Think = null,
string? Next = null
);
public sealed record MessageEntry(
string Role,
string Content,
string Timestamp
);
public sealed record ChatRequest(
string Message,
string? AgentId
);
public sealed record ChatResponse(
bool Ok,
string? Reply,
string? Error
);
public sealed record FeedEntry(
string Agent,
string Action,
string Timestamp,
string Time,
string? AgentId = null,
string? Type = null
);
public sealed record DashboardStatus(
bool GatewayOk,
string IrisStatus,
int ActiveAgents,
int PendingTasks
);
public sealed record QueueItem(
string Id,
string Name,
string Status,
string Priority,
string Source,
string WaitTime
);
public sealed record AgentModelInfo(
string Model,
string Provider
);
public sealed record SetModelRequest(
string Model
);
public sealed record ModelOption(
string Id,
string Name,
string Provider
);
// ── Dashboard Task DTOs ──
public sealed record DashboardTaskDto(
Guid Id,
string Title,
string? Detail,
string Source,
string State,
string Priority,
string? AssignedTo,
Guid? ParentTaskId,
DateTimeOffset? DueDate,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt,
bool IsAgentTask = false,
string? ExpectedFrom = null,
string? LastActivityMessage = null,
DateTimeOffset? LastActivityAt = null
);
public sealed record CreateDashboardTaskRequest(
string Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo,
Guid? ParentTaskId = null
);
public sealed record CreateAgentTaskRequest(
string Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo,
string? ExpectedFrom,
Guid? ParentTaskId = null
);
public sealed record UpdateDashboardTaskRequest(
string? Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo,
DateTimeOffset? DueDate = null
);
public sealed record UpdateDashboardTaskStatusRequest(
string Status
);
public sealed record AgentActivityEntry(
string Time,
string Text
);
// ── Task Board DTOs ──
public sealed record BoardResponse(
List<DashboardTaskDto> Offen,
List<DashboardTaskDto> InProgress,
List<DashboardTaskDto> Delegated,
List<DashboardTaskDto> Review,
List<DashboardTaskDto> Blocked,
List<DashboardTaskDto> Done
);
public sealed record MoveTaskRequest(
string State
);
public sealed record ResetStaleRequest(
int StaleHours = 2
);
public sealed record ResetStaleResponse(
int ResetCount
);
public sealed record PostActivityRequest(
string Message,
string? Type = null
);
// ── Agent Workflow DTOs ──
/// <summary>
/// Overview of the agent workflow state, grouping tasks by expected respondent
/// and highlighting stale tasks. Used by Iris to see who she is waiting for.
/// </summary>
public sealed record AgentWorkflowOverview(
List<DashboardTaskDto> WaitingForBao,
List<DashboardTaskDto> WaitingForIris,
List<DashboardTaskDto> WaitingForOthers,
List<DashboardTaskDto> StaleTasks,
TimeSpan StaleThreshold
);
// ── Notification DTOs ──
public sealed record NotificationDto(
Guid Id, string Type, string Title, string? Message,
string ForUser, Guid? TaskId, bool IsRead, DateTimeOffset CreatedAt
);
public sealed record UnreadCountDto(int Count);
+20
View File
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Nexus.Api.Data;
namespace Nexus.Api;
public class NexusDbContextFactory : IDesignTimeDbContextFactory<NexusDbContext>
{
public NexusDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<NexusDbContext>();
var connectionString = args.Length > 0
? args[0]
: Environment.GetEnvironmentVariable("ConnectionStrings__Nexus")
?? "Host=localhost;Port=5432;Database=nexus;Username=nexus;Password=nexus";
optionsBuilder.UseNpgsql(connectionString);
return new NexusDbContext(optionsBuilder.Options);
}
}
+14 -205
View File
@@ -1,217 +1,26 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Nexus.Api.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Middleware;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- JWT Configuration --- // --- Service Registration ---
var jwtKey = builder.Configuration["Jwt:Key"]; builder.Services.AddNexusAuth(builder.Configuration);
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; builder.Services.AddNexusRateLimiting();
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; builder.Services.AddNexusForwardedHeaders();
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) builder.Services.AddNexusSwagger();
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddNexusApplicationServices();
.AddJwtBearer(options => builder.Services.AddNexusRepositories();
{ builder.Services.AddNexusHealthChecks(builder.Configuration);
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
// --- Rate Limiting ---
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
// --- Forwarded Headers ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// --- Swagger & JSON ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// --- Database ---
builder.Services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
// --- HTTP Clients ---
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
});
// --- Application Services ---
builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAgentService, AgentService>();
// --- Repositories ---
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
// --- Health Checks ---
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
// --- Controllers ---
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
// --- Database Migration & Owner Seeding --- // --- Database Migration & Seeding ---
await using (var scope = app.Services.CreateAsyncScope()) await app.EnsureDatabaseAsync();
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = builder.Configuration["Owner:Password"];
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseForwardedHeaders(); app.UseNexusPipeline(app.Environment);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
// --- Helpers ---
static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
@@ -0,0 +1,84 @@
using System.Collections.Concurrent;
namespace Nexus.Api.RateLimiting;
/// <summary>
/// Simple in-memory tracking of login attempts per IP,
/// aligned with the fixed-window rate limiter (5 attempts / 1 minute).
///
/// Provides remaining-attempt count that can be passed back to the frontend.
/// </summary>
public sealed class LoginAttemptTracker
{
private const int MaxAttempts = 5;
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
// IP → (count, windowStartTicks)
private static readonly ConcurrentDictionary<string, (int Count, long WindowStartTicks)> _store = new();
/// <summary>
/// Registers a failed attempt for the given IP.
/// Returns remaining attempts (0 = locked out until reset).
/// </summary>
public int RecordFailedAttempt(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
var (count, windowStart) = _store.AddOrUpdate(ip,
_ => (1, now),
(_, entry) =>
{
if (now - entry.WindowStartTicks >= windowTicks)
return (1, now);
return (entry.Count + 1, entry.WindowStartTicks);
});
return Math.Max(0, MaxAttempts - count);
}
/// <summary>
/// Returns the remaining attempts for the given IP without recording.
/// </summary>
public int GetRemaining(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (_store.TryGetValue(ip, out var entry))
{
if (now - entry.WindowStartTicks >= windowTicks)
return MaxAttempts;
return Math.Max(0, MaxAttempts - entry.Count);
}
return MaxAttempts;
}
/// <summary>
/// Returns the number of seconds until the rate-limit window resets,
/// or 0 if the window has already expired / no attempts recorded.
/// </summary>
public int GetRetryAfterSeconds(string ip)
{
var now = Environment.TickCount64;
var windowTicks = (long)Window.TotalMilliseconds;
if (!_store.TryGetValue(ip, out var entry))
return 0;
var elapsed = now - entry.WindowStartTicks;
if (elapsed >= windowTicks)
return 0;
return (int)Math.Ceiling((windowTicks - elapsed) / 1000.0);
}
/// <summary>
/// Resets attempt count for the given IP (e.g. on success).
/// </summary>
public void Reset(string ip)
{
_store.TryRemove(ip, out _);
}
}
@@ -8,6 +8,18 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct); => db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
{
var ids = taskIds.Distinct().ToList();
if (ids.Count == 0)
return Task.FromResult(new List<ActivityEvent>());
return db.Activity.AsNoTracking()
.Where(x => x.TaskId.HasValue && ids.Contains(x.TaskId.Value))
.OrderByDescending(x => x.CreatedAt)
.ToListAsync(ct);
}
public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync( public async Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default) string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
{ {
@@ -5,6 +5,7 @@ namespace Nexus.Api.Repositories;
public interface IActivityRepository public interface IActivityRepository
{ {
Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default); Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default);
Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default);
Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync( Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type, string? sort, int page, int pageSize, CancellationToken ct = default); string? type, string? sort, int page, int pageSize, CancellationToken ct = default);
Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default); Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default);
+4 -3
View File
@@ -7,15 +7,16 @@ public interface IUserRepository
ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default); ValueTask<NexusUser?> GetByIdAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default); Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default);
Task<bool> AnyUsersAsync(CancellationToken ct = default); Task<bool> AnyUsersAsync(CancellationToken ct = default);
Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default);
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default); Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default);
Task DeleteAsync(NexusUser user, CancellationToken ct = default);
// Refresh token operations
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default); Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
} }
+1
View File
@@ -30,6 +30,7 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default) public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{ {
task.UpdatedAt = DateTimeOffset.UtcNow; task.UpdatedAt = DateTimeOffset.UtcNow;
db.Tasks.Update(task);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
} }
+44 -3
View File
@@ -11,6 +11,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default) public Task<NexusUser?> GetByEmailAsync(string normalizedEmail, CancellationToken ct = default)
=> db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct); => db.Users.FirstOrDefaultAsync(u => u.NormalizedEmail == normalizedEmail, ct);
public Task<List<NexusUser>> GetAllAsync(CancellationToken ct = default)
=> db.Users.OrderBy(u => u.CreatedAt).ToListAsync(ct);
public Task<bool> AnyUsersAsync(CancellationToken ct = default) public Task<bool> AnyUsersAsync(CancellationToken ct = default)
=> db.Users.AnyAsync(ct); => db.Users.AnyAsync(ct);
@@ -24,6 +27,17 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateAsync(NexusUser user, CancellationToken ct = default) public Task UpdateAsync(NexusUser user, CancellationToken ct = default)
=> db.SaveChangesAsync(ct); => db.SaveChangesAsync(ct);
public async Task DeleteAsync(NexusUser user, CancellationToken ct = default)
{
// Remove refresh tokens first
var tokens = await db.RefreshTokens
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
db.RefreshTokens.RemoveRange(tokens);
db.Users.Remove(user);
await db.SaveChangesAsync(ct);
}
public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default) public Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default)
=> db.RefreshTokens => db.RefreshTokens
.Include(r => r.User) .Include(r => r.User)
@@ -43,6 +57,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default) public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
=> db.SaveChangesAsync(ct); => db.SaveChangesAsync(ct);
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
{
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await db.SaveChangesAsync(ct);
}
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
{
var activeTokens = await db.RefreshTokens
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
.ToListAsync(ct);
if (activeTokens.Count == 0) return;
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await db.SaveChangesAsync(ct);
}
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default) public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
{ {
var cutoff = DateTimeOffset.UtcNow.AddDays(-30); var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
@@ -51,9 +92,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
.ToListAsync(ct); .ToListAsync(ct);
if (oldTokens.Count > 0) if (oldTokens.Count > 0)
{
db.RefreshTokens.RemoveRange(oldTokens); db.RefreshTokens.RemoveRange(oldTokens);
await db.SaveChangesAsync(ct);
}
} }
public Task SaveChangesAsync(CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
} }
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class AgentConfigService : IAgentConfigService
{
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
{
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return Array.Empty<AgentConfigFileInfo>();
return Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => AllowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
.ToList();
}
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
return null;
var content = await File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null;
var tempPath = safePath + ".tmp";
try
{
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
catch
{
if (File.Exists(tempPath)) File.Delete(tempPath);
throw;
}
var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
}
}
+46 -27
View File
@@ -17,6 +17,7 @@ public interface IAuthService
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default); Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default); Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default); Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
} }
public sealed record AuthSession( public sealed record AuthSession(
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly ILogger<AuthService> _logger; private readonly ILogger<AuthService> _logger;
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger) public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
{ {
_users = users; _users = users;
@@ -68,7 +71,7 @@ public sealed class AuthService : IAuthService
if (token.RevokedAt is not null) if (token.RevokedAt is not null)
{ {
await RevokeFamilyAsync(token.FamilyId, ct); await _users.RevokeFamilyAsync(token.FamilyId, ct);
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId); _logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
return null; return null;
} }
@@ -81,23 +84,12 @@ public sealed class AuthService : IAuthService
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(refreshToken)) return; if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken); var tokenHash = HashToken(refreshToken);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); await _users.RevokeTokenAsync(tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await _users.SaveChangesAsync(ct);
} }
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default) public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> Task.Run(async () => => _users.GetByIdAsync(userId, ct).AsTask();
{
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
// For read-only access, we call it but the result shouldn't be mutated
return await _users.GetByIdAsync(userId, ct);
}, ct);
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
{ {
@@ -128,6 +120,46 @@ public sealed class AuthService : IAuthService
return true; return true;
} }
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
{
// Validate admin token
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
{
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
return false;
}
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(adminToken),
Encoding.UTF8.GetBytes(AdminResetToken)))
{
_logger.LogWarning("Invalid admin reset token provided");
return false;
}
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
return false;
if (newPassword.Length < 10)
return false;
var normalizedEmail = NormalizeEmail(email);
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
if (user is null)
{
_logger.LogWarning("Admin password reset: user {Email} not found", email);
return false;
}
user.PasswordHash = PasswordSecurity.Hash(newPassword);
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.UpdateAsync(user, ct);
_logger.LogInformation("Admin password reset completed for {Email}", email);
return true;
}
private async Task<AuthSession?> CreateSessionAsync( private async Task<AuthSession?> CreateSessionAsync(
NexusUser user, NexusUser user,
Guid familyId, Guid familyId,
@@ -185,19 +217,6 @@ public sealed class AuthService : IAuthService
return new JwtSecurityTokenHandler().WriteToken(token); return new JwtSecurityTokenHandler().WriteToken(token);
} }
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
{
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await _users.SaveChangesAsync(ct);
}
private static string GenerateRefreshToken() private static string GenerateRefreshToken()
{ {
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
+86
View File
@@ -0,0 +1,86 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public sealed class CalendarService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<CalendarService> logger) : ICalendarService
{
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return data ?? new List<CronJobEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
}
return BuildFallbackCronJobs();
}
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return data ?? new List<UpcomingCronEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
}
return BuildFallbackUpcomingJobs();
}
private HttpClient CreateGatewayClient()
{
var client = httpClientFactory.CreateClient("gateway");
var token = configuration["Integrations:OpenClaw:Token"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
];
}
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
];
}
}
+209
View File
@@ -0,0 +1,209 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class DashboardService(
IOpenClawGatewayClient gateway,
ITaskService taskService,
ILogger<DashboardService> logger) : IDashboardService
{
public async Task<DashboardStatus> GetStatusAsync()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return [];
}
}
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
{
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
if (!string.IsNullOrWhiteSpace(agentFilter))
{
entries = entries
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
return entries;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return [];
}
}
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
{
try
{
return await gateway.SendChatMessageAsync(agentId, message);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
}
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
{
try
{
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
return messages
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard messages fetch failed");
return [];
}
}
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
{
try
{
var cronTask = gateway.GetQueueAsync();
var tasksTask = taskService.GetOpenAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var merged = new List<QueueItem>(cronTask.Result);
foreach (var t in tasksTask.Result)
{
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
}
return merged
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return [];
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
{
var ok = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
}
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
{
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
var result = await taskService.CompleteViaQueueAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
};
}
var deleted = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
}
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
{
if (!id.StartsWith("task-"))
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
var result = await taskService.CyclePriorityAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
};
}
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
{
try
{
return await gateway.GetAgentModelAsync(agentId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
return null;
}
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
try
{
return await gateway.SetAgentModelAsync(agentId, model);
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
return false;
}
}
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
{
try
{
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
return [];
}
}
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
{
"high" or "critical" or "urgent" => "high",
"low" or "minor" => "low",
_ => "medium"
};
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
{
["high"] = 0, ["medium"] = 1, ["low"] = 2
};
}
+75
View File
@@ -0,0 +1,75 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class DocService : IDocService
{
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
private static readonly string[] SearchRoots =
[
"/mnt/workspace-iris",
"/home/node/.openclaw/workspace/nexus"
];
private static readonly (string Dir, string Category)[] ScanDirectories =
[
("/mnt/workspace-iris/nexus-phases", "phases"),
("/mnt/workspace-iris/skills", "skills"),
("/mnt/workspace-iris", "workspace"),
("/home/node/.openclaw/workspace/nexus", "nexus"),
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
];
public IReadOnlyList<DocFileInfo> GetAll()
{
var results = new List<DocFileInfo>();
foreach (var (dir, category) in ScanDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext)) continue;
var fi = new FileInfo(file);
results.Add(new DocFileInfo(
fi.Name,
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
category,
ext.Replace(".", ""),
fi.Length,
fi.LastWriteTimeUtc));
}
}
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
}
public async Task<DocFileContent?> GetFileAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string? resolvedPath = null;
foreach (var root in SearchRoots)
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return null;
var content = await File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
var relativePath = resolvedPath
.Replace("/mnt/workspace-iris/", "")
.Replace("/home/node/.openclaw/workspace/nexus/", "");
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
public interface IAgentConfigService
{
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+9
View File
@@ -0,0 +1,9 @@
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public interface ICalendarService
{
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
}
+25
View File
@@ -0,0 +1,25 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
public interface IDashboardService
{
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
List<ModelOption> GetAvailableModels();
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record DocFileInfo(
string Name,
string Path,
string Category,
string Type,
long Size,
DateTime ModifiedAt);
public sealed record DocFileContent(
string Name,
string Path,
string Content,
long Size,
DateTime ModifiedAt);
public interface IDocService
{
IReadOnlyList<DocFileInfo> GetAll();
Task<DocFileContent?> GetFileAsync(string path);
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record IncidentSummary(
string Name,
string Title,
string? Date,
string Severity,
string Excerpt,
long Size);
public sealed record IncidentDetail(
string Name,
string Title,
string? Date,
string Content,
long Size);
public interface IIncidentService
{
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
Task<IncidentDetail?> GetByNameAsync(string name);
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
public interface IMemoryService
{
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
Task<MemoryFileContent?> GetFileAsync(string name);
}
+13
View File
@@ -0,0 +1,13 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface INotificationService
{
Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default);
Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default);
Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default);
Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default);
Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawGatewayClient
{
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
List<ModelOption> GetAvailableModels();
}
+6
View File
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IOperationsService
{
Task<object> GetSnapshotAsync(CancellationToken ct = default);
}
+17
View File
@@ -0,0 +1,17 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
public interface IProjectService
{
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
}
+44
View File
@@ -0,0 +1,44 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
public interface ITaskService
{
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default);
Task<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
// Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
// Agent Workflow Overview
Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default);
Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+19
View File
@@ -0,0 +1,19 @@
using Nexus.Api.Data;
namespace Nexus.Api.Services;
public sealed record TeamMember(
string Id,
string Name,
string Role,
string Model,
OperationalStatus Status,
DateTimeOffset? LastSeen,
string? Workspace,
string? Description,
string Identity);
public interface ITeamService
{
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
}
+89
View File
@@ -0,0 +1,89 @@
using Nexus.Api.Helpers;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public sealed partial class IncidentService : IIncidentService
{
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
{
if (!Directory.Exists(BasePath))
return Array.Empty<IncidentSummary>();
var incidents = new List<IncidentSummary>();
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await File.ReadAllTextAsync(file);
var title = ExtractTitle(name, content);
var date = ExtractDate(name);
var severity = ExtractSeverity(content);
var excerpt = ExtractExcerpt(content);
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
}
return incidents;
}
public async Task<IncidentDetail?> GetByNameAsync(string name)
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
return null;
if (!File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(BasePath, name + ".md");
if (!File.Exists(filePath!))
return null;
}
var content = await File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
var date = ExtractDate(fileName);
return new IncidentDetail(fileName, title, date, content, fi.Length);
}
private static string ExtractTitle(string name, string content)
{
var match = TitleRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : name;
}
private static string? ExtractDate(string fileName)
{
var match = DateRegex().Match(fileName);
return match.Success ? match.Groups[1].Value : null;
}
private static string ExtractSeverity(string content)
{
var match = SeverityRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
}
private static string ExtractExcerpt(string content)
{
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
}
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
private static partial Regex TitleRegex();
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
private static partial Regex DateRegex();
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
private static partial Regex SeverityRegex();
}
+100
View File
@@ -0,0 +1,100 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class MemoryService : IMemoryService
{
private const string BasePath = "/mnt/workspace-iris/memory";
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
private const int MaxFileSize = 1_000_000;
private const int MaxFiles = 50;
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
{
var files = new List<MemoryFileInfo>();
if (File.Exists(LongTermPath))
{
var fi = new FileInfo(LongTermPath);
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
}
if (Directory.Exists(BasePath))
{
var memFiles = Directory.GetFiles(BasePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new MemoryFileInfo(
f.Name,
f.FullName.Replace(BasePath, "").TrimStart('/'),
f.Length,
f.LastWriteTimeUtc));
files.AddRange(memFiles);
}
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
}
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
{
var results = new List<MemorySearchResult>();
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
{
var fi = new FileInfo(file);
if (fi.Length > MaxFileSize) continue;
var content = await File.ReadAllTextAsync(file);
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Add(new MemorySearchResult(
Path.GetFileName(file),
file.Replace(BasePath, "").TrimStart('/'),
excerpt,
fi.Length));
}
}
await SearchDir(BasePath);
if (File.Exists(LongTermPath))
{
var content = await File.ReadAllTextAsync(LongTermPath);
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
}
}
return results;
}
public async Task<MemoryFileContent?> GetFileAsync(string name)
{
string? filePath;
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
{
filePath = LongTermPath;
}
else
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
return null;
}
if (!File.Exists(filePath!))
return null;
var content = await File.ReadAllTextAsync(filePath!);
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
}
}
+61
View File
@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class NotificationService(NexusDbContext db) : INotificationService
{
public async Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification
{
Type = type,
Title = title,
Message = message,
ForUser = forUser.ToLowerInvariant(),
TaskId = taskId
};
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct);
return notification;
}
public async Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
{
var query = db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant());
if (unreadOnly)
query = query.Where(n => !n.IsRead);
return await query
.OrderByDescending(n => n.CreatedAt)
.Take(limit)
.ToListAsync(ct);
}
public async Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default)
{
var notification = await db.Notifications.FindAsync([id], ct);
if (notification is null) return false;
notification.IsRead = true;
await db.SaveChangesAsync(ct);
return true;
}
public async Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default)
{
var count = await db.Notifications
.Where(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead)
.ExecuteUpdateAsync(s => s.SetProperty(n => n.IsRead, true), ct);
return count;
}
public async Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default)
{
return await db.Notifications
.CountAsync(n => n.ForUser == forUser.ToLowerInvariant() && !n.IsRead, ct);
}
}
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class OperationsService(
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : IOperationsService
{
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
{
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
// Repository calls share the scoped EF Core DbContext and must stay serialized.
var projects = await projectRepo.GetAllAsync(ct);
var tasks = await taskRepo.GetAllAsync(ct);
var activity = await activityRepo.GetRecentAsync(20, ct);
var agents = await agentsTask;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = await runtimeTask;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
return new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
},
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
};
}
}
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class ProjectService(
IProjectRepository projectRepo,
IActivityRepository activityRepo) : IProjectService
{
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
=> await projectRepo.GetAllAsync(ct);
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await projectRepo.GetByIdAsync(id, ct);
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
var project = new Project
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return null;
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return project;
}
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
if (await projectRepo.HasTasksAsync(id, ct))
{
project.Status = OperationalStatus.Offline;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
await projectRepo.DeleteAsync(project, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
}
}
+642
View File
@@ -0,0 +1,642 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo,
INotificationService notificationService,
IHttpContextAccessor httpContextAccessor) : ITaskService
{
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return null;
var activity = await activityRepo.GetRecentForTasksAsync([task.Id], ct);
return MapToDtoWithActivity(task, activity);
}
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created", TaskId = task.Id }, ct);
return task;
}
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Title) && !string.Equals(task.Title, request.Title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{request.Title.Trim()}\"");
task.Title = request.Title.Trim();
}
if (!string.IsNullOrWhiteSpace(request.Priority) && !string.Equals(task.Priority, request.Priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {request.Priority.Trim()}");
task.Priority = request.Priority.Trim();
}
if (request.ProjectId.HasValue)
{
changes.Add($"Projekt-ID geändert");
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
}
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" aktualisiert: {changeSummary}", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted", TaskId = task.Id }, ct);
await taskRepo.DeleteAsync(task, ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
/// <summary>
/// Returns agent-tasks that are still open and where an agent is expected to respond.
/// Iris Dashboard uses this to see who she is waiting for.
/// </summary>
public async Task<IReadOnlyList<WorkTask>> GetWaitingTasksAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all
.Where(t => t.IsAgentTask && !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderBy(t => t.ExpectedFrom != null ? 0 : 1)
.ThenByDescending(t => t.UpdatedAt)
.ToList();
}
/// <summary>
/// Returns agent-tasks grouped by which agent is expected to respond,
/// with stale-detection: tasks in InProgress/Delegated that haven't been
/// updated within the stale threshold.
/// </summary>
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var agentTasks = all.Where(t => t.IsAgentTask).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(agentTasks.Select(t => t.Id), ct);
List<DashboardTaskDto> map(IEnumerable<WorkTask> tasks)
=> tasks.Select(task => MapToDtoWithActivity(task, activity)).ToList();
var waitingForBao = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "bao", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForIris = map(agentTasks
.Where(t => string.Equals(t.ExpectedFrom, "iris", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)));
var waitingForOthers = map(agentTasks
.Where(t =>
{
var expected = (t.ExpectedFrom ?? "").ToLowerInvariant();
return expected != "bao" && expected != "iris" && !string.IsNullOrWhiteSpace(expected) &&
!string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase);
}));
var staleTasks = map(agentTasks
.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
staleTasks, staleThreshold);
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, Guid? parentTaskId = null, CancellationToken ct = default)
{
// Validate parent task exists if specified
if (parentTaskId.HasValue)
{
var parent = await taskRepo.GetByIdAsync(parentTaskId.Value, ct);
if (parent is null)
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
}
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = ValidateAssignedTo(assignedTo),
ParentTaskId = parentTaskId
};
await taskRepo.AddAsync(task, ct);
var message = $"Task \"{task.Title}\" created ({task.Source})";
if (parentTaskId.HasValue)
message += $" [child of {parentTaskId.Value}]";
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
// Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_assigned",
$"Neue Aufgabe: {task.Title}",
detail,
"bao",
task.Id,
ct);
}
return task;
}
public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default)
{
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true;
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
// Persist the agent-task-specific fields
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "agent_task",
Message = $"Agent-Task created: \"{task.Title}\" (Source: {task.Source}, Expected: {task.ExpectedFrom ?? "none"})",
TaskId = task.Id
}, ct);
// Notify iris about new agent-task
await notificationService.CreateAsync(
"agent_task_created",
$"Neuer Agent-Task: {task.Title}",
detail,
"iris",
task.Id,
ct);
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source,
string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default)
{
var caller = ResolveCaller();
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var changes = new List<string>();
if (!string.IsNullOrWhiteSpace(title) && !string.Equals(task.Title, title.Trim(), StringComparison.Ordinal))
{
changes.Add($"Titel: \"{task.Title}\" → \"{title.Trim()}\"");
task.Title = title.Trim();
}
if (detail is not null)
{
var newDetail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.Equals(task.Detail ?? "", newDetail ?? "", StringComparison.Ordinal))
{
changes.Add("Beschreibung aktualisiert");
task.Detail = newDetail;
}
}
if (!string.IsNullOrWhiteSpace(source))
task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority) && !string.Equals(task.Priority, priority.Trim(), StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Priorität: {task.Priority} → {priority.Trim()}");
task.Priority = priority.Trim();
}
if (assignedTo is not null)
{
var validated = ValidateAssignedTo(assignedTo);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
task.AssignedTo = validated;
}
}
if (dueDate.HasValue)
{
if (task.DueDate?.Date != dueDate.Value.Date)
{
changes.Add($"Fällig: {task.DueDate?.ToString("yyyy-MM-dd") ?? "kein Datum"} → {dueDate.Value:yyyy-MM-dd}");
task.DueDate = dueDate;
}
}
await taskRepo.UpdateAsync(task, ct);
var changeSummary = changes.Count > 0 ? string.Join("; ", changes) : "keine sichtbaren Änderungen";
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" aktualisiert von {caller}: {changeSummary}",
TaskId = task.Id
}, ct);
// Notification: wenn Bao die Task geändert hat, Iris benachrichtigen
if (changes.Count > 0 && caller == "bao")
{
await notificationService.CreateAsync(
"task_content_changed",
$"Bao hat \"{task.Title}\" geändert",
$"{changeSummary}",
"iris",
task.Id,
ct);
}
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
{
if (!TaskStateHelper.IsValidState(status))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.Priority = task.Priority.ToLowerInvariant() switch
{
"high" => "Medium",
"medium" => "Low",
"low" => "High",
_ => "Medium"
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}", TaskId = task.Id }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
// ── Board operations ──
public async Task<BoardResponse> GetBoardAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var delegated = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in all)
{
var dto = MapToDto(task);
switch (task.State.ToLowerInvariant())
{
case "backlog":
offen.Add(dto); break;
case "in progress":
inProgress.Add(dto); break;
case "delegated":
delegated.Add(dto); break;
case "review":
review.Add(dto); break;
case "blocked":
blocked.Add(dto); break;
case "done":
done.Add(dto); break;
default:
offen.Add(dto); break;
}
}
offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
delegated.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(offen, inProgress, delegated, review, blocked, done);
}
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
public async Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default)
{
// Resolve canonical state: accept board group keys or canonical strings
var canonical = TaskStateHelper.AllStates
.FirstOrDefault(s => s.Equals(newState, StringComparison.OrdinalIgnoreCase));
if (canonical is null)
{
// Try mapping from board group key
canonical = TaskStateHelper.BoardGroupToState(newState);
}
if (canonical is null)
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
// Enforce workflow rules
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" moved to {canonical}", TaskId = task.Id }, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{
var normalizedHours = Math.Max(1, staleHours);
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
}
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) ||
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
var prevState = task.State;
task.State = "Backlog";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
TaskId = task.Id
}, ct);
}
return staleTasks.Count;
}
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{
var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList();
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom);
private static DashboardTaskDto MapToDtoWithActivity(WorkTask t, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(e => e.TaskId == t.Id)
.OrderByDescending(e => e.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom,
last?.Message,
last?.CreatedAt);
}
/// <summary>
/// Validates AssignedTo — only recognized agent values are accepted.
/// Returns null for invalid values.
/// </summary>
private static string? ValidateAssignedTo(string? assignedTo)
{
if (string.IsNullOrWhiteSpace(assignedTo)) return null;
var lower = assignedTo.Trim().ToLowerInvariant();
return ValidAssignees.Contains(lower) ? lower : null;
}
/// <summary>
/// Resolves the caller identity from the HTTP context.
/// Reads the X-Agent-Id header for agent calls, falls back to JWT name.
/// Outside HTTP context → "nexus-system" (allowed for internal Cron/ResetStale ops).
/// </summary>
private string ResolveCaller()
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null) return "nexus-system"; // internal system ops allowed
var agentHeader = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
return agentHeader.Trim().ToLowerInvariant();
var user = httpContext.User;
var nameClaim = user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return nameClaim?.ToLowerInvariant() ?? "";
}
/// <summary>
/// Creates status-change notifications when a task moves to a new state.
/// - Wenn Bao ändert → Iris benachrichtigen
/// - Wenn Iris ändert → Bao benachrichtigen
/// - Review/Blocked bekommen spezifische Töne
/// </summary>
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct)
{
var caller = ResolveCaller();
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_review",
$"Task zur Überprüfung: {task.Title}",
$"Status auf Review geändert von {caller}",
"bao",
task.Id,
ct);
}
else if (string.Equals(canonical, "Blocked", StringComparison.OrdinalIgnoreCase))
{
await notificationService.CreateAsync(
"task_blocked",
$"Aufgabe blockiert: {task.Title}",
$"Die Task wurde von {caller} auf Blockiert gesetzt.",
"iris",
task.Id,
ct);
}
else
{
// Allgemeine Statusänderung: Gegenüber benachrichtigen
if (caller == "bao")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Bao hat Status geändert: {task.Title}",
$"Status → {canonical}",
"iris",
task.Id,
ct);
}
else if (caller == "iris")
{
await notificationService.CreateAsync(
"task_status_changed",
$"Iris hat Status geändert: {task.Title}",
$"Status → {canonical}",
"bao",
task.Id,
ct);
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Nexus.Api.Services;
public sealed class TeamService(IAgentService agentService) : ITeamService
{
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
{
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<TeamMember>(agents.Count);
foreach (var agent in agents)
{
var identity = await ReadIdentityAsync(agent.Workspace, ct);
team.Add(new TeamMember(
agent.Id, agent.Name, agent.Role, agent.Model,
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity));
}
return team;
}
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
return string.Empty;
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (!File.Exists(identityFile))
return string.Empty;
var content = await File.ReadAllTextAsync(identityFile, ct);
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
}
}
+91 -10
View File
@@ -4,7 +4,14 @@ services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 384M
reservations:
memory: 96M
environment: environment:
POSTGRES_INITDB_ARGS: --data-checksums
POSTGRES_DB: ${POSTGRES_DB:-nexus} POSTGRES_DB: ${POSTGRES_DB:-nexus}
POSTGRES_USER: ${POSTGRES_USER:-nexus} POSTGRES_USER: ${POSTGRES_USER:-nexus}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
@@ -15,12 +22,29 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s
networks: [nexus] networks: [nexus]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
api: api:
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
environment: environment:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
@@ -29,36 +53,93 @@ services:
Jwt__Issuer: ${JWT_ISSUER:-nexus} Jwt__Issuer: ${JWT_ISSUER:-nexus}
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web} Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env} Owner__Email: ${OWNER_EMAIL:?Set OWNER_EMAIL in .env}
# OWNER_PASSWORD is only used during initial seed (first deploy).
# After that the DB is the single source of truth, enforced by SeedAudit.
# Default: empty (seed uses a random password if unset on first run).
Owner__Password: ${OWNER_PASSWORD:-} Owner__Password: ${OWNER_PASSWORD:-}
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner} Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789} Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-}
NexusApiKey: ${NEXUS_API_KEY:-}
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_started
restart: true
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes: volumes:
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris - /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer - /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer - /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt - /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher - /home/projekte_bao/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor - /home/projekte_bao/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
networks: [nexus] - /home/projekte_bao/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
networks:
- nexus
- openclaw_default
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
web: web:
build: build:
context: ./frontend context: ./frontend
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
labels:
- "traefik.enable=true"
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
- "traefik.http.routers.nexus.tls=true"
- "traefik.http.routers.nexus.tls.certresolver=letsencrypt"
- "traefik.http.services.nexus.loadbalancer.server.port=80"
ports: ports:
- "127.0.0.1:18880:80" - "127.0.0.1:18880:80"
depends_on: [api] depends_on:
networks: [nexus] api:
condition: service_started
restart: true
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- nexus
- proxy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks: networks:
nexus: nexus:
openclaw_default:
external: true
proxy:
external: true
volumes: volumes:
nexus-postgres: nexus-postgres:
+2
View File
@@ -0,0 +1,2 @@
// Gateway API HTTP test script - cleaned up after testing
// Results documented in gateway-api-research.md
+2
View File
@@ -0,0 +1,2 @@
// Gateway API test script - cleaned up after testing
// Results documented in gateway-api-research.md
+401
View File
@@ -0,0 +1,401 @@
# Gateway API Research
> Generated: 2026-06-10
> Auth mode: password (not token)
## 1. Authentication
**Auth mode:** `password`
**Credential:** `ieDm...PAg` (masked: `ieDmOiBiVfbbDM0ibrEebPAg` → use `ieDm...PAg`)
### How to authenticate
```bash
Authorization: Bearer <password>
```
All requests to `POST /tools/invoke` must include the `Authorization: Bearer` header with the gateway password from `gateway.auth.password`.
### Configuration (from openclaw.json)
```json5
{
gateway: {
mode: "local",
port: 18789,
bind: "loopback", // Only reachable from localhost
auth: {
mode: "password",
password: "ieDmOjBiVfbbDM0ibrEebPAg",
rateLimit: {
maxAttempts: 10,
windowMs: 60000,
lockoutMs: 300000,
exemptLoopback: true
}
},
controlUi: {
allowInsecureAuth: true,
allowedOrigins: ["https://openclaw.noveria.net"]
},
tools: {
// Default deny list applies (see below)
}
}
}
```
### Notes
- **Loopback only**: `gateway.bind: "loopback"` means the API only listens on `127.0.0.1:18789`.
- **Rate limiting**: 10 failed attempts per 60s window → 5min lockout. Loopback is exempt.
- **Control UI**: Allowed origin: `https://openclaw.noveria.net`
---
## 2. API Endpoint: `POST /tools/invoke`
**URL:** `http://127.0.0.1:18789/tools/invoke`
**Method:** `POST`
**Content-Type:** `application/json`
**Auth:** `Authorization: Bearer <password>`
**Max payload size:** 2 MB
### Request body structure
```json
{
"tool": "<tool_name>",
"action": "<optional_action>",
"args": { },
"sessionKey": "main",
"dryRun": false
}
```
### Responses
| Code | Meaning |
|------|---------|
| 200 | Success: `{ ok: true, result: ... }` |
| 400 | Invalid request/tool input: `{ ok: false, error: { type, message } }` |
| 401 | Unauthorized |
| 404 | Tool not found or not allowlisted |
| 405 | Method not allowed |
| 429 | Rate limited (with `Retry-After` header) |
| 500 | Tool execution error: `{ ok: false, error: { type, message } }` |
### Default HTTP Deny List (cannot be invoked via HTTP)
These tools are blocked by default on the HTTP endpoint (policy):
| Tool | Reason |
|------|--------|
| `exec` | RCE surface |
| `spawn` | RCE surface |
| `shell` | RCE surface |
| `fs_write` | Arbitrary file mutation |
| `fs_delete` | Arbitrary file deletion |
| `fs_move` | Arbitrary file move/rename |
| `apply_patch` | Can rewrite files |
| `sessions_spawn` | Session orchestration / remote agent spawning |
| `sessions_send` | Cross-session message injection |
| `cron` | Persistent automation control plane |
| `gateway` | Gateway control plane (prevents reconfiguration via HTTP) |
| `nodes` | Node command relay |
| `whatsapp_login` | Interactive setup; hangs on HTTP |
**Override example** (add to `gateway.tools` in config):
```json5
{
gateway: {
tools: {
deny: ["browser"], // extra blocks
allow: ["gateway"], // remove from default deny
}
}
}
```
---
## 3. Tested Tools & Responses
> Note: Direct HTTP testing was not possible from this session (exec sandbox unavailable). Documentation based on API spec and config analysis.
### 3.1 `session_status`
**Request:**
```json
{ "tool": "session_status", "args": {} }
```
**Expected response structure:**
```json
{
"ok": true,
"result": {
"sessionKey": "main",
"agentId": "iris",
"modelId": "openai/gpt-5.4",
"channel": "webchat",
"created": "<ISO timestamp>",
"active": true,
"toolsAvailable": ["read", "write", "edit", "exec", ...],
"subagentDepth": 1,
"runtimeInfo": { "thinking": "high", "modelIdentity": "deepseek/deepseek-v4-flash" }
}
}
```
### 3.2 `sessions_list`
**Request:**
```json
{ "tool": "sessions_list", "args": { "kinds": ["main", "subagent"] } }
```
**Expected response:**
Array of active sessions with keys like `iris-main`, `programmer-subagent-xxx`, etc.
### 3.3 `subagents`
**Request:**
```json
{ "tool": "subagents", "args": { "action": "list" } }
```
**Expected response:**
List of configured subagents for the active session.
### 3.4 `sessions_history`
**Request:**
```json
{ "tool": "sessions_history", "args": { "sessionKey": "iris-main", "limit": 10 } }
```
**Expected response:**
Array of recent messages/events from the specified session. Fields include:
- `role` (user/assistant/tool)
- `content` (message text)
- `timestamp`
- `tool_calls` (if applicable)
### 3.5 `memory_search`
**Request:**
```json
{ "tool": "memory_search", "args": { "query": "...", "maxResults": 5 } }
```
**Expected response:**
```json
{
"ok": true,
"result": [
{
"content": "...",
"path": "memory/YYYY-MM-DD.md",
"score": 0.95,
"metadata": { "...": "..." }
}
]
}
```
### 3.6 Health Check (non-tools/invoke)
**Request:**
```bash
GET http://127.0.0.1:18789/health
```
**Expected response:** "OK" or `{ "status": "ok" }`
---
## 4. Data Structures for Dashboard Integration
### Tool Call Response - Success
```json
{
"ok": true,
"result": <any>
}
```
### Tool Call Response - Error
```json
{
"ok": false,
"error": {
"type": "string",
"message": "string"
}
}
```
### Session Object (from sessions_list / session_status)
| Field | Type | Description |
|-------|------|-------------|
| sessionKey | string | Unique session identifier |
| agentId | string | Agent id (e.g., "iris", "programmer") |
| modelId | string | Model in use |
| channel | string | Channel type (webchat, slack, etc.) |
| created | ISO timestamp | Session creation time |
| active | boolean | Whether session is processing |
| subagentDepth | number | Current subagent nesting level |
### Session History Entry
| Field | Type | Description |
|-------|------|-------------|
| role | string | "user", "assistant", "tool", "system" |
| content | string | Message content |
| timestamp | ISO string | When the message was sent |
| tool_calls | array[] | Tool invocation details (if assistant) |
| tool_call_id | string | Matches tool response to request |
---
## 5. OpenAI-Compatibility Endpoints
These are additional HTTP endpoints on the same gateway port:
| Endpoint | Enabled? | Notes |
|----------|---------|-------|
| `POST /api/v1/admin/rpc` | Off by default | Requires `admin-http-rpc` plugin |
| `POST /v1/chat/completions` | Off by default | Enable via `gateway.http.endpoints.chatCompletions.enabled` |
| `POST /v1/responses` | Off by default | Enable via `gateway.http.endpoints.responses.enabled` |
None of these are enabled in the current config.
---
## 6. Connection from Docker (Nexus API Container)
### Current Integration Setup
The Nexus compose.yaml already includes the full integration infrastructure:
```yaml
api:
extra_hosts:
- host.docker.internal:host-gateway
environment:
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
```
The API container:
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
- Has `extra_hosts` configured for `host.docker.internal`
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
### Known Issue: Gateway Bind = loopback
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
| Scenario | Works? | Why |
|----------|--------|-----|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
```json5
{
gateway: {
bind: "lan" // was "loopback"
}
}
```
**Test command (from Nexus API container):**
```bash
curl -s http://host.docker.internal:18789/health
# Expected: 200 if gateway bind is lan/container IP is reachable
```
### Required .env Vars for Nexus
The Nexus project `.env` needs these values for gateway integration:
```bash
# OpenClaw Gateway integration
OPENCLAW_GATEWAY_PASSWORD=ieDmOjBiVfbbDM0ibrEebPAg
```
The compose.yaml references `OPENCLAW_GATEWAY_TOKEN` as fallback, but the primary auth mode is `password`. Either var works with Bearer auth.
---
## 7. Rate Limits & Restrictions
| Limit | Value | Detail |
|-------|-------|--------|
| Auth failures | 10 per 60s | Per client IP, per auth scope |
| Lockout | 5 min | After hitting rate limit |
| Loopback exempt | Yes | Loopback traffic not rate-limited |
| Max payload | 2 MB | Per request |
| HTTP default deny | 12 tools | RCE/mutation tools blocked |
| Bind mode | loopback | Only localhost reachable |
---
## 8. Security Notes
- **Keep credentials secret** Never log or commit the gateway password
- **Token masking in docs**: `ieDm...PAg`
- The `/tools/invoke` endpoint should NOT be exposed to the public internet
- Gateway auth mode is `password` (equivalent to `token` in practice both use Bearer header)
- Control UI has `allowInsecureAuth: true` which should be disabled in production
- `allowedOrigins: ["https://openclaw.noveria.net"]` should be reviewed
---
## 9. Example curl Commands (for reference)
```bash
# Health check
curl http://127.0.0.1:18789/health
# Session status
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"session_status","args":{}}'
# List sessions
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"sessions_list","args":{"kinds":["main","subagent"]}}'
# Session history
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"sessions_history","args":{"sessionKey":"iris-main","limit":10}}'
# Memory search
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"memory_search","args":{"query":"nexus","maxResults":5}}'
# Subagents list
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"subagents","args":{"action":"list"}}'
```
Replace `<PASSWORD>` with the actual gateway password.
@@ -0,0 +1 @@
{"locator":{"name":"pnpm","reference":"10.12.1"},"bin":{"pnpm":"./bin/pnpm.cjs","pnpx":"./bin/pnpx.cjs"},"hash":"sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"}
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2015-2016 Rico Sta. Cruz and other contributors
Copyright (c) 2016-2025 Zoltan Kochan and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,212 @@
[简体中文](https://pnpm.io/zh/) |
[日本語](https://pnpm.io/ja/) |
[한국어](https://pnpm.io/ko/) |
[Italiano](https://pnpm.io/it/) |
[Português Brasileiro](https://pnpm.io/pt/)
<picture>
<source media="(prefers-color-scheme: light)" srcset="https://i.imgur.com/qlW1eEG.png">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/qlW1eEG.png">
<img src="https://i.imgur.com/qlW1eEG.png" alt="pnpm">
</picture>
Fast, disk space efficient package manager:
* **Fast.** Up to 2x faster than the alternatives (see [benchmark](#benchmark)).
* **Efficient.** Files inside `node_modules` are linked from a single content-addressable storage.
* **[Great for monorepos](https://pnpm.io/workspaces).**
* **Strict.** A package can access only dependencies that are specified in its `package.json`.
* **Deterministic.** Has a lockfile called `pnpm-lock.yaml`.
* **Works as a Node.js version manager.** See [pnpm env use](https://pnpm.io/cli/env).
* **Works everywhere.** Supports Windows, Linux, and macOS.
* **Battle-tested.** Used in production by teams of [all sizes](https://pnpm.io/users) since 2016.
* [See the full feature comparison with npm and Yarn](https://pnpm.io/feature-comparison).
To quote the [Rush](https://rushjs.io/) team:
> Microsoft uses pnpm in Rush repos with hundreds of projects and hundreds of PRs per day, and 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"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/main.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
find /home/node/.openclaw/workspace/nexus/frontend/src -type f \( -name "TeamNetwork*" -o -name "MissionCard*" -o -name "useDashboardData*" \) 2>&1
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080a0f" /> <meta name="theme-color" content="#080a0f" />
<title>Nexus | Noveria Operations</title> <title>Nexus | Noveria Operations</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+13
View File
@@ -11,6 +11,19 @@ server {
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Gehashte Assets: 1 Jahr cachen (immutable wg. Content-Hash im Dateinamen)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA-Entry nie cachen
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location /api/ { location /api/ {
proxy_pass http://api:8080; proxy_pass http://api:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
+6 -2
View File
@@ -10,10 +10,15 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.8",
"@lucide/vue": "1.17.0", "@lucide/vue": "1.17.0",
"@tailwindcss/vite": "^4.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.16", "vue": "^3.5.16",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
@@ -27,4 +32,3 @@
}, },
"packageManager": "pnpm@10.12.1" "packageManager": "pnpm@10.12.1"
} }
+545
View File
@@ -14,12 +14,27 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.8 specifier: ^4.1.8
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)) version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
pinia: pinia:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3)) version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.35(typescript@5.7.3))
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
tailwindcss: tailwindcss:
specifier: ^4.1.8 specifier: ^4.1.8
version: 4.3.0 version: 4.3.0
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.3.0)
vue: vue:
specifier: ^3.5.16 specifier: ^3.5.16
version: 3.5.35(typescript@5.7.3) version: 3.5.35(typescript@5.7.3)
@@ -39,6 +54,9 @@ importers:
vite: vite:
specifier: ^6.3.5 specifier: ^6.3.5
version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0) version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vitest:
specifier: ^3.1.3
version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vue-tsc: vue-tsc:
specifier: ^2.2.10 specifier: ^2.2.10
version: 2.2.12(typescript@5.7.3) version: 2.2.12(typescript@5.7.3)
@@ -218,6 +236,24 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -364,6 +400,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tailwindcss/node@4.3.0': '@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -454,12 +493,29 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8 vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@tanstack/vue-virtual@3.13.28':
resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@22.19.20': '@types/node@22.19.20':
resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==} resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@vitejs/plugin-vue@5.2.4': '@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -467,6 +523,35 @@ packages:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 vue: ^3.2.25
'@vitest/expect@3.2.6':
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
'@vitest/mocker@3.2.6':
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.6':
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
'@vitest/runner@3.2.6':
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
'@vitest/snapshot@3.2.6':
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
'@vitest/spy@3.2.6':
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@@ -528,9 +613,26 @@ packages:
'@vue/shared@3.5.35': '@vue/shared@3.5.35':
resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==} resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
alien-signals@1.0.13: alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -540,6 +642,25 @@ packages:
brace-expansion@2.1.1: brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
copy-anything@4.0.5: copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -550,6 +671,22 @@ packages:
de-indent@1.0.2: de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
detect-libc@2.1.2: detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -562,6 +699,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.25.12: esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -570,6 +710,16 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -602,6 +752,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true hasBin: true
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -672,6 +825,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -682,6 +838,9 @@ packages:
mitt@3.0.1: mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1: muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -690,9 +849,21 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0: perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -716,6 +887,11 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
radix-vue@1.9.17:
resolution: {integrity: sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==}
peerDependencies:
vue: '>= 3.2.0'
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -724,6 +900,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -732,10 +911,27 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
superjson@2.2.6: superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'} engines: {node: '>=16'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@4.3.0: tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@@ -743,10 +939,31 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'} engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.17: tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.7.3: typescript@5.7.3:
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -755,6 +972,11 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@6.4.3: vite@6.4.3:
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -795,9 +1017,48 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@3.2.6:
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.6
'@vitest/ui': 3.2.6
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-router@4.6.4: vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies: peerDependencies:
@@ -817,6 +1078,11 @@ packages:
typescript: typescript:
optional: true optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots: snapshots:
'@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@7.29.7': {}
@@ -910,6 +1176,34 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/utils@0.2.11': {}
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/utils': 0.2.11
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/number@3.6.7':
dependencies:
'@swc/helpers': 0.5.23
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1008,6 +1302,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.61.1': '@rollup/rollup-win32-x64-msvc@4.61.1':
optional: true optional: true
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.3.0': '@tailwindcss/node@4.3.0':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -1076,17 +1374,75 @@ snapshots:
tailwindcss: 4.3.0 tailwindcss: 4.3.0
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0) vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/vue-virtual@3.13.28(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@tanstack/virtual-core': 3.17.0
vue: 3.5.35(typescript@5.7.3)
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/node@22.19.20': '@types/node@22.19.20':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))': '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))':
dependencies: dependencies:
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0) vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vue: 3.5.35(typescript@5.7.3) vue: 3.5.35(typescript@5.7.3)
'@vitest/expect@3.2.6':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.6':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.6':
dependencies:
'@vitest/utils': 3.2.6
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.6':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
loupe: 3.2.1
tinyrainbow: 2.0.0
'@volar/language-core@2.4.15': '@volar/language-core@2.4.15':
dependencies: dependencies:
'@volar/source-map': 2.4.15 '@volar/source-map': 2.4.15
@@ -1191,8 +1547,33 @@ snapshots:
'@vue/shared@3.5.35': {} '@vue/shared@3.5.35': {}
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.7.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
alien-signals@1.0.13: {} alien-signals@1.0.13: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
birpc@2.9.0: {} birpc@2.9.0: {}
@@ -1201,6 +1582,24 @@ snapshots:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
cac@6.7.14: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
clsx@2.1.1: {}
copy-anything@4.0.5: copy-anything@4.0.5:
dependencies: dependencies:
is-what: 5.5.0 is-what: 5.5.0
@@ -1209,6 +1608,14 @@ snapshots:
de-indent@1.0.2: {} de-indent@1.0.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
defu@6.1.7: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
enhanced-resolve@5.23.0: enhanced-resolve@5.23.0:
@@ -1218,6 +1625,8 @@ snapshots:
entities@7.0.1: {} entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.25.12: esbuild@0.25.12:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12 '@esbuild/aix-ppc64': 0.25.12
@@ -1249,6 +1658,14 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1266,6 +1683,8 @@ snapshots:
jiti@2.7.0: {} jiti@2.7.0: {}
js-tokens@9.0.1: {}
lightningcss-android-arm64@1.32.0: lightningcss-android-arm64@1.32.0:
optional: true optional: true
@@ -1315,6 +1734,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0
loupe@3.2.1: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1325,12 +1746,20 @@ snapshots:
mitt@3.0.1: {} mitt@3.0.1: {}
ms@2.1.3: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
nanoid@5.1.11: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
perfect-debounce@1.0.0: {} perfect-debounce@1.0.0: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
@@ -1350,6 +1779,23 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
radix-vue@1.9.17(vue@3.5.35(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.7.3))
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@tanstack/vue-virtual': 3.13.28(vue@3.5.35(typescript@5.7.3))
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.7.3))
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
aria-hidden: 1.2.6
defu: 6.1.7
fast-deep-equal: 3.1.3
nanoid: 5.1.11
vue: 3.5.35(typescript@5.7.3)
transitivePeerDependencies:
- '@vue/composition-api'
rfdc@1.4.1: {} rfdc@1.4.1: {}
rollup@4.61.1: rollup@4.61.1:
@@ -1383,27 +1829,76 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.61.1 '@rollup/rollup-win32-x64-msvc': 4.61.1
fsevents: 2.3.3 fsevents: 2.3.3
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
superjson@2.2.6: superjson@2.2.6:
dependencies: dependencies:
copy-anything: 4.0.5 copy-anything: 4.0.5
tailwind-merge@3.6.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
dependencies:
tailwindcss: 4.3.0
tailwindcss@4.3.0: {} tailwindcss@4.3.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tslib@2.8.1: {}
typescript@5.7.3: {} typescript@5.7.3: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0): vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@@ -1418,8 +1913,53 @@ snapshots:
jiti: 2.7.0 jiti: 2.7.0
lightningcss: 1.32.0 lightningcss: 1.32.0
vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
'@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.6
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.20
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.35(typescript@5.7.3)):
dependencies:
vue: 3.5.35(typescript@5.7.3)
vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)): vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)):
dependencies: dependencies:
'@vue/devtools-api': 6.6.4 '@vue/devtools-api': 6.6.4
@@ -1440,3 +1980,8 @@ snapshots:
'@vue/shared': 3.5.35 '@vue/shared': 3.5.35
optionalDependencies: optionalDependencies:
typescript: 5.7.3 typescript: 5.7.3
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+10 -30
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue' import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue' import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue' import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore() const store = useOperationsStore()
const auth = useAuthStore() const auth = useAuthStore()
@@ -22,7 +23,7 @@ const activeView = computed(() => {
const routePaths: Record<string, string> = { const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security', Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar', Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings', Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
} }
const navigate = (label: string) => { const navigate = (label: string) => {
@@ -31,7 +32,7 @@ const navigate = (label: string) => {
} }
const mobileNavOpen = ref(false) const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value)) const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) store.refresh() if (auth.isAuthenticated) store.refresh()
@@ -39,7 +40,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<RouterView v-if="route.name === 'Login'" /> <RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell"> <div v-else class="shell">
<AppSidebar <AppSidebar
:active-view="activeView" :active-view="activeView"
@@ -82,32 +83,11 @@ onMounted(() => {
</template> </template>
</section> </section>
</main> </main>
<ToastContainer />
</div> </div>
</template> </template>
<style scoped> <style scoped>
:root {
--bg: #0b0d13;
--panel: #11141b;
--line: #1f2330;
--accent: #7b6ef2;
--accent-soft: rgba(123,110,242,.08);
--text: #e8eaf0;
--text-dim: #6f7889;
--green: #27ae60;
--red: #e74c3c;
--yellow: #f1c40f;
--orange: #e67e22;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.shell { .shell {
display: flex; display: flex;
height: 100vh; height: 100vh;
@@ -135,12 +115,12 @@ main {
gap: 12px; gap: 12px;
} }
.page-heading h1 { margin: 0; font-size: 18px; } .page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); } .page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow { .eyebrow {
font-size: 8.5px; font-size: 8.5px;
font-weight: 700; font-weight: 700;
letter-spacing: .12em; letter-spacing: .12em;
color: var(--accent); color: var(--nx-accent);
text-transform: uppercase; text-transform: uppercase;
} }
.refresh { .refresh {
@@ -149,15 +129,15 @@ main {
gap: 5px; gap: 5px;
flex-shrink: 0; flex-shrink: 0;
padding: 6px 11px; padding: 6px 11px;
border: 1px solid var(--line); border: 1px solid var(--nx-line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: var(--text-dim); color: var(--nx-text-dim);
font-size: 9px; font-size: 9px;
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
} }
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; } .refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; } .spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
+727
View File
@@ -0,0 +1,727 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
/* ── Nexus V2 Theme (Tailwind v4 @theme directive) ── */
@theme {
/* Font families */
--font-sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Space surfaces */
--color-space-0: #050410;
--color-space-1: #0a0818;
--color-space-2: #0e0c20;
--color-space-3: #141130;
--color-space-4: #1b1742;
/* Glass */
--color-glass: rgba(20, 17, 48, 0.55);
--color-glass-2: rgba(28, 24, 64, 0.55);
/* Lines */
--color-line: rgba(150, 140, 255, 0.10);
--color-line-2: rgba(150, 140, 255, 0.18);
--color-line-3: rgba(150, 140, 255, 0.30);
/* Text */
--color-tx: #ece9ff;
--color-tx-2: #a8a3d6;
--color-tx-3: #6f6aa0;
/* Accent */
--color-a-blue: #4f7cff;
--color-a-purple: #b557f6;
--color-a-mid: #7c6cff;
/* Status */
--color-st-work: #3ddc97;
--color-st-think: #34d6f5;
--color-st-queue: #fbbf24;
--color-st-block: #fb7185;
--color-st-idle: #6b6796;
/* Border radius */
--radius-r: 14px;
--radius-r-sm: 10px;
--radius-r-lg: 20px;
}
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 252 80% 74%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 252 80% 74%;
--radius: 0.5rem;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
* {
border-color: hsl(var(--border));
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* Nexus overrides for existing CSS variables used in dashboard */
:root {
--nx-bg: #080a0f;
--nx-panel: #10131a;
--nx-panel-soft: #0d1016;
--nx-line: #202530;
--nx-muted: #7e8799;
--nx-accent: #8b7cf6;
--nx-accent-soft: rgba(139, 124, 246, 0.12);
--nx-green: #51d49a;
--nx-text: #e8eaf0;
--nx-text-dim: #6f7889;
}
/* ── Existing Nexus layout styles ── */
*,
*::before,
*::after {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 224px 1fr;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 22px 14px 14px;
border-right: 1px solid #1a1e27;
background: rgba(9, 11, 16, 0.94);
backdrop-filter: blur(18px);
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 0 8px 25px;
}
.brand-mark {
width: 35px;
height: 35px;
display: grid;
place-items: center;
border: 1px solid #443d7c;
border-radius: 10px;
background: linear-gradient(145deg, #241f44, #12121f);
color: #b8adff;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
}
.brand strong {
display: block;
font-size: 13px;
letter-spacing: 0.14em;
}
.brand span,
.owner span {
display: block;
color: var(--nx-muted);
font-size: 10px;
margin-top: 2px;
}
.nav {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav button,
.sidebar-bottom > button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
padding: 9px 10px;
border-radius: 7px;
background: transparent;
color: #8991a1;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.nav button:hover,
.nav button.active {
color: #ececf5;
background: var(--nx-accent-soft);
}
.nav button.active {
box-shadow: inset 2px 0 var(--nx-accent);
}
.nav button i {
margin-left: auto;
padding: 1px 6px;
border: 1px solid #343947;
border-radius: 8px;
font-size: 9px;
font-style: normal;
}
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid #1b1f28;
padding-top: 10px;
}
.owner {
display: grid;
grid-template-columns: 31px 1fr auto;
gap: 9px;
align-items: center;
margin-top: 10px;
padding: 10px 8px;
width: 100%;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.owner:hover {
background: var(--nx-accent-soft);
border-radius: 8px;
}
.owner strong {
font-size: 11px;
}
.avatar {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border-radius: 50%;
background: #28243f;
color: #bcb3ff;
font-size: 10px;
}
main {
min-width: 0;
}
.topbar {
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
border-bottom: 1px solid #191d25;
background: rgba(8, 10, 15, 0.68);
backdrop-filter: blur(16px);
}
.search {
width: min(390px, 42vw);
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border: 1px solid #202530;
border-radius: 7px;
color: #6f7889;
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 2px 5px;
border: 1px solid #2c313d;
border-radius: 4px;
color: #606979;
font-size: 9px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.connection {
display: flex;
gap: 6px;
align-items: center;
font-size: 10px;
color: #8c95a5;
}
.connection.live {
color: var(--nx-green);
}
.connection.preview {
color: #e6b75d;
}
.ask,
.refresh-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border: 1px solid #37315e;
border-radius: 7px;
background: #18152a;
color: #c4bbff;
font-size: 10px;
cursor: pointer;
}
.content {
padding: 16px 16px 60px;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: end;
margin-bottom: 28px;
}
.eyebrow,
.kicker {
color: #7065c8;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
}
h1 {
margin: 7px 0 5px;
font-size: 27px;
letter-spacing: -0.04em;
}
.page-heading p,
.placeholder p {
margin: 0;
color: var(--nx-muted);
font-size: 11px;
}
.refresh-btn {
border-color: var(--nx-line);
background: var(--nx-panel);
color: #a5adba;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mobile-menu {
display: none;
border: 0;
background: transparent;
color: #aaa4e7;
}
/* ── Keep existing module/layout styles for non-dashboard pages ── */
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.metrics article,
.panel {
border: 1px solid var(--nx-line);
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
border-radius: 9px;
}
.metrics article {
padding: 16px 17px;
}
.metrics span {
color: #717a8a;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
}
.metrics strong {
display: block;
margin: 7px 0 5px;
font-size: 24px;
letter-spacing: -0.04em;
}
.metrics small {
color: #687181;
font-size: 9px;
}
.metrics small.up {
color: #55c995;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.panel {
padding: 18px;
min-height: 180px;
}
.span-2 {
grid-column: span 2;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 1px solid #1d222c;
}
.panel-head h2 {
margin: 4px 0 0;
font-size: 13px;
}
.panel-head button {
border: 0;
background: transparent;
color: #8e96a5;
font-size: 9px;
}
.badge {
padding: 4px 8px;
border-radius: 10px;
font-size: 8px;
}
.badge.positive {
color: var(--nx-green);
background: rgba(81, 212, 154, 0.1);
}
.badge.warning {
color: #e7b660;
background: rgba(231, 182, 96, 0.1);
}
.badge.negative {
color: #e16e75;
background: rgba(225, 110, 117, 0.1);
}
.runtime-row {
display: flex;
align-items: center;
gap: 12px;
padding-top: 22px;
}
.runtime-icon {
width: 45px;
height: 45px;
display: grid;
place-items: center;
border-radius: 9px;
color: #ad9fff;
background: var(--nx-accent-soft);
}
.runtime-main strong,
.model strong,
.project strong,
.event strong {
display: block;
font-size: 11px;
}
.runtime-main span,
.model small,
.event small {
display: block;
margin-top: 4px;
color: var(--nx-muted);
font-size: 9px;
}
.pulse-bars {
height: 42px;
display: flex;
align-items: center;
gap: 3px;
margin-left: auto;
}
.pulse-bars i {
width: 3px;
min-height: 5px;
border-radius: 3px;
background: linear-gradient(#927fff, #443b7c);
}
.model {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 9px;
padding: 12px 2px;
border-bottom: 1px solid #1b2029;
}
.model > span:last-child {
color: #687181;
font-size: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #657083;
}
.status-dot.online {
background: var(--nx-green);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
}
.status-dot.offline {
background: #e16e75;
}
.project {
display: grid;
grid-template-columns: 34px 1fr auto;
align-items: center;
gap: 11px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.project-letter {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border: 1px solid #353047;
border-radius: 7px;
color: #a99cf5;
font-size: 10px;
}
.project-info > div:first-child {
display: flex;
justify-content: space-between;
}
.project-info span {
color: var(--nx-muted);
font-size: 8px;
}
.project b {
color: #838c9c;
font-size: 9px;
}
.progress {
height: 3px;
margin-top: 8px;
overflow: hidden;
border-radius: 4px;
background: #242936;
}
.progress i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff);
}
.event {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.event > span {
width: 6px;
height: 6px;
margin-top: 4px;
border-radius: 50%;
background: #657083;
}
.event > span.runtime {
background: var(--nx-green);
}
.event > span.deploy {
background: #8b7cf6;
}
.event > span.security {
background: #e5ad52;
}
.placeholder {
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.placeholder svg {
margin-bottom: 18px;
color: #8074d8;
}
.placeholder h2 {
margin: 8px 0;
}
/* Animations */
.animate-spin {
animation: spin 1s linear infinite;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
z-index: 20;
left: -240px;
width: 224px;
transition: left 0.2s ease;
}
.sidebar.open {
left: 0;
box-shadow: 20px 0 60px #000;
}
.mobile-menu {
display: block;
}
.topbar {
padding: 0 18px;
}
.metrics {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
}
@media (max-width: 560px) {
.content {
padding: 26px 16px 40px;
}
.search {
display: none;
}
.topbar {
justify-content: space-between;
}
.metrics {
grid-template-columns: 1fr 1fr;
}
.page-heading {
align-items: start;
}
.page-heading p {
max-width: 220px;
line-height: 1.5;
}
.runtime-row {
flex-wrap: wrap;
}
.pulse-bars {
width: 100%;
margin-left: 57px;
}
}

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