Compare commits

..

31 Commits

Author SHA1 Message Date
devops 4633e570e8 fix(agents): finish sanitized config migration — GatewayClient also reads agents-sanitized.json
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
The previous commit f4bee44 updated AgentService.cs but missed
OpenClawGatewayClient.cs which also reads agent IDs and model config
from the raw openclaw.json. Both LoadAgentIdsFromConfig() and
GetAvailableModels() now default to /etc/nexus/agents-sanitized.json.

This completes the P4 migration: Nexus no longer needs read access to
openclaw.json for any code path.
2026-07-12 14:09:31 +02:00
devops f4bee442db feat: sanitized agent config — Nexus no longer reads secrets from openclaw.json
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
- Mount agents-sanitized.json (agents key only, no secrets) instead of full openclaw.json
- Update AgentService default path from /home/node/.openclaw/openclaw.json to /etc/nexus/agents-sanitized.json
- Add AgentConfigPath env var to compose for explicit path configuration
- Generate sanitized file in deploy-nexus.sh before each deploy using Python extraction
- Add agents-sanitized.json to .gitignore

Eliminates the fragile ACL on openclaw.json (uid 1654) that causes 500 errors
on the Board endpoint when lost.
2026-07-12 13:08:43 +02:00
devops 7f1d5b706d fix(deploy): harden deploy script — remove force-recreate, add Auth-Smoke
Remove --force-recreate from docker compose up so Postgres persists
across deploys unless its image or config actually changed.

Add Auth Smoke checks before declaring deploy success:
- SeedAudit owner_created key must exist in DB
- Owner login flow must return 401 (invalid_credentials) — proving
  auth pipeline is functional and DB is reachable

Deploy fails (exit 1) if any smoke check fails (fail-closed).
46 lines changed in deploy-nexus.sh.
2026-07-11 17:03:49 +02:00
devops c3e0e6913b test: add EnsureDatabaseAsync SeedAudit regression tests for owner password re-seed guard
Adds 4 tests in EnsureDatabaseSeedAuditTests:
- EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash
- EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword
- EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner
- EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner

Verifies that once SeedAudit contains owner_created, subsequent
EnsureDatabaseAsync calls (simulating pod restarts) do NOT reset
the owner's password hash. Covers the owner_created SeedAudit guard.
2026-07-11 16:08:53 +02:00
devops b82d88563a feat(ui): consolidate shared UI patterns into reusable components
- Token cleanup: replace raw hex colors (#c084fc, #60a5fa, #6ee7b7, #fdba74)
  in BoardCard.vue with nexus-tokens.css CSS variables (--clr-iris, --clr-bao,
  --clr-agent, --clr-review)
- New composable: useFormatDate (formatDate, relativeTime, toDateInputValue,
  minutesSince, hoursSince) — extracts duplicated date helpers from
  TaskBoardView and BoardCard
- New composable: useConfirm — reusable confirmation dialog logic
  (open/close, error/success state, Escape binding, body scroll lock)
- New component: StatusPill — unified state pill for backlog/progress/
  review/blocked/done, replacing inline .detail-state-pill classes
- New component: SkeletonLoader — loading placeholder with shimmer
  animation (card/text/circle variants)
- TaskBoardView: imports StatusPill & useFormatDate, removes 35+ lines
  of duplicated helpers
- ui/index.ts: exports new StatusPill & SkeletonLoader
- Build verified: pnpm build green (vue-tsc --noEmit + vite build pass)
2026-07-11 14:11:02 +02:00
devops 7de12c6541 fix(ui): eliminate remaining hardcoded hex colors from TaskBoardView
Replace all #hex literals with nexus-token CSS variables:
- iris badges/dots/assignee/banner → var(--clr-iris)
- bao dots/assignee/meta-chip → var(--clr-bao)
- agent assignee → var(--clr-agent)
- stale banners/badges/dots/counts → var(--clr-stale)
- review btn-changes/state pill → var(--clr-review)
- other dots/column ring → var(--clr-other)
- expected badge → var(--a-purple)
- success flash/state pill → var(--pill-progress)
- backlog state pill → var(--pill-backlog)
- all #fff references → var(--tx)

Add --pill-* state pill color tokens to nexus-tokens.css.
Fix #fff in nexus-btn-gradient in tokens.css.

Task: ea7c2063
Ref: 50d95fa (V2 Design-Fundament)
2026-07-11 13:06:33 +02:00
devops b093b0c4b5 restore: reapply 50d95fa UI foundation clobbered by sync in 2ee1fe9
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
My rsync --delete based deploy sync overwrote the working tree with a stale
local copy and accidentally reverted commit 50d95fa (V2 Design-Fundament,
task ea7c2063) — new ui/ components deleted, token/color work in
dashboard/v2 and nexus-tokens.css rolled back. This restores all 17 files
exactly from 50d95fa; no overlap with the modal rework files (TaskBoardView,
tasks store, BoardCard), which stay as committed in 2ee1fe9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:13:04 +02:00
devops 2ee1fe973f feat(board): rework quick-peek modal (Linear-style) + board toolbar
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Modal redesign — compact summary instead of two long cards:
- Topbar: breadcrumb + state pill + copyable task-id/link chip; review
  actions (approve / request changes) live in the modal header too
- Main column: title, meta chips (ball, created, last update), compact
  description, then square summary tiles (done/total with progress,
  active, open, blocked) + per-agent pills — details stay in Vollansicht
- Right sidebar: properties as slim Linear-style rows (status, priority,
  assignee, due, source) with hover-reveal controls; activity moved next
  to properties as a compact timeline with a new comment composer
  (POST tasks/{id}/activity via new store action postTaskActivity)
- Unified button system (32px: primary/ghost/approve/changes/icon)
- flattenBoard includes nested children again so quick-peek works for
  child rows inside master cards

Board (issue-tracker basics):
- Toolbar with search (title/detail/id/child titles) and ball filters:
  Alle / Du bist dran / Bei Iris / Haengt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:51 +02:00
developer 50d95fa7a9 feat(ui): V2 Design-Fundament — UI-Komponenten konsolidieren und tokenisieren
## Neue wiederverwendbare Komponenten in components/ui/:
- StatusDot.vue — Animierte Status-Punkte (work/think/idle/block/queue)
- PageHeading.vue — Standardisierter Gradient-Seiten-Header
- EmptyState.vue — Shared Empty-State mit Icon/Title/Description/Action
- ActivityTimeline.vue — Wiederverwendbarer Aktivitäts-Feed
- SectionHeader.vue — Spalten-/Sektions-Header (dot + label + count)

## Token-Konformität hergestellt:
- Badge.vue: +12 nexus-token-basierte Varianten (work/think/blocked/queue/done/review/iris/bao/agent/priority*)
- Card.vue: glass-panel/raised/subtle Varianten via nexus-tokens.css
- button/index.ts: +gradient, +icon, +ghostSubtle, +danger Varianten
- nexus-tokens.css: Buttons + --clr-* Semantic Colors (iris/bao/agent/review/stale/other)

## Hartkodierte Farben eliminiert (0 verbleibend):
- dashboard/v2/*: alle #hex → var(--)* ersetzt
- components/ui/*: alle #hex → var(--)* ersetzt

Task: ea7c2063
Build:  grün
2026-07-11 11:09:31 +02:00
devops aef76d5f45 feat(board): master-task board, non-destructive stall watchdog, review flow
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
  nested inside their parent card instead of as separate column cards, so a
  big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
  children directly instead of scraping the flat board

Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
  tasks with no activity past the threshold as stalled (activity event +
  Iris notification) WITHOUT resetting the column — no work is discarded.
  Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
  interval 10m); hard reset kept only on the explicit manual endpoint

Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
  ExpectedFrom=iris, notifies Iris)

Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
  children, expand to show children grouped by agent with per-child state +
  stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions

Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:26:50 +02:00
devops f564ecfbc7 feat: unified rail shell, robust live sync, task board performance
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Shell:
- One shared NexusLayout for ALL routes (dashboard + pages): compact 68px
  icon rail with hover-expand overlay, replaces both old sidebars + topbars
- Single flat nav source (railNav) — same menu everywhere incl. Settings
- Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup,
  NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView

Live updates:
- Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and
  ChannelReader.ReadAsync were re-invoked while pending — every published
  update threw InvalidOperationException and killed ALL live streams
- liveSync store: treat graceful stream close as disconnect (was stuck
  connected=true with polling stopped -> page frozen until manual reload),
  fast first retry, heartbeat watchdog (65s), reconnect on online/visibility
- One app-wide SSE connection owned by the layout instead of per-view
  connect/disconnect churn; removed duplicate live-sync.ts store

Performance:
- Board endpoint: drop nested childTasks duplication (counts stay) — payload
  104KB -> 65KB; SSE snapshots shrink equally
- nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire);
  text/event-stream excluded to keep SSE unbuffered

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:05:52 +02:00
devops 706ff82ccd fix: harden Nexus runtime health and rollback 2026-07-10 00:37:24 +02:00
devops dbda764190 fix: execute Nexus deploy inside host-mounted workspace
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Failing after 21s
2026-07-10 00:16:47 +02:00
devops 361a64f886 ops: move canonical workspace to projects root
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 7s
2026-07-10 00:02:41 +02:00
devops a104acf160 ci: consolidate Nexus deployment and provenance
CI - Build & Test / Backend (.NET) (push) Successful in 48s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 55s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
2026-07-09 23:46:46 +02:00
devops aaec3eb4ed feat: complete Nexus mission-control workflows 2026-07-09 23:40:36 +02:00
devops 436ddfee0f fix: stream deploy source snapshot
CI - Build & Test / Backend (.NET) (push) Successful in 38s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:56:18 +02:00
devops 38954feb8f fix: snapshot deploy source before sync
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Failing after 3s
2026-06-24 07:52:24 +02:00
devops f30cce4fb3 fix: harden nexus deploy sync
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 4s
2026-06-24 07:43:02 +02:00
devops 7216bfdeff fix: repair gitea deploy pipeline
CI - Build & Test / Backend (.NET) (push) Successful in 34s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 7s
2026-06-24 07:33:10 +02:00
devops 16385d10cb docs: update routing docs for Traefik
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:43:33 +02:00
devops 250e730f33 fix: require auth for chat endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 06:32:04 +02:00
devops c9e22195ad fix: assert forbidden workflow results
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:36:18 +02:00
devops 8d8f8cc8a8 fix: assert task workflow result statuses
CI - Build & Test / Backend (.NET) (push) Failing after 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:30:59 +02:00
devops 873c5d586c fix: repair agent model converter build
CI - Build & Test / Backend (.NET) (push) Failing after 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:26:56 +02:00
devops 95495a8332 feat: complete task board workflow gates
CI - Build & Test / Backend (.NET) (push) Failing after 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 20s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-24 01:23:49 +02:00
devops 68b428e411 fix: prioritize rollback over queued deploys 2026-06-24 01:22:55 +02:00
devops 1214cf9a4d chore: simplify nexus cicd pipeline 2026-06-24 01:22:55 +02:00
devops 195c497c88 fix: route standalone views via route metadata 2026-06-24 01:22:55 +02:00
devops a2272c5df6 fix: harden owner bootstrap and auth persistence 2026-06-24 01:22:55 +02:00
129 changed files with 8440 additions and 4209 deletions
-12
View File
@@ -1,12 +0,0 @@
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=replace-with-a-strong-database-password
JWT_KEY=replace-with-at-least-32-random-bytes
OWNER_EMAIL=owner@example.com
OWNER_PASSWORD=replace-with-at-least-14-characters
OWNER_DISPLAY_NAME=Owner
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=
OPENCLAW_GATEWAY_PASSWORD=
OLLAMA_BASE_URL=http://host.docker.internal:11434
NVIDIA_API_KEY=
+4 -6
View File
@@ -15,13 +15,11 @@ JWT_KEY=*** # at least 32 bytes (base64-encoded)
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
# ── Owner Account ─────────────────────────────────────── # ── Bootstrap Owner (first seed only) ───────────────────
OWNER_EMAIL=*** BOOTSTRAP_OWNER_EMAIL=***
OWNER_PASSWORD=*** # at least 14 characters; leave empty for auto-generated
OWNER_DISPLAY_NAME=*** # leave empty for auto-generated from email
# ── OpenClaw Integration ──────────────────────────────── # ── OpenClaw Integration ────────────────────────────────
# Base URL of the OpenClaw gateway (host.docker.internal from inside container) # Internal Docker-DNS URL of the OpenClaw gateway
OPENCLAW_BASE_URL=http://host.docker.internal:18789 OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=*** OPENCLAW_GATEWAY_TOKEN=***
OPENCLAW_GATEWAY_PASSWORD=*** OPENCLAW_GATEWAY_PASSWORD=***
+264
View File
@@ -0,0 +1,264 @@
#!/bin/sh
set -eu
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
ENV_TMPFILE=""
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
BOOTSTRAP_OWNER_EMAIL="${BOOTSTRAP_OWNER_EMAIL_DEPLOY:-vmbao62@hotmail.de}"
cleanup() {
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
fi
}
trap cleanup EXIT INT TERM
require_env() {
name="$1"
eval "value=\${$name:-}"
if [ -z "$value" ]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env ENV_POSTGRES_PASSWORD
require_env ENV_JWT_KEY
secure_tmpfile() {
template="$1"
dir="$(dirname "$template")"
base="$(basename "$template")"
mkdir -p "$dir"
mktemp "$dir/$base.XXXXXX"
}
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
chmod 600 "$ENV_TMPFILE"
if [ ! -f VERSION ]; then
echo "VERSION file not found" >&2
exit 1
fi
VERSION="$(tr -d '[:space:]' < VERSION)"
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Invalid VERSION value: $VERSION" >&2
exit 1
fi
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
echo "Deploying Nexus v$VERSION from $GIT_REF"
umask 077
cat > "$ENV_TMPFILE" <<EOF_ENV
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=${BOOTSTRAP_OWNER_EMAIL}
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=${VERSION}
NEXUS_GIT_SHA=${GIT_SHA}
EOF_ENV
echo "Syncing source to deploy path: $DEPLOY_PATH"
git archive --format=tar HEAD | docker run --rm -i \
-v "$DEPLOY_PATH:/dest" \
alpine:3.20 \
sh -c '
set -eu
dest_owner="$(stat -c "%u:%g" /dest)"
mkdir -p /src-snapshot
tar -xf - -C /src-snapshot
is_protected_path() {
case "$1" in
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
rm -rf "$path"
fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then
cp -a "$path" /dest/
fi
done
chown -R "$dest_owner" /dest
'
# ── Sanitized agents config for Nexus (no secrets) ──
echo "Generating sanitized agents config for Nexus"
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
if [ -f "$OPENCLAW_CONFIG" ]; then
python3 -c "
import json, sys
with open('$OPENCLAW_CONFIG') as f:
data = json.load(f)
agents = data.get('agents')
if agents is None:
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
sys.exit(1)
with open('$AGENTS_SANITIZED_PATH', 'w') as f:
json.dump({'agents': agents}, f, indent=2)
"
chmod 644 "$AGENTS_SANITIZED_PATH" 2>/dev/null || true
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
else
echo "WARNING: openclaw.json not found at $OPENCLAW_CONFIG — agents-sanitized.json NOT generated" >&2
fi
echo "Building and starting Docker compose stack"
docker run --rm \
-v "$DEPLOY_PATH:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
-i \
docker:cli \
sh -c 'set -eu
umask 077
cat > /tmp/nexus-deploy-env
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-deploy-env build
# ── Postgres: only recreate if image or config changed ──
# docker compose up -d (without --force-recreate) is smart enough
# to only recreate containers whose config or image has changed.
# We DROP --force-recreate so postgres persists across deploys
# unless its image tag or compose config actually changed.
docker compose --env-file /tmp/nexus-deploy-env up -d --remove-orphans --wait
docker compose --env-file /tmp/nexus-deploy-env ps
' < "$ENV_TMPFILE"
echo "Verifying image provenance"
for container in nexus-api-1 nexus-web-1; do
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
version="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$container")"
if [ "$revision" != "$GIT_SHA" ]; then
echo "Image revision mismatch for $container: expected $GIT_SHA, got $revision" >&2
exit 1
fi
if [ "$version" != "$VERSION" ]; then
echo "Image version mismatch for $container: expected $VERSION, got $version" >&2
exit 1
fi
echo "$container provenance verified: v$version $revision"
done
echo "Checking live health"
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
case "$health_body" in
'{"status":"Healthy"'*)
echo "Health check passed"
break
;;
esac
if [ -n "$health_body" ]; then
echo "Health endpoint is reachable but not healthy: $health_body" >&2
fi
if [ "$retry" -eq 6 ]; then
echo "Health check failed" >&2
exit 1
fi
sleep "$retry"
done
pass=0
fail=0
check() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check_post() {
path="$1"
expected="$2"
label="$3"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{}' "$BASE_URL$path")"
printf '%-28s HTTP %s\n' "$label" "$code"
if [ "$code" = "$expected" ]; then
pass=$((pass + 1))
else
fail=$((fail + 1))
fi
}
check "/dashboard" "200" "Dashboard"
check "/health" "200" "Health"
check "/api/v1/operations/snapshot" "401" "Operations auth"
check_post "/api/v1/chat" "401" "Chat auth"
# ── Auth Smoke: SeedAudit owner_created exists in DB ──
echo ""
echo "Auth Smoke: SeedAudit owner_created"
seed_key="$(docker exec nexus-postgres-1 psql -U nexus -d nexus -t -A -c "SELECT key FROM \"SeedAudit\" WHERE key = 'owner_created'" 2>/dev/null || echo "")"
seed_key="$(echo "$seed_key" | tr -d '[:space:]')"
if [ "$seed_key" = "owner_created" ]; then
echo " SeedAudit owner_created: ✅ exists"
pass=$((pass + 1))
else
echo " SeedAudit owner_created: ❌ NOT FOUND (DB may not be seeded)" >&2
echo " Raw output: '$seed_key'" >&2
fail=$((fail + 1))
fi
# ── Auth Smoke: Owner login flow returns 401 for unknown password ──
# This proves the user exists, auth pipeline is functional, and the DB is reachable.
# We POST with a WRONG password intentionally — a 401 means "user found, password wrong",
# which is the correct auth flow behavior. A 5xx or connection error means the stack is broken.
echo "Auth Smoke: Owner login flow"
login_body="$(curl -sS --max-time 10 \
-X POST \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${BOOTSTRAP_OWNER_EMAIL}\",\"password\":\"smoke-test-wrong-password-$(date +%s)\"}" \
"$BASE_URL/api/v1/auth/login" 2>/dev/null || echo "CONNECTION_ERROR")"
if echo "$login_body" | grep -q '"error":"invalid_credentials"'; then
echo " Owner login flow: ✅ HTTP 401 with valid JSON (auth pipeline working)"
pass=$((pass + 1))
else
echo " Owner login flow: ❌ unexpected response" >&2
echo " Response: $(echo "$login_body" | head -c 200)" >&2
fail=$((fail + 1))
fi
if [ "$fail" -ne 0 ]; then
echo ""
echo "Smoke test failed: $fail failed, $pass passed" >&2
exit 1
fi
echo ""
echo "Nexus v$VERSION deployed and verified"
+4 -4
View File
@@ -33,7 +33,7 @@ on:
host_backup_path: host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)' description: 'Host path for backup (only if keep_on_host is true)'
required: false required: false
default: '/home/projekte_bao/openclaw/backups' default: '/home/projekte_bao/backups/nexus'
type: string type: string
# Optional: uncomment to enable nightly automatic backups # Optional: uncomment to enable nightly automatic backups
@@ -43,11 +43,11 @@ on:
jobs: jobs:
backup: backup:
name: Backup PostgreSQL name: Backup PostgreSQL
runs-on: ubuntu-latest runs-on: linux
env: env:
ENV_TMPFILE: /tmp/nexus-backup-env ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus DEPLOY_PATH: /home/projekte_bao/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1 BACKUP_CONTAINER_NAME: nexus-postgres-1
steps: steps:
@@ -72,7 +72,7 @@ jobs:
echo "🗄️ Dumping PostgreSQL cluster..." echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \ docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \ sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus" \
| gzip > "${{ steps.meta.outputs.filename }}" | gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1) SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
+28 -3
View File
@@ -8,9 +8,12 @@ concurrency:
on: on:
push: push:
branches: [main] branches:
- main
- 'codex/**'
pull_request: pull_request:
branches: [main] branches: [main]
workflow_dispatch:
jobs: jobs:
# ─── Backend ─────────────────────────────────── # ─── Backend ───────────────────────────────────
@@ -51,7 +54,7 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
run: | run: |
corepack enable corepack enable
corepack prepare pnpm@latest --activate corepack prepare pnpm@10.12.1 --activate
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -73,7 +76,7 @@ jobs:
security: security:
name: Security Check name: Security Check
runs-on: linux runs-on: linux
if: github.ref == 'refs/heads/main' if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -97,3 +100,25 @@ jobs:
else else
echo "✅ No obvious secrets found" echo "✅ No obvious secrets found"
fi fi
deploy:
name: Deploy Nexus
runs-on: linux
needs: [backend, frontend, security]
concurrency:
group: deploy-production
cancel-in-progress: false
if: |
gitea.event_name == 'push' &&
gitea.ref == 'refs/heads/main'
env:
DEPLOY_PATH: /home/projekte_bao/nexus
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy after green CI
run: sh .gitea/scripts/deploy-nexus.sh
-199
View File
@@ -1,199 +0,0 @@
name: Deploy Now
run-name: 🚀 Deploy Now by @${{ gitea.actor }}
on:
workflow_dispatch:
jobs:
deploy:
name: Deploy Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
- name: Resolve Version
id: version
run: |
set -euo pipefail
if [ ! -f VERSION ]; then
echo "ERROR: VERSION file not found"
exit 1
fi
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "ERROR: Invalid semver in VERSION: $VERSION"
exit 1
fi
GIT_REF=$(git rev-parse --short HEAD)
echo "Deploy version: v${VERSION} git:${GIT_REF}"
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
- name: Prepare .env
run: |
set -euo pipefail
HOST_OWNER_PASSWORD=$(docker run --rm -v "${DEPLOY_PATH}:/host-deploy:ro" alpine:latest sh -c "grep '^OWNER_PASSWORD=' /host-deploy/.env | cut -d= -f2-" 2>/dev/null || true)
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "ERROR: OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
printf 'POSTGRES_DB=nexus\n' > "${ENV_TMPFILE}"
printf 'POSTGRES_USER=nexus\n' >> "${ENV_TMPFILE}"
printf 'POSTGRES_PASSWORD=%s\n' "${ENV_POSTGRES_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'JWT_KEY=%s\n' "${ENV_JWT_KEY}" >> "${ENV_TMPFILE}"
printf 'JWT_ISSUER=nexus\n' >> "${ENV_TMPFILE}"
printf 'JWT_AUDIENCE=nexus-web\n' >> "${ENV_TMPFILE}"
printf 'OWNER_EMAIL=vmbao62@hotmail.de\n' >> "${ENV_TMPFILE}"
printf 'OWNER_PASSWORD=%s\n' "${HOST_OWNER_PASSWORD}" >> "${ENV_TMPFILE}"
printf 'OWNER_DISPLAY_NAME=\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_BASE_URL=http://host.docker.internal:18789\n' >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_TOKEN=%s\n' "${ENV_OPENCLAW_TOKEN}" >> "${ENV_TMPFILE}"
printf 'OPENCLAW_GATEWAY_PASSWORD=\n' >> "${ENV_TMPFILE}"
chmod 600 "${ENV_TMPFILE}"
echo "OK .env written to ${ENV_TMPFILE}"
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "cd /src && find . -mindepth 1 -maxdepth 1 ! -name .git -exec cp -r {} /dest/ \; && DEST_OWNER=\$(stat -c '%u:%g' /dest) && chown -R \"\$DEST_OWNER\" /dest"
echo "OK synced to ${DEPLOY_PATH}"
- name: Build and Deploy
run: |
set -euo pipefail
SCRIPT=/tmp/nexus-deploy-script.sh
printf '#!/bin/sh\n' > "$SCRIPT"
printf 'set -e\n' >> "$SCRIPT"
printf 'trap "rm -f /tmp/nexus-deploy-env" EXIT\n' >> "$SCRIPT"
printf 'cat > /tmp/nexus-deploy-env\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true\n' >> "$SCRIPT"
printf 'docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)\n' >> "$SCRIPT"
printf 'if [ -n "$PG_VOL" ]; then\n' >> "$SCRIPT"
printf ' echo "Checking postgres WAL integrity..."\n' >> "$SCRIPT"
printf ' docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo WAL reset OK" 2>&1 || echo "pg_resetwal failed (may be benign)"\n' >> "$SCRIPT"
printf 'else\n' >> "$SCRIPT"
printf ' echo "Postgres volume not found - will be created fresh"\n' >> "$SCRIPT"
printf 'fi\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Deploying all services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env build --no-cache\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate\n' >> "$SCRIPT"
printf '\n' >> "$SCRIPT"
printf 'echo "Waiting for services to become healthy (up to 180s)..."\n' >> "$SCRIPT"
printf 'for i in $(seq 1 36); do\n' >> "$SCRIPT"
printf ' STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)\n' >> "$SCRIPT"
printf ' if echo "$STATUS" | grep -q unhealthy; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Unhealthy containers - failing fast"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env logs --tail=30\n' >> "$SCRIPT"
printf ' exit 1\n' >> "$SCRIPT"
printf ' elif echo "$STATUS" | grep -q starting; then\n' >> "$SCRIPT"
printf ' echo " [$i/36] Still starting..."\n' >> "$SCRIPT"
printf ' sleep 5\n' >> "$SCRIPT"
printf ' else\n' >> "$SCRIPT"
printf ' echo "All containers healthy"\n' >> "$SCRIPT"
printf ' docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf ' exit 0\n' >> "$SCRIPT"
printf ' fi\n' >> "$SCRIPT"
printf 'done\n' >> "$SCRIPT"
printf 'echo "Timeout waiting for services"\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env ps -a\n' >> "$SCRIPT"
printf 'docker compose --env-file /tmp/nexus-deploy-env logs --tail=20\n' >> "$SCRIPT"
printf 'exit 1\n' >> "$SCRIPT"
chmod +x "$SCRIPT"
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${SCRIPT}:/deploy.sh:ro" \
-w /workspace/nexus \
-i \
docker:cli \
sh /deploy.sh < "${ENV_TMPFILE}"
rm -f "$SCRIPT"
echo "OK deployed"
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "OK cleaned"
fi
- name: Health Check
run: |
echo "Health check..."
RETRY=0; MAX=6; WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "OK Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "ERROR Health check failed after $MAX attempts"
exit 1
- name: Smoke Test
run: |
PASS=0; FAIL=0; BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " OK"
PASS=$((PASS + 1))
else
echo " FAIL (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "ERROR Smoke test failed"
exit 1
fi
echo "OK Smoke test passed"
- name: Summary
if: always()
run: |
echo "========================================"
echo " Deploy Summary"
echo "========================================"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: main"
echo " Service: all"
echo " Trigger: Manual"
echo " Status: ${{ job.status }}"
echo "========================================"
+7 -367
View File
@@ -1,388 +1,28 @@
name: Deploy Nexus v2 name: Deploy Nexus Manual
run-name: 🚀 Deploy v2 by @${{ gitea.actor }} run-name: Deploy Nexus manually by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version 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
on: on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run:
workflows: ["CI - Build & Test"]
types: [completed]
branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
jobs: jobs:
deploy: deploy:
name: Deploy Nexus name: Deploy Nexus
runs-on: ubuntu-latest runs-on: linux
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: env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus DEPLOY_PATH: /home/projekte_bao/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }} ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
# OWNER_PASSWORD is read from the host's persistent .env — NOT from a Gitea secret.
# This ensures the password stays consistent across deploys and the DB is the
# single source of truth after initial seed (enforced by SeedAudit guard).
steps: steps:
# ═══════════════════════════════════════════════════ - name: Checkout main
# Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: main ref: main
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════ - name: Deploy main
# Step 2: Set up Git identity run: sh .gitea/scripts/deploy-nexus.sh
# ═══════════════════════════════════════════════════
- name: Configure Git
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: |
set -euo pipefail
# 1. Check VERSION exists
if [ ! -f VERSION ]; then
echo "❌ VERSION file not found"
exit 1
fi
# 2. Read and validate semver format
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1
fi
# 3. Log version, git ref, and describe
GIT_REF=$(git rev-parse --short HEAD)
GIT_DESCRIBE=$(git describe --always --dirty)
echo "📦 Deploy version: v${VERSION}"
echo "🔖 Git ref: ${GIT_REF}"
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
# 4. Set outputs for downstream steps
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
# ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets + host .env (SAFE)
#
# Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after
# compose operations complete.
#
# OWNER_PASSWORD is read from the host's persistent .env
# to ensure it stays the single source of truth. Other
# secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
# come from Gitea secrets.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file)
run: |
set -euo pipefail
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
echo " The host .env is the single source of truth for the owner password."
echo " Ensure OWNER_PASSWORD is set in the deploy-path .env before deploying."
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea Secrets + host .env → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus
POSTGRES_USER=nexus
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 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Code synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy
run: |
set -euo pipefail
BUILD_ARGS=""
SERVICE_ARG=""
# Write the deploy script to a file to avoid nested quoting issues
cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
#!/bin/sh
set -e
trap 'rm -f /tmp/nexus-deploy-env' EXIT
cat > /tmp/nexus-deploy-env
# ── Clean up zombie containers ──
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
docker rm -f nexus-postgres-1 nexus-api-1 nexus-web-1 2>/dev/null || true
# ── WAL recovery ──
PG_VOL=$(docker volume ls -q --filter name=nexus-postgres 2>/dev/null | head -1)
if [ -n "$PG_VOL" ]; then
echo "Checking postgres WAL integrity..."
docker run --rm -v "$PG_VOL:/var/lib/postgresql/data" --entrypoint sh postgres:17-alpine -c "pg_resetwal -f /var/lib/postgresql/data && echo 'WAL reset OK'" 2>&1 || echo "pg_resetwal failed (may be benign)"
else
echo "Postgres volume not found - will be created fresh"
fi
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
run: |
echo "🏥 Health check..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
# Fibonacci-ish backoff: 1,2,3,5,8,13
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 9: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ═══════════════════════════════════════════════════
# Step 10: Deployment Summary
# ═══════════════════════════════════════════════════
- name: Deployment Summary
if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: main"
echo " 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()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Version: v${{ steps.version.outputs.version }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+58 -40
View File
@@ -18,9 +18,12 @@ run-name: 🔙 Rollback by @${{ gitea.actor }}
# migrations). If the tag predates a destructive migration, manual # migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps. # DB intervention is needed — that's an edge case surfaced to DevOps.
# ─────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────
# Rollback wins over queued/in-progress deploys.
# It shares deploy-production with deploy.yaml so rollback and deploy never run together,
# but cancel-in-progress=true prevents a queued auto-deploy from running after rollback.
concurrency: concurrency:
group: deploy-production group: deploy-production
cancel-in-progress: false cancel-in-progress: true
on: on:
workflow_dispatch: workflow_dispatch:
@@ -37,9 +40,9 @@ on:
jobs: jobs:
rollback: rollback:
name: Rollback Nexus name: Rollback Nexus
runs-on: ubuntu-latest runs-on: linux
env: env:
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus DEPLOY_PATH: /home/projekte_bao/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }} ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }} ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
@@ -94,22 +97,12 @@ jobs:
fi fi
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Step 3: Prepare .env from secrets + host .env (safe temp file) # Step 3: Prepare .env from secrets (safe temp file)
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
- name: Prepare .env (secrets + host .env → temp file) - name: Prepare .env (secrets → temp file)
run: | run: |
set -euo pipefail set -euo pipefail
# Read OWNER_PASSWORD from the host's persistent .env
HOST_OWNER_PASSWORD=""
if [ -f "${DEPLOY_PATH}/.env" ]; then
HOST_OWNER_PASSWORD=$(grep '^OWNER_PASSWORD=' "${DEPLOY_PATH}/.env" | cut -d= -f2- || true)
fi
if [ -z "${HOST_OWNER_PASSWORD}" ]; then
echo "❌ OWNER_PASSWORD not found in ${DEPLOY_PATH}/.env"
exit 1
fi
cat > "${ENV_TMPFILE}" <<EOF cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline # Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus POSTGRES_DB=nexus
@@ -118,12 +111,12 @@ jobs:
JWT_KEY=${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 BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${HOST_OWNER_PASSWORD} OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD= OPENCLAW_GATEWAY_PASSWORD=
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
NEXUS_GIT_SHA=$(git rev-parse HEAD)
EOF EOF
chmod 600 "${ENV_TMPFILE}" chmod 600 "${ENV_TMPFILE}"
@@ -136,18 +129,38 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm \ git archive --format=tar HEAD | docker run --rm -i \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \ -v "${DEPLOY_PATH}:/dest" \
alpine:latest \ alpine:latest \
sh -c " sh -c '
cd /src && \ set -eu
find . -mindepth 1 -maxdepth 1 \ dest_owner="$(stat -c "%u:%g" /dest)"
! -name .git \ mkdir -p /src-snapshot
-exec cp -r {} /dest/ \; && \ tar -xf - -C /src-snapshot
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest is_protected_path() {
" case "$1" in
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
return 0
;;
*)
return 1
;;
esac
}
cd /dest
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then rm -rf "$path"; fi
done
cd /src-snapshot
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
done
chown -R "$dest_owner" /dest
'
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}" echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
@@ -160,16 +173,18 @@ jobs:
docker run --rm \ docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \ -w /workspace/nexus \
-i \
docker:cli \ docker:cli \
sh -c " sh -c '
set -e set -eu
echo '🔙 Rolling back to ${{ inputs.target_tag }}' umask 077
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache cat > /tmp/nexus-rollback-env
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
" docker compose --env-file /tmp/nexus-rollback-env build --no-cache
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
' < "${ENV_TMPFILE}"
echo "✅ Rollback redeploy completed" echo "✅ Rollback redeploy completed"
@@ -195,11 +210,14 @@ jobs:
WAIT=1 WAIT=1
while [ $RETRY -lt $MAX ]; do while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1)) RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
echo "" case "$HEALTH_BODY" in
'{"status":"Healthy"'*)
echo "✅ Health check passed (attempt $RETRY/$MAX)" echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0 exit 0
fi ;;
esac
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..." echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT sleep $WAIT
NEXT=$((WAIT + RETRY)) NEXT=$((WAIT + RETRY))
@@ -280,7 +298,7 @@ jobs:
echo "│ Letzter bekannter funktionierender Stand: │" echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │" echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │" echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │" echo "│ cd /home/projekte_bao/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │" echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │" echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘" echo "└─────────────────────────────────────────────────────────────┘"
+3 -1
View File
@@ -6,7 +6,6 @@
# Environment # Environment
.env .env
!.env.example
!.env.template !.env.template
# IDE # IDE
@@ -40,3 +39,6 @@ frontend/.corepack-home/
# Claude local config (per-developer, not repo-shared) # Claude local config (per-developer, not repo-shared)
.claude/ .claude/
# Sanitized agent config (generated on host, not committed)
backend/agents-sanitized.json
+6 -5
View File
@@ -31,7 +31,7 @@
│ 127.0.0.1:18880 │ │ 127.0.0.1:18880 │
│ │ │ │ │ │
│ ┌───────────────────────────┼───────────────────┐ │ │ ┌───────────────────────────┼───────────────────┐ │
│ │ Host nginx reverse proxy │ │ │ │ │ Traefik v3 reverse proxy │ │ │
│ │ nexus.noveria.net :443 ───┘ │ │ │ │ nexus.noveria.net :443 ───┘ │ │
│ └───────────────────────────────────────────────┘ │ │ └───────────────────────────────────────────────┘ │
│ │ │ │
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
ss -tlnp | grep 18880 ss -tlnp | grep 18880
``` ```
### Host nginx Reverse Proxy ### Traefik Reverse Proxy
Falls `nexus.noveria.net` nicht erreichbar: Falls `nexus.noveria.net` nicht erreichbar:
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880` - Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
- TLS-Zertifikat gültig? - `web` hängt am externen `proxy`-Netzwerk
- TLS-Zertifikat/Let's-Encrypt-Resolver in Traefik gültig?
--- ---
@@ -151,5 +152,5 @@ Falls `nexus.noveria.net` nicht erreichbar:
| backend/Dockerfile | ✅ Multi-Stage .NET 10 | | backend/Dockerfile | ✅ Multi-Stage .NET 10 |
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx | | frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing | | frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
| Host nginx Reverse Proxy | ⚠️ Muss auf Port 18880 zeigen | | Traefik Reverse Proxy | ✅ Per Compose-Labels auf `web:80` |
| Docker installiert auf VPS | ⚠️ Vorausgesetzt | | Docker installiert auf VPS | ⚠️ Vorausgesetzt |
+82 -25
View File
@@ -7,10 +7,10 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
> Backend-Brücke und Gateway-Integration geprüft. Siehe > Backend-Brücke und Gateway-Integration geprüft. Siehe
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md) > [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
> CI runs automatically on every push. CD can run **automatically after successful CI** > CI runs automatically on every push. CD runs **inside the green CI run**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with > on main or can be triggered **manually** (workflow_dispatch). Deploy reads
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys > `VERSION` but does not mutate Git or create tags. Rollback and database backup
> stay read-only. Rollback and database backup are separate manual workflows. > are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation. > See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation ## Current foundation
@@ -26,16 +26,16 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
## Local/container start ## Local/container start
```bash ```bash
cp .env.example .env cp .env.template .env
# Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY, # Replace every placeholder, especially POSTGRES_PASSWORD, JWT_KEY and BOOTSTRAP_OWNER_EMAIL.
# OWNER_EMAIL and OWNER_PASSWORD.
docker compose up --build -d docker compose up --build -d
curl http://127.0.0.1:18880/health 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 `BOOTSTRAP_OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10 derives the initial display name from that email, and logs a generated temporary password once.
characters. Existing databases are never overwritten by the bootstrap process. After first seed the password lives only in PostgreSQL. Existing databases are
never overwritten by the bootstrap process.
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS. The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
Health checks, rate limiting, and security headers are active. Health checks, rate limiting, and security headers are active.
@@ -174,6 +174,63 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
## API endpoints ## API endpoints
### MCP Agent Data Plane
Nexus exposes an MCP endpoint at `/mcp` for agent-facing board operations.
It uses the official `ModelContextProtocol.AspNetCore` SDK with stateless
streamable HTTP transport. Tools are a thin facade over `ITaskBridgeService`;
they must not duplicate board business logic.
Auth follows the bridge rules: requests provide `X-Agent-Id` and/or
`X-Nexus-Api-Key`. Secrets stay in OpenClaw/Gateway config and are never
embedded in frontend code.
Registered tools:
| Tool | Purpose |
|---|---|
| `nexus_get_board` | Full task board |
| `nexus_agent_overview` | Waiting/stale workflow overview |
| `nexus_get_task` | Single task |
| `nexus_get_children` | Child tasks for a parent |
| `nexus_get_activity` | Task activity history |
| `nexus_create_task` | Create parent/standalone task |
| `nexus_create_child_task` | Create visible delegation child task |
| `nexus_update_status` | Update status using the canonical enum only |
| `nexus_append_activity` | Append checkpoint/activity |
| `nexus_handoff` | Handoff to a known agent |
The compatible `/api/bridge` HTTP facade remains available for internal
diagnostics and transition clients. New agent integrations should use MCP;
`/api/dashboard` is UI/admin surface, not an agent contract.
### Mission Control Gateway Plane
Nexus keeps the Browser -> Nexus -> OpenClaw boundary: the frontend never talks
to OpenClaw directly. Read-only Gateway status is exposed through
`GET /api/dashboard/gateway`; it reports reachability, discovered Gateway
version and the optional `Integrations:OpenClaw:RequiredVersion` pin. A set pin
does not mutate production config, but makes protocol drift visible in the UI.
Agent activity shown as "Thinking" is redacted before display. Lines containing
token, password, bearer, authorization, API key or secret markers are replaced
with a redaction marker. Persisted audit-worthy events should be written as
short Activity entries, not raw session transcripts.
Nexus activity updates stream live through the Dashboard SSE channel and are
filtered by explicit `agentIds`. Gateway session history is read-only fallback
data: it is fetched on demand, redacted before display and not persisted as a
long-term raw transcript. Agent "Now" and "Today" summaries are deterministic
derivations from redacted Nexus activity plus redacted Gateway history; Nexus
does not call an LLM to summarize this feed.
Config writes and approval actions are owner-only. Config saves validate before
replacement, keep a `.bak` when an existing file is replaced, write audit events
without file contents or secrets and return structured `validation`, `backup`
and `reloadCheck` results. Workspace Markdown hot reload is currently reported
truthfully as `not_supported`; JSON validation exists in the save path but JSON
files are not exposed unless they are explicitly allowlisted for editing.
### Backend Bridge (Agent-zu-Backend, NICHT Frontend) ### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
@@ -250,11 +307,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
|---|---|---| |---|---|---|
| `GET` | `/api/v1/tasks` | List all tasks | | `GET` | `/api/v1/tasks` | List all tasks |
| `POST` | `/api/v1/tasks` | Create task | | `POST` | `/api/v1/tasks` | Create task |
| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour | | `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals |
| `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) | | `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) |
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state | | `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress done) | | `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) |
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress backlog) | | `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) | | `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
### Agents ### Agents
@@ -264,10 +321,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
| `GET` | `/api/v1/agents` | List all agents | | `GET` | `/api/v1/agents` | List all agents |
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) | | `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
| `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) | | `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) |
| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary |
| `POST` | `/api/v1/agents/{id}/command` | Send command to agent | | `POST` | `/api/v1/agents/{id}/command` | Send command to agent |
| `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) | | `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content | | `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) | | `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result |
### Memory & Docs ### Memory & Docs
@@ -351,24 +409,23 @@ Every push to `main` triggers `.gitea/workflows/ci.yaml`:
CI must never break. If it does, Reviewer fixes. CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3) ### CD — Auto + Manual (CD v4)
Deployment can happen automatically or manually: Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main) #### Auto-Deploy (after successful CI jobs on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main` - Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
- Uses safe defaults: `patch` bump, all services, main ref - Starts only after backend, frontend, and security jobs succeed on `main`
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits) - Deploys the current `main` version after CI succeeds.
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops - This replaces `workflow_run`, which did not create deploy runs in this Gitea 1.26.3 installation.
- The deploy script reads `VERSION`; it does not mutate Git, bump versions, or create tags
#### Manual Deploy (`workflow_dispatch`) #### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves) 1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
2. Chooses version bump type: patch (default) / minor / major 2. Workflow validates `VERSION`, builds and deploys `main`
3. Optionally scopes to a single service or specific git ref 3. Health check + smoke test verify the deployment
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`) #### Rollback (`workflow_dispatch`)
+108 -6
View File
@@ -45,6 +45,94 @@ public class AgentServiceTests
Assert.Null(agent); Assert.Null(agent);
} }
[Fact]
public async Task GetAllowedAgentIdsAsync_IncludesProductOwnerAndProgrammerFast()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var ids = await service.GetAllowedAgentIdsAsync(CancellationToken.None);
Assert.Contains("product-owner", ids);
Assert.Contains("programmer-fast", ids);
}
[Fact]
public async Task GetAgentAsync_ProgrammerFast_UsesPrimaryModelAndDeveloperRole()
{
var configPath = CreateAgentConfigFile();
var config = CreateConfiguration(configPath);
var runtime = new FakeRuntime();
var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("programmer-fast", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("Developer", agent.Role);
Assert.Equal("openai/gpt-5.3-codex-spark", agent.Model);
}
[Fact]
public async Task GetAgentAsync_LegacyStringModel_IsSupported()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "deepseek/deepseek-v4-flash"
},
"list": [
{
"id": "iris",
"name": "iris",
"model": "openai/gpt-5.5"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("iris", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5", agent!.Model);
}
[Fact]
public async Task GetAgentAsync_ObjectModel_InheritsStringDefaultModel()
{
var configPath = CreateAgentConfigFile(
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": "openai/gpt-5.5-mini"
},
"list": [
{
"id": "reviewer",
"name": "reviewer"
}
]
}
}
""");
var config = CreateConfiguration(configPath);
var service = new AgentService(config, new FakeRuntime());
var agent = await service.GetAgentAsync("reviewer", CancellationToken.None);
Assert.NotNull(agent);
Assert.Equal("openai/gpt-5.5-mini", agent!.Model);
}
private static IConfiguration CreateConfiguration(string configPath) private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder() => new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> .AddInMemoryCollection(new Dictionary<string, string?>
@@ -53,10 +141,10 @@ public class AgentServiceTests
}) })
.Build(); .Build();
private static string CreateAgentConfigFile() private static string CreateAgentConfigFile(string? json = null)
{ {
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json"); var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path, File.WriteAllText(path, json ??
""" """
{ {
"agents": { "agents": {
@@ -69,19 +157,33 @@ public class AgentServiceTests
"list": [ "list": [
{ {
"id": "iris", "id": "iris",
"name": "iris" "name": "iris",
"model": { "primary": "openai/gpt-5.5" }
},
{
"id": "product-owner",
"name": "product-owner",
"model": { "primary": "openai/gpt-5.5" }
}, },
{ {
"id": "programmer", "id": "programmer",
"name": "programmer" "name": "programmer",
"model": { "primary": "openai/gpt-5.4" }
},
{
"id": "programmer-fast",
"name": "programmer-fast",
"model": { "primary": "openai/gpt-5.3-codex-spark" }
}, },
{ {
"id": "reviewer", "id": "reviewer",
"name": "reviewer" "name": "reviewer",
"model": { "primary": "openai/gpt-5.5" }
}, },
{ {
"id": "architekt", "id": "architekt",
"name": "architekt" "name": "architekt",
"model": { "primary": "openai/gpt-5.5" }
} }
] ]
} }
+397
View File
@@ -0,0 +1,397 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Tests for AuthService login, change-password, admin-reset, and related flows.
/// These are unit-level tests using an in-memory EF Core database so no
/// external PostgreSQL instance is needed.
/// </summary>
public sealed class AuthServiceTests
{
// ── Fixture helpers ─────────────────────────────────────────────────
/// <summary>
/// Creates a test fixture with an in-memory database, a UserRepository,
/// and an AuthService backed by an in-memory configuration.
/// </summary>
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
var repo = new UserRepository(db);
// In-memory config with minimum required JWT settings
var config = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
var auth = new AuthService(repo, config, logger);
return (db, repo, auth);
}
private static LoginRequest Login(string email, string password)
=> new() { Email = email, Password = password };
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
{
var user = new NexusUser
{
Email = email,
NormalizedEmail = AuthService.NormalizeEmail(email),
DisplayName = email.Split('@')[0],
PasswordHash = PasswordSecurity.Hash(password),
Role = role
};
db.Users.Add(user);
await db.SaveChangesAsync();
return user;
}
// ══════════════════════════════════════════════════════════════════
// Password Security Unit Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public void Hash_And_Verify_RoundTrip_Succeeds()
{
const string password = "MyTestPassword123!";
var hash = PasswordSecurity.Hash(password);
Assert.NotNull(hash);
Assert.StartsWith("v1.", hash);
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
Assert.True(ok);
Assert.False(needsUpgrade);
}
[Fact]
public void Verify_WrongPassword_Fails()
{
var hash = PasswordSecurity.Hash("CorrectPassword123!");
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
}
[Fact]
public void Verify_EmptyHash_ReturnsFalse()
{
Assert.False(PasswordSecurity.Verify("password", "", out _));
}
[Fact]
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
{
const string password = "OldFormatPassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
Assert.True(ok);
Assert.True(needsUpgrade);
}
// ══════════════════════════════════════════════════════════════════
// Login Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task Login_WithValidCredentials_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string password = "ValidPassword123!";
await SeedUserAsync(db, "test@example.com", password);
var session = await auth.LoginAsync(Login("test@example.com", password));
Assert.NotNull(session);
Assert.Equal("test", session.User.DisplayName);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
}
[Fact]
public async Task Login_WithNonexistentEmail_ReturnsNull()
{
var (db, repo, auth) = CreateFixture();
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
}
[Fact]
public async Task Login_UpdatesLastLoginAt()
{
var (db, repo, auth) = CreateFixture();
const string password = "TestPassword123!";
var user = await SeedUserAsync(db, "test@example.com", password);
var beforeLogin = user.LastLoginAt;
await Task.Delay(10);
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated!.LastLoginAt);
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
}
/// <summary>
/// Validates that LoginAsync persists a password hash upgrade AND login
/// timestamps even when there are NO expired refresh tokens. Previously
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
/// but that only happens when oldTokens.Count > 0.
/// </summary>
[Fact]
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
{
var (db, repo, auth) = CreateFixture();
const string password = "LegacyUpgradePassword123!";
var legacyHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(password)));
var user = new NexusUser
{
Email = "legacy@example.com",
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
DisplayName = "Legacy",
PasswordHash = legacyHash,
Role = "user"
};
db.Users.Add(user);
await db.SaveChangesAsync();
// Login triggers hash upgrade
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.StartsWith("v1.", updated.PasswordHash);
Assert.NotEqual(legacyHash, updated.PasswordHash);
// Second login with the upgraded hash should also work
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
}
[Fact]
public async Task Login_WithExistingHash_DoesNotChangeHash()
{
var (db, repo, auth) = CreateFixture();
const string password = "StablePassword123!";
var user = await SeedUserAsync(db, "stable@example.com", password);
var originalHash = user.PasswordHash;
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
var updated = await repo.GetByIdAsync(user.Id);
Assert.NotNull(updated);
Assert.Equal(originalHash, updated.PasswordHash);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
{
var (db, repo, auth) = CreateFixture();
const string oldPw = "OldPassword123!";
const string newPw = "NewPassword456!";
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = oldPw,
NewPassword = newPw
});
Assert.True(result);
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
}
[Fact]
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
{
CurrentPassword = "WrongPassword456!",
NewPassword = "NewPassword789!"
}));
}
// ══════════════════════════════════════════════════════════════════
// Admin Reset Password Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task AdminResetPassword_WithValidToken_Succeeds()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
const string oldPw = "OldPassword123!";
const string newPw = "NewAdminPassword456!";
await SeedUserAsync(db, "adminreset@example.com", oldPw);
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
}
[Fact]
public async Task AdminResetPassword_WithInvalidToken_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
}
[Fact]
public async Task AdminResetPassword_NonexistentUser_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
}
[Fact]
public async Task AdminResetPassword_ShortPassword_Fails()
{
var (db, repo, auth) = CreateFixture();
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
}
// ══════════════════════════════════════════════════════════════════
// Profile Update Tests
// ══════════════════════════════════════════════════════════════════
[Fact]
public async Task UpdateProfile_ChangesDisplayName()
{
var (db, repo, auth) = CreateFixture();
const string password = "Password123!";
var user = await SeedUserAsync(db, "profile@example.com", password);
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
{
DisplayName = "New Name"
});
Assert.NotNull(updated);
Assert.Equal("New Name", updated.DisplayName);
}
// ══════════════════════════════════════════════════════════════════
// NormalizeEmail
// ══════════════════════════════════════════════════════════════════
[Fact]
public void NormalizeEmail_TrimsAndUppercases()
{
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
}
}
/// <summary>
/// Minimal in-memory IConfiguration implementation for unit tests.
/// Reads from a case-insensitive dictionary.
/// </summary>
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
{
private readonly Dictionary<string, string?> _data;
private readonly Dictionary<string, MemoryConfigSection> _sections;
public MemoryConfig(Dictionary<string, string?> data)
{
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
}
public string? this[string key]
{
get => _data.TryGetValue(key, out var val) ? val : null;
set => _data[key] = value ?? string.Empty;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
{
if (!_sections.TryGetValue(key, out var section))
{
section = new MemoryConfigSection(key, this);
_sections[key] = section;
}
return section;
}
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
{
public string Key => path.Split(':').Last();
public string Path => path;
public string? Value { get => root[path]; set => root[path] = value; }
public string? this[string key]
{
get => root[$"{path}:{key}"];
set => root[$"{path}:{key}"] = value;
}
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
=> root.GetSection($"{path}:{key}");
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
public IChangeToken GetReloadToken()
=> NeverToken.Instance;
}
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
internal sealed class NeverToken : IChangeToken
{
public static readonly NeverToken Instance = new();
public bool HasChanged => false;
public bool ActiveChangeCallbacks => false;
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
}
internal sealed class NoopDisposable : IDisposable
{
public static readonly NoopDisposable Instance = new();
public void Dispose() { }
}
+17
View File
@@ -0,0 +1,17 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ChatControllerTests
{
[Fact]
public void ChatController_RequiresAuthorization()
{
var attribute = typeof(ChatController).GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(attribute);
}
}
@@ -0,0 +1,232 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Regression tests for the SeedAudit-based owner-seed guard in EnsureDatabaseAsync.
/// Verifies that once SeedAudit contains "owner_created", subsequent calls to
/// EnsureDatabaseAsync (simulating pod restarts) do NOT reset the owner's password hash.
/// </summary>
public sealed class EnsureDatabaseSeedAuditTests
{
private const string SeedKey = "owner_created";
/// <summary>
/// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row.
/// </summary>
private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
const string originalPassword = "InitialOwnerPassword123!";
var originalHash = PasswordSecurity.Hash(originalPassword);
var owner = new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = originalHash,
Role = "owner"
};
db.Users.Add(owner);
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
return (db, owner, originalHash);
}
/// <summary>
/// Simulates the restart guard: if SeedAudit contains owner_created,
/// the owner password hash must not be changed to a newly generated hash.
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash()
{
// Arrange — seed the DB with an owner and a SeedAudit row
var (db, owner, originalHash) = await CreateSeededFixtureAsync();
// Sanity check: password hash starts as expected
Assert.Equal(originalHash, owner.PasswordHash);
Assert.True(PasswordSecurity.Verify("InitialOwnerPassword123!", owner.PasswordHash, out _));
// Act — simulate a password change by the user
const string newPassword = "ChangedOwnerPassword456!";
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
await db.SaveChangesAsync();
// Detach and re-read to confirm the change persisted
db.ChangeTracker.Clear();
var afterChange = await db.Users.FirstAsync(u => u.Id == owner.Id);
Assert.NotEqual(originalHash, afterChange.PasswordHash);
Assert.True(PasswordSecurity.Verify(newPassword, afterChange.PasswordHash, out _));
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterChange.PasswordHash, out _));
// Act — simulate EnsureDatabaseAsync on restart:
// It checks SeedAudit first; if owner_created exists, it returns immediately.
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded, "SeedAudit should contain owner_created after initial seed");
if (alreadySeeded)
{
// EnsureDatabaseAsync returns here — owner is NOT touched
}
// Assert — after the "restart", the password hash must still be the changed one
db.ChangeTracker.Clear();
var afterRestart = await db.Users.FirstAsync(u => u.Id == owner.Id);
Assert.Equal(afterChange.PasswordHash, afterRestart.PasswordHash);
Assert.NotEqual(originalHash, afterRestart.PasswordHash);
Assert.True(PasswordSecurity.Verify(newPassword, afterRestart.PasswordHash, out _),
"Changed password must still work after simulated restart");
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterRestart.PasswordHash, out _),
"Original seed password must NOT work after simulated restart");
}
/// <summary>
/// Simulates a full restart: creates a completely new DbContext (simulating a new pod),
/// and verifies the SeedAudit guard prevents owner re-seeding.
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword()
{
// Arrange — create and seed the first "instance"
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
Guid ownerId;
string changedHash;
// First "pod run": seed owner + SeedAudit, then change password
await using (var db1 = new NexusDbContext(options))
{
const string initialPassword = "SeedPassword123!";
var owner = new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
};
db1.Users.Add(owner);
db1.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db1.SaveChangesAsync();
ownerId = owner.Id;
// Password change
const string newPassword = "NewSecurePassword789!";
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
await db1.SaveChangesAsync();
changedHash = owner.PasswordHash;
}
// Act — second "pod run": new DbContext, simulate EnsureDatabaseAsync
await using (var db2 = new NexusDbContext(options))
{
var alreadySeeded = await db2.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded, "SeedAudit must persist across DbContext instances");
// EnsureDatabaseAsync would return here because alreadySeeded is true
// No user creation or password reset happens
var owner = await db2.Users.FirstAsync(u => u.Id == ownerId);
Assert.Equal(changedHash, owner.PasswordHash);
Assert.True(PasswordSecurity.Verify("NewSecurePassword789!", owner.PasswordHash, out _),
"Changed password must survive a full simulated restart (new DbContext)");
Assert.False(PasswordSecurity.Verify("SeedPassword123!", owner.PasswordHash, out _),
"Seed password must NOT work after restart");
}
}
/// <summary>
/// Verifies that if all users are deleted but SeedAudit still has owner_created,
/// a restart will NOT re-create the owner (the SeedAudit guard is the single
/// source of truth — preventing password drift even if the user table is wiped).
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner()
{
// Arrange
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
// SeedAudit exists from a prior run
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
// Users table is empty (simulating a wiped DB or fresh volume with existing SeedAudit)
var hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers);
// Act — simulate restart: SeedAudit check
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded);
// EnsureDatabaseAsync returns early because alreadySeeded is true
if (alreadySeeded)
{
// No owner is created
}
// Assert — owner was NOT created (SeedAudit prevents re-seed)
hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers,
"SeedAudit should prevent owner re-creation even when Users table is empty");
}
/// <summary>
/// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync
/// would proceed to seed a new owner (this is the pre-guard behavior,
/// documented here for completeness).
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner()
{
// Arrange
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
// No SeedAudit, no users — this is a fresh DB
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.False(alreadySeeded);
var hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers);
// Act — simulate the seed path (what EnsureDatabaseAsync would do when !alreadySeeded && !hasUsers)
if (!alreadySeeded && !hasUsers)
{
// This is what EnsureDatabaseAsync would do: create owner + seed audit
db.Users.Add(new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = PasswordSecurity.Hash("GeneratedTempPassword"),
Role = "owner"
});
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
}
// Assert — owner now exists
hasUsers = await db.Users.AnyAsync();
Assert.True(hasUsers);
Assert.True(await db.SeedAudits.AnyAsync(s => s.Key == SeedKey));
}
}
+496
View File
@@ -0,0 +1,496 @@
using System.Reflection;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Nexus.Api.Data;
using Nexus.Api.Controllers;
using Nexus.Api.DTOs;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class MissionControlPhaseTests
{
[Fact]
public void AgentConfigSave_IsBaoOwnerOnly()
{
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal("owner", authorize!.Roles);
}
[Fact]
public void TaskApprovalEndpoints_AreOwnerOnly()
{
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
}
[Fact]
public void GatewayActivityRedaction_RemovesSensitiveLines()
{
var text = OpenClawGatewayClient.RedactSensitiveText("""
Status: ok
Authorization: Bearer abc.def.ghi
Next step ready
X-Nexus-Api-Key: secret
""");
Assert.Contains("Status: ok", text);
Assert.Contains("Next step ready", text);
Assert.DoesNotContain("Bearer abc", text);
Assert.DoesNotContain("secret", text);
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
}
[Fact]
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
{
var now = DateTimeOffset.UtcNow;
var activity = new[]
{
new ActivityEvent
{
Type = "agent_task",
Message = "programmer completed repo scan",
CreatedAt = now.AddHours(-3)
}
};
var gateway = new[]
{
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
};
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
Assert.Equal("gateway-session-history", summary.Now.Source);
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
Assert.Contains("Working on redaction", summary.Now.Text);
Assert.Equal("derived-mixed", summary.Today.Source);
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
Assert.Contains("Working on redaction", summary.Today.Text);
Assert.Contains("Checking task mapping", summary.Today.Text);
Assert.Contains("programmer completed repo scan", summary.Today.Text);
}
[Fact]
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var liveUpdates = new LiveUpdateService();
var subscription = await liveUpdates.SubscribeAsync();
var repository = new ActivityRepository(db, liveUpdates);
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
});
var stored = await repository.GetRecentAsync(1);
Assert.Single(stored);
Assert.DoesNotContain("secret-token", stored[0].Message);
Assert.Contains("programmer", stored[0].Message);
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
var envelope = await subscription.Reader.ReadAsync();
Assert.Equal("activity.created", envelope.Type);
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
using var doc = JsonDocument.Parse(payloadJson);
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
Assert.Contains("programmer", agentIds);
}
[Fact]
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var repository = new ActivityRepository(db, new LiveUpdateService());
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent programmer: compile module"
});
await repository.AddAsync(new ActivityEvent
{
Type = "agent",
Message = "Command sent to agent reviewer: inspect module"
});
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
Assert.Single(programmerEvents);
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task GatewayInfo_ReportsVersionDrift()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("2026.07.08", info.Version);
Assert.Equal("2026.07.09", info.RequiredVersion);
Assert.Equal("drift", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.08", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
{
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Null(info.Version);
Assert.Equal("missing", info.VersionStatus);
Assert.False(info.VersionMatches);
Assert.NotNull(info.Warning);
Assert.Contains("2026.07.09", info.Warning!);
}
[Fact]
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
{
var client = CreateClient(request =>
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
};
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
return response;
}, requiredVersion: "2026.07.09");
var info = await client.GetGatewayInfoAsync();
Assert.True(info.Reachable);
Assert.Equal("matched", info.VersionStatus);
Assert.True(info.VersionMatches);
Assert.Null(info.Warning);
}
[Fact]
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
var client = CreateClient(request =>
{
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
{
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
object status = agentId switch
{
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
_ => new { status = "ready", model = "openai/gpt-5.5" }
};
return ToolResult(status);
}
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
var agents = await client.GetAgentsAsync();
Assert.Collection(agents.OrderBy(a => a.Id),
architekt =>
{
Assert.Equal("architekt", architekt.Id);
Assert.Equal("unsupported", architekt.StatusKind);
Assert.Equal("Unsupported", architekt.StatusLabel);
Assert.Equal("tool not available", architekt.StatusDetail);
},
iris =>
{
Assert.Equal("iris", iris.Id);
Assert.Equal("connected", iris.StatusKind);
Assert.Equal("Arbeitet", iris.StatusLabel);
},
programmer =>
{
Assert.Equal("programmer", programmer.Id);
Assert.Equal("stale", programmer.StatusKind);
Assert.Equal("Stale", programmer.StatusLabel);
Assert.NotNull(programmer.StatusDetail);
Assert.Contains("40m", programmer.StatusDetail!);
},
reviewer =>
{
Assert.Equal("reviewer", reviewer.Id);
Assert.Equal("error", reviewer.StatusKind);
Assert.Equal("Fehler", reviewer.StatusLabel);
Assert.Equal("gateway timeout", reviewer.StatusDetail);
});
}
[Fact]
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "original");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
Assert.NotNull(attempt.Failure);
Assert.Equal("validation_failed", attempt.Failure!.Code);
Assert.Equal("failed", attempt.Failure.Validation.Status);
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
{
var agentId = $"phase-p4-{Guid.NewGuid():N}";
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
Directory.CreateDirectory(workspacePath);
var configPath = Path.Combine(workspacePath, "TOOLS.md");
await File.WriteAllTextAsync(configPath, "before");
try
{
var service = new AgentConfigService();
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
Assert.NotNull(attempt.SaveResult);
var result = attempt.SaveResult!;
Assert.Equal("passed", result.Validation.Status);
Assert.Equal("markdown", result.Validation.FileKind);
Assert.Equal("created", result.Backup.Status);
Assert.True(result.Backup.BackupCreated);
Assert.Equal("not_supported", result.ReloadCheck.Status);
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
}
finally
{
Directory.Delete(workspacePath, recursive: true);
}
}
[Fact]
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
{
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"validation_failed",
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
new AgentConfigBackupResult("not_applicable", false),
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
var activityRepo = new CapturingActivityRepository();
var controller = new AgentsController(
new FakeAgentService(),
new FakeAgentRuntime(),
activityRepo,
configService,
new FakeDashboardService(),
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, "bao"),
new Claim(ClaimTypes.Role, "owner")
], "TestAuth"))
}
}
};
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
var audit = Assert.Single(activityRepo.Added);
Assert.Equal("config_audit", audit.Type);
Assert.Contains("validation=failed", audit.Message);
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
}
private static OpenClawGatewayClient CreateClient(
Func<HttpRequestMessage, HttpResponseMessage> responder,
string? requiredVersion = null,
string[]? agentIds = null)
{
var configValues = new Dictionary<string, string?>
{
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
};
if (agentIds is not null)
{
var configPath = Path.GetTempFileName();
File.WriteAllText(configPath, JsonSerializer.Serialize(new
{
agents = new
{
list = agentIds.Select(id => new { id }).ToArray()
}
}));
configValues["AgentConfigPath"] = configPath;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues)
.Build();
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
{
BaseAddress = new Uri("http://gateway.local")
};
return new OpenClawGatewayClient(httpClient, configuration);
}
private static HttpResponseMessage ToolResult(object payload)
=> new(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(
JsonSerializer.Serialize(new { ok = true, result = payload }),
Encoding.UTF8,
"application/json")
};
}
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(responder(request));
}
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
{
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
=> Task.FromResult<AgentConfigFileContent?>(null);
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
=> Task.FromResult(attempt);
}
file sealed class CapturingActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeAgentService : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> Task.FromResult<AgentDetail?>(null);
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
}
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
{
public string Name => "fake";
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+1
View File
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
+149
View File
@@ -0,0 +1,149 @@
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class NexusMcpToolsTests
{
[Fact]
public void NexusMcpTools_RegistersExpectedToolNames()
{
var toolNames = typeof(NexusMcpTools)
.GetMethods()
.Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false)
.OfType<McpServerToolAttribute>()
.FirstOrDefault())
.Where(attribute => attribute is not null)
.Select(attribute => attribute!.Name ?? string.Empty)
.Order()
.ToArray();
Assert.Equal(
[
"nexus_agent_overview",
"nexus_append_activity",
"nexus_create_child_task",
"nexus_create_task",
"nexus_get_activity",
"nexus_get_board",
"nexus_get_children",
"nexus_get_task",
"nexus_handoff",
"nexus_update_status"
], toolNames);
}
[Fact]
public void NexusMcpTaskState_OnlyContainsCanonicalStates()
{
Assert.Equal(
[
nameof(NexusMcpTaskState.Backlog),
nameof(NexusMcpTaskState.InProgress),
nameof(NexusMcpTaskState.Blocked),
nameof(NexusMcpTaskState.Done),
nameof(NexusMcpTaskState.Review)
], Enum.GetNames<NexusMcpTaskState>());
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
}
[Fact]
public async Task McpTools_ReadAndWrite_UseBridgeService()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("iris");
var tools = CreateTools(fixture);
var createResult = await tools.CreateTask(
title: "MCP parent",
detail: "Created through MCP tool facade",
priority: "High",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(createResult.Ok);
Assert.NotNull(createResult.Data);
Assert.Equal("MCP parent", createResult.Data!.Title);
var activityResult = await tools.AppendActivity(
createResult.Data.Id,
"MCP checkpoint",
"comment",
CancellationToken.None);
Assert.True(activityResult.Ok);
Assert.Equal("MCP checkpoint", activityResult.Data!.Message);
var statusResult = await tools.UpdateStatus(
createResult.Data.Id,
NexusMcpTaskState.InProgress,
CancellationToken.None);
Assert.True(statusResult.Ok);
Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State);
var board = await tools.GetBoard(CancellationToken.None);
Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id);
var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None);
Assert.True(taskResult.Ok);
Assert.Equal("MCP parent", taskResult.Data!.Title);
}
[Fact]
public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var task = await fixture.TaskService.CreateDashboardTaskAsync(
"Programmer cannot move",
"State changes stay with Iris/Bao.",
"iris",
"Normal",
"programmer",
null,
CancellationToken.None);
fixture.SetCallerAgent("programmer");
var tools = CreateTools(fixture);
var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal("nexus_update_status", result.Command);
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task McpTools_ServiceKey_ResolvesAsNexusSystem()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext(
headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
});
var tools = CreateTools(fixture);
var result = await tools.CreateTask(
title: "System MCP task",
assignedTo: "iris",
ct: CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("bao", result.Data!.Source);
}
private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture)
=> new(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.HttpContextAccessor,
fixture.Configuration,
NullLogger<NexusMcpTools>.Instance);
}
+2
View File
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException(); public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(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<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException(); public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(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> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
+427
View File
@@ -0,0 +1,427 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class StaleTaskRecoveryTests
{
[Fact]
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stale in progress",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
await fixture.ActivityRepository.AddAsync(new ActivityEvent
{
Type = "comment",
Message = "Previous agent note",
TaskId = staleInProgress.Id,
CreatedAt = staleTimestamp.AddMinutes(15)
}, CancellationToken.None);
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Blocked task",
State = "Blocked",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Review task",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Done task",
State = "Done",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Backlog task",
State = "Backlog",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
}, CancellationToken.None);
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
CreatedAt = staleTimestamp
}, CancellationToken.None);
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(1, resetCount);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
Assert.NotNull(resetActivity);
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
{
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var taskId = Guid.NewGuid();
var staleCandidate = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "In progress",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var currentTask = new WorkTask
{
Id = taskId,
Title = "Changed during recovery scan",
State = "Review",
Source = "iris",
UpdatedAt = staleTimestamp,
CreatedAt = staleTimestamp
};
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
var activityRepository = new FakeActivityRepository();
var liveUpdateService = new FakeLiveUpdateService();
var recoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService,
new FakeNotificationService());
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
Assert.Equal(0, resetCount);
Assert.Equal("Review", currentTask.State);
Assert.Equal(0, taskRepository.ResetCount);
Assert.Empty(activityRepository.Added);
Assert.Equal(0, liveUpdateService.PublishCount);
}
[Fact]
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stalled agent task",
State = "In progress",
Source = "iris",
UpdatedAt = stalledTimestamp,
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(1, flagged);
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(0, flaggedAgain);
}
[Fact]
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StalledMinutes = 45,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
Assert.Equal(7, flaggedCount);
}
[Fact]
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
var services = new ServiceCollection();
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
await using var provider = services.BuildServiceProvider();
var backgroundService = new StaleTaskRecoveryBackgroundService(
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StalledMinutes = 40,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await backgroundService.StartAsync(cts.Token);
await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
}
[Fact]
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
{
const string key = "TaskRecovery__StaleHours";
var originalValue = Environment.GetEnvironmentVariable(key);
try
{
Environment.SetEnvironmentVariable(key, "5");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
})
.AddEnvironmentVariables()
.Build();
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
Assert.NotNull(options);
Assert.Equal(5, options!.StaleHours);
Assert.Equal(30, options.IntervalMinutes);
}
finally
{
Environment.SetEnvironmentVariable(key, originalValue);
}
}
}
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
{
public int ResetCount { get; private set; }
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask> { staleCandidate });
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
public Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (id != currentTask.Id
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|| currentTask.UpdatedAt >= staleBefore)
{
return Task.FromResult(false);
}
ResetCount++;
currentTask.State = "Backlog";
currentTask.UpdatedAt = updatedAt;
return Task.FromResult(true);
}
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> Task.FromResult(new List<WorkTask>());
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
=> Task.FromResult(task);
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
=> Task.CompletedTask;
public Task<int> CountAsync(CancellationToken ct = default)
=> Task.FromResult(0);
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
=> Task.FromResult(0);
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
=> Task.FromResult<WorkTask?>(null);
}
file sealed class FakeActivityRepository : IActivityRepository
{
public List<ActivityEvent> Added { get; } = [];
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
string? type,
string? sort,
int page,
int pageSize,
CancellationToken ct = default)
=> Task.FromResult((new List<ActivityEvent>(), 0));
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> Task.FromResult(new List<ActivityEvent>());
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{
Added.Add(activity);
return Task.FromResult(activity);
}
}
file sealed class FakeLiveUpdateService : ILiveUpdateService
{
public int PublishCount { get; private set; }
public long CurrentSequence => PublishCount;
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
=> throw new NotSupportedException();
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
{
PublishCount++;
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
}
}
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{
public int FlagCallCount { get; private set; }
public int ResetCallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; }
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
FlagCallCount++;
LastThreshold = stalledThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
ResetCallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class FakeNotificationService : INotificationService
{
public List<Notification> Created { get; } = [];
public 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, TaskId = taskId };
Created.Add(notification);
return Task.FromResult(notification);
}
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;
public T Get(string? name) => CurrentValue;
public IDisposable? OnChange(Action<T, string?> listener) => null;
}
+559
View File
@@ -0,0 +1,559 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class TaskWorkflowTests
{
[Fact]
public async Task CreateAgentTaskAsync_PreservesConfiguredAssigneeAndBacklogState_WhenPlannedChildTask()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"PO spec",
"Prepare specification",
"iris",
"Medium",
"product-owner",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
Assert.Equal("Backlog", child.State);
Assert.Equal("product-owner", child.AssignedTo);
Assert.Equal("programmer-fast", child.ExpectedFrom);
Assert.True(child.IsAgentTask);
}
[Fact]
public async Task GetDashboardTaskByIdAsync_MapsChildDelegationAndActivity()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
var child = await fixture.TaskService.CreateAgentTaskAsync(
"Implement",
"Code changes",
"iris",
"High",
"programmer-fast",
"programmer-fast",
parent.Id,
startsInProgress: false,
initialState: null,
ct: CancellationToken.None);
var dto = await fixture.TaskService.GetDashboardTaskByIdAsync(child.Id, CancellationToken.None);
Assert.NotNull(dto);
Assert.True(dto!.HasVisibleDelegation);
Assert.NotNull(dto.LastActivityMessage);
Assert.Equal("programmer-fast", dto.AssignedTo);
Assert.Equal("programmer-fast", dto.ExpectedFrom);
}
[Fact]
public async Task BridgeGetChildTasksAsync_ReturnsMappedActivityAndVisibleDelegation()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", null, "iris", "High", "iris", null, CancellationToken.None);
await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Review",
"Review implementation",
"iris",
"Medium",
"reviewer",
"reviewer",
startsInProgress: false,
ct: CancellationToken.None);
var children = await fixture.TaskBridgeService.GetChildTasksAsync(parent.Id, CancellationToken.None);
var child = Assert.Single(children);
Assert.True(child.HasVisibleDelegation);
Assert.NotNull(child.LastActivityMessage);
Assert.Equal("reviewer", child.AssignedTo);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AcceptsServiceKeyWithoutConfiguredNexusSystemAgent()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task DashboardController_GetBoard_AcceptsServiceKeyWithoutJwt()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new DashboardController(
new FakeDashboardService(),
fixture.TaskService,
fixture.ActivityRepository,
new HttpContextAccessor(),
fixture.AgentService,
fixture.Configuration,
fixture.NotificationService,
fixture.LiveUpdateService)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task TasksController_GetBoard_AcceptsProgrammerFastHeader()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "programmer-fast"
})
}
};
var result = await controller.GetBoard(CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_Anonymous_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext()
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
}
[Fact]
public async Task TasksController_ResetStale_UnknownAgentHeader_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Agent-Id"] = "unknown-agent"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_OrdinaryJwtUser_IsForbidden()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status403Forbidden);
}
[Fact]
public async Task TasksController_ResetStale_ServiceKey_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
{
["X-Nexus-Api-Key"] = "test-service-key"
})
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task TasksController_ResetStale_IrisHeader_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(agentId: "iris")
}
};
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
AssertStatusCode(result, StatusCodes.Status200OK);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_OrdinaryJwtUser_IsUnauthorized()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("user-1", "user"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<UnauthorizedObjectResult>(result.Result);
}
[Fact]
public async Task GatewayBridgeController_GetBoard_AdminJwt_IsAllowed()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var controller = new GatewayBridgeController(
fixture.TaskBridgeService,
fixture.AgentService,
fixture.Configuration,
NullLogger<GatewayBridgeController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = TaskWorkflowFixture.CreateHttpContext(user: TaskWorkflowFixture.CreateUser("bao", "admin"))
}
};
var result = await controller.GetBoard(CancellationToken.None);
Assert.IsType<OkObjectResult>(result.Result);
}
[Fact]
public async Task CreateChildTaskAsync_TransitionsBacklogParent_WhenCallerIsProgrammerFast()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
fixture.SetCallerAgent("programmer-fast");
var parent = await fixture.TaskService.CreateDashboardTaskAsync(
"Parent", "Coordination", "iris", "High", "iris", null, CancellationToken.None);
var result = await fixture.TaskBridgeService.CreateChildTaskAsync(
parent.Id,
"Implement",
"Ship the change",
"programmer-fast",
"Medium",
"programmer-fast",
"programmer-fast",
startsInProgress: false,
ct: CancellationToken.None);
var updatedParent = await fixture.TaskService.GetByIdAsync(parent.Id, CancellationToken.None);
Assert.Equal(TaskBridgeOutcome.Success, result.Outcome);
Assert.NotNull(updatedParent);
Assert.Equal("In progress", updatedParent!.State);
}
private static void AssertStatusCode(IResult result, int expectedStatusCode)
{
if (expectedStatusCode == StatusCodes.Status403Forbidden)
{
Assert.Equal("Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult", result.GetType().FullName);
return;
}
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
Assert.Equal(expectedStatusCode, statusResult.StatusCode);
}
}
internal sealed class TaskWorkflowFixture : IAsyncDisposable
{
private readonly NexusDbContext _db;
private TaskWorkflowFixture(
NexusDbContext db,
IConfiguration configuration,
ITaskRepository taskRepository,
IActivityRepository activityRepository,
INotificationService notificationService,
ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService,
ITaskService taskService,
ITaskBridgeService taskBridgeService,
IAgentService agentService,
HttpContextAccessor httpContextAccessor)
{
_db = db;
Configuration = configuration;
TaskRepository = taskRepository;
ActivityRepository = activityRepository;
NotificationService = notificationService;
LiveUpdateService = liveUpdateService;
StaleTaskRecoveryService = staleTaskRecoveryService;
TaskService = taskService;
TaskBridgeService = taskBridgeService;
AgentService = agentService;
HttpContextAccessor = httpContextAccessor;
}
public IConfiguration Configuration { get; }
public ITaskRepository TaskRepository { get; }
public IActivityRepository ActivityRepository { get; }
public INotificationService NotificationService { get; }
public ILiveUpdateService LiveUpdateService { get; }
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
public ITaskService TaskService { get; }
public ITaskBridgeService TaskBridgeService { get; }
public IAgentService AgentService { get; }
public HttpContextAccessor HttpContextAccessor { get; }
public static async Task<TaskWorkflowFixture> CreateAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
await db.Database.EnsureCreatedAsync();
var configPath = CreateAgentConfigFile();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath,
["NexusApiKey"] = "test-service-key"
})
.Build();
var agentService = new AgentService(configuration, new FakeRuntime());
var liveUpdateService = new LiveUpdateService();
var activityRepository = new ActivityRepository(db, liveUpdateService);
var taskRepository = new TaskRepository(db);
var notificationService = new NotificationService(db, liveUpdateService);
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService,
notificationService);
var taskService = new TaskService(
taskRepository,
activityRepository,
notificationService,
agentService,
httpContextAccessor,
liveUpdateService,
staleTaskRecoveryService);
var taskBridgeService = new TaskBridgeService(
taskService,
agentService,
activityRepository,
notificationService,
liveUpdateService);
return new TaskWorkflowFixture(
db,
configuration,
taskRepository,
activityRepository,
notificationService,
liveUpdateService,
staleTaskRecoveryService,
taskService,
taskBridgeService,
agentService,
httpContextAccessor);
}
public static DefaultHttpContext CreateHttpContext(
string? agentId = null,
Dictionary<string, string>? headers = null,
ClaimsPrincipal? user = null)
{
var httpContext = new DefaultHttpContext();
if (!string.IsNullOrWhiteSpace(agentId))
httpContext.Request.Headers["X-Agent-Id"] = agentId;
if (headers is not null)
{
foreach (var (key, value) in headers)
httpContext.Request.Headers[key] = value;
}
httpContext.User = user ?? new ClaimsPrincipal(new ClaimsIdentity());
return httpContext;
}
public static ClaimsPrincipal CreateUser(string userId, string role)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
public void SetCallerAgent(string agentId)
{
HttpContextAccessor.HttpContext = CreateHttpContext(agentId: agentId);
}
public async ValueTask DisposeAsync()
{
await _db.DisposeAsync();
}
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{ "id": "iris", "name": "iris", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "product-owner", "name": "product-owner", "model": { "primary": "openai/gpt-5.5" } },
{ "id": "programmer", "name": "programmer", "model": { "primary": "openai/gpt-5.4" } },
{ "id": "programmer-fast", "name": "programmer-fast", "model": { "primary": "openai/gpt-5.3-codex-spark" } },
{ "id": "reviewer", "name": "reviewer", "model": { "primary": "openai/gpt-5.5" } }
]
}
}
""");
return path;
}
}
file sealed class FakeDashboardService : IDashboardService
{
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
public List<ModelOption> GetAvailableModels() => [];
}
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
*.user
*.suo
.vs/
.vscode/
.git/
.gitignore
.env
*.log
+145 -8
View File
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using System.Security.Claims;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
@@ -14,6 +16,7 @@ public class AgentsController(
IAgentRuntime runtime, IAgentRuntime runtime,
IActivityRepository activityRepo, IActivityRepository activityRepo,
IAgentConfigService agentConfigService, IAgentConfigService agentConfigService,
IDashboardService dashboardService,
ILogger<AgentsController> logger) : ControllerBase ILogger<AgentsController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -39,7 +42,25 @@ public class AgentsController(
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct) public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
{ {
var items = await activityRepo.GetByAgentAsync(id, 50, ct); var items = await activityRepo.GetByAgentAsync(id, 50, ct);
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })); var activity = items
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
.ToList();
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
foreach (var entry in gatewayEntries)
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
return Results.Ok(activity
.OrderByDescending(x => x.At)
.Take(50));
}
[HttpGet("{id}/summary")]
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
{
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
} }
[HttpPost("{id}/command")] [HttpPost("{id}/command")]
@@ -84,20 +105,48 @@ public class AgentsController(
} }
[HttpPut("{id}/config/{fileName}")] [HttpPut("{id}/config/{fileName}")]
[Authorize(Roles = "owner")]
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 (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)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
try try
{ {
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct); var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
return result is null var caller = DescribeCaller(HttpContext.User);
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt }); if (attempt.Failure is not null)
{
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
}, ct);
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = attempt.Failure.Validation.Errors.ToArray()
});
}
var result = attempt.SaveResult!;
await activityRepo.AddAsync(new Data.ActivityEvent
{
Type = "config_audit",
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
}, ct);
return Results.Ok(new
{
result.FileName,
result.Size,
result.ModifiedAt,
result.Validation,
result.Backup,
ReloadCheck = result.ReloadCheck
});
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
@@ -116,4 +165,92 @@ public class AgentsController(
statusCode: StatusCodes.Status500InternalServerError); statusCode: StatusCodes.Status500InternalServerError);
} }
} }
private static string DescribeCaller(ClaimsPrincipal user)
{
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
}
}
public sealed record AgentActivityResponse(
long? Id,
string Type,
string Message,
DateTimeOffset At,
string Source,
string? RelativeTime = null
);
public sealed record AgentSummaryResponse(
AgentSummaryItemResponse Now,
AgentSummaryItemResponse Today,
DateTimeOffset GeneratedAt
);
public sealed record AgentSummaryItemResponse(
string Text,
string Source,
DateTimeOffset? Timestamp
);
public static class AgentSummaryBuilder
{
public static AgentSummaryResponse Build(
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
DateTimeOffset nowUtc)
{
var points = activity
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
.OrderByDescending(point => point.Timestamp)
.ToList();
var current = points.FirstOrDefault();
var now = current is null
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
var windowStart = nowUtc.AddHours(-24);
var todayPoints = points
.Where(point => point.Timestamp >= windowStart)
.ToList();
AgentSummaryItemResponse today;
if (todayPoints.Count == 0)
{
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
}
else
{
var snippets = todayPoints
.Select(point => point.Text)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(3)
.ToList();
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
if (extraCount > 0)
text += $" (+{extraCount} weitere)";
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
? todayPoints[0].Source
: "derived-mixed";
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
}
return new AgentSummaryResponse(now, today, nowUtc);
}
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
} }
+2
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[Authorize]
[ApiController] [ApiController]
[Route("api/v1/chat")] [Route("api/v1/chat")]
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
+99 -10
View File
@@ -16,6 +16,8 @@ public class DashboardController(
ITaskService taskService, ITaskService taskService,
IActivityRepository activityService, IActivityRepository activityService,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
IAgentService agentService,
IConfiguration configuration,
INotificationService notificationService, INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ControllerBase ILiveUpdateService liveUpdateService) : ControllerBase
{ {
@@ -54,6 +56,10 @@ public class DashboardController(
public async Task<List<QueueItem>> GetQueue(CancellationToken ct) public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct); => await dashboardService.GetQueueAsync(ct);
[HttpGet("gateway")]
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
=> await dashboardService.GetGatewayInfoAsync(ct);
[HttpDelete("queue/{id}")] [HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{ {
@@ -191,9 +197,15 @@ public class DashboardController(
// ── Task Board Endpoints ── // ── Task Board Endpoints ──
[AllowAnonymous]
[HttpGet("tasks/board")] [HttpGet("tasks/board")]
public async Task<BoardResponse> GetBoard(CancellationToken ct) public async Task<ActionResult<BoardResponse>> GetBoard(CancellationToken ct)
=> await taskService.GetBoardAsync(ct); {
if (!await CanReadBoardAsync(ct))
return Unauthorized();
return Ok(await taskService.GetBoardAsync(ct));
}
[HttpGet("live")] [HttpGet("live")]
public async Task Live( public async Task Live(
@@ -224,15 +236,25 @@ public class DashboardController(
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct); var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20)); using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested) // PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
{ // Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
// mit einer InvalidOperationException.
var readTask = subscription.Reader.ReadAsync(ct).AsTask(); var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask(); var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
try
{
while (!ct.IsCancellationRequested)
{
var completed = await Task.WhenAny(readTask, heartbeatTask); var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask) if (completed == readTask)
{ {
var envelope = await readTask; var envelope = await readTask;
readTask = subscription.Reader.ReadAsync(ct).AsTask();
if (envelope.Type == "notifications.snapshot") if (envelope.Type == "notifications.snapshot")
{ {
var snapshot = envelope.Payload as NotificationSnapshotDto var snapshot = envelope.Payload as NotificationSnapshotDto
@@ -251,12 +273,24 @@ public class DashboardController(
envelope, envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live"))); new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
} }
else if (await heartbeatTask) else
{ {
var ticked = await heartbeatTask;
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
if (!ticked) break;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live")); await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
} }
} }
} }
catch (OperationCanceledException)
{
// Client hat die Verbindung beendet — normal.
}
catch (System.Threading.Channels.ChannelClosedException)
{
// Subscription serverseitig geschlossen — Stream regulär beenden.
}
}
[HttpPatch("tasks/{id:guid}/move")] [HttpPatch("tasks/{id:guid}/move")]
public async Task<ActionResult<DashboardTaskDto>> MoveTask( public async Task<ActionResult<DashboardTaskDto>> MoveTask(
@@ -288,6 +322,52 @@ public class DashboardController(
}; };
} }
// ── Review-Aktionen (Bao/Iris) ──
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/approve")]
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
{
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
var result = await taskService.ApproveReviewAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/request-changes")]
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary> /// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim. /// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly). /// Falls back to empty string (which authorization helpers reject accordingly).
@@ -319,10 +399,7 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")] [HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct) public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{ => Ok(await taskService.GetChildTaskDtosAsync(id, ct));
var children = await taskService.GetChildTasksAsync(id, ct);
return Ok(children.Select(MapToDto).ToList());
}
[HttpGet("tasks/{id:guid}")] [HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct) public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
@@ -401,7 +478,7 @@ public class DashboardController(
var task = await taskService.CreateAgentTaskAsync( var task = await taskService.CreateAgentTaskAsync(
request.Title, request.Detail, request.Source ?? "iris", request.Title, request.Detail, request.Source ?? "iris",
request.Priority, request.AssignedTo, request.ExpectedFrom, request.Priority, request.AssignedTo, request.ExpectedFrom,
request.ParentTaskId, ct); request.ParentTaskId, request.StartsInProgress, request.InitialState, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
} }
@@ -415,4 +492,16 @@ public class DashboardController(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
t.IsAgentTask, t.ExpectedFrom); t.IsAgentTask, t.ExpectedFrom);
private async Task<bool> CanReadBoardAsync(CancellationToken ct)
{
var allowedAgent = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
if (!string.IsNullOrWhiteSpace(allowedAgent))
return true;
if (RequestAuthorizationHelper.HasValidServiceKey(HttpContext, configuration))
return true;
return User.Identity?.IsAuthenticated == true;
}
} }
+12 -5
View File
@@ -38,6 +38,7 @@ namespace Nexus.Api.Controllers;
public class GatewayBridgeController( public class GatewayBridgeController(
ITaskBridgeService bridge, ITaskBridgeService bridge,
IAgentService agentService, IAgentService agentService,
IConfiguration configuration,
ILogger<GatewayBridgeController> logger) : ControllerBase ILogger<GatewayBridgeController> logger) : ControllerBase
{ {
private const string ApikeyErrorMessage = private const string ApikeyErrorMessage =
@@ -101,6 +102,7 @@ public class GatewayBridgeController(
priority: command.Priority ?? "Normal", priority: command.Priority ?? "Normal",
assignedTo: command.AssignedTo, assignedTo: command.AssignedTo,
expectedFrom: command.ExpectedFrom ?? command.AssignedTo, expectedFrom: command.ExpectedFrom ?? command.AssignedTo,
startsInProgress: command.StartsInProgress,
ct: ct); ct: ct);
return MapResult(result, "create_child_task"); return MapResult(result, "create_child_task");
@@ -232,12 +234,13 @@ public class GatewayBridgeController(
private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct) private async Task<(bool Success, string AgentId, ActionResult? ErrorResult)> TryResolveAgentAsync(CancellationToken ct)
{ {
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct); var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault(); var agentHeader = Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader)) if (!string.IsNullOrWhiteSpace(agentHeader))
{ {
var normalizedHeader = agentHeader.Trim().ToLowerInvariant(); var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedAgentIds.Contains(normalizedHeader)) if (allowedActorIds.Contains(normalizedHeader))
return (true, normalizedHeader, null); return (true, normalizedHeader, null);
logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback", logger.LogWarning("Bridge: ignoring unknown X-Agent-Id '{AgentId}' from {Ip} and continuing auth fallback",
@@ -248,14 +251,17 @@ public class GatewayBridgeController(
if (User.Identity?.IsAuthenticated == true) if (User.Identity?.IsAuthenticated == true)
{ {
var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant(); var normalizedClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedAgentIds.Contains(normalizedClaim)) if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return (true, normalizedClaim, null); return (true, normalizedClaim, null);
if (User.IsInRole("owner") || User.IsInRole("admin") || User.IsInRole("member")) // Browser JWT fallback is intentionally restricted to board owners/admins.
// Agent/service traffic should authenticate as an allowed agent or service principal.
if (User.IsInRole("owner") || User.IsInRole("admin"))
return (true, "bao", null); return (true, "bao", null);
} }
if (User.IsInRole("Service") && allowedAgentIds.Contains("nexus-system")) if (RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration) &&
allowedActorIds.Contains("nexus-system"))
return (true, "nexus-system", null); return (true, "nexus-system", null);
var unauthorized = Unauthorized(new { error = ApikeyErrorMessage }); var unauthorized = Unauthorized(new { error = ApikeyErrorMessage });
@@ -345,7 +351,8 @@ public sealed record BridgeCreateChildTaskCommand(
string? Detail = null, string? Detail = null,
string? Priority = null, string? Priority = null,
string? AssignedTo = null, string? AssignedTo = null,
string? ExpectedFrom = null string? ExpectedFrom = null,
bool StartsInProgress = false
); );
public sealed record BridgeUpdateStatusCommand(string State); public sealed record BridgeUpdateStatusCommand(string State);
+50 -18
View File
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Models; using Nexus.Api.Models;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
@@ -10,7 +12,11 @@ namespace Nexus.Api.Controllers;
[Authorize] [Authorize]
[ApiController] [ApiController]
[Route("api/v1/tasks")] [Route("api/v1/tasks")]
public class TasksController(ITaskService taskService, IAgentService agentService) : ControllerBase public class TasksController(
ITaskService taskService,
IAgentService agentService,
IConfiguration configuration,
IActivityRepository activityRepository) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
@@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpGet("pending-approval")] [HttpGet("pending-approval")]
[Authorize(Roles = "owner")]
public async Task<IResult> GetPendingApproval(CancellationToken ct) public async Task<IResult> GetPendingApproval(CancellationToken ct)
{ {
var pending = await taskService.GetPendingApprovalAsync(ct); var pending = await taskService.GetPendingApprovalAsync(ct);
@@ -34,9 +41,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpPost("{id:guid}/approve")] [HttpPost("{id:guid}/approve")]
[Authorize(Roles = "owner")]
public async Task<IResult> Approve(Guid id, CancellationToken ct) public async Task<IResult> Approve(Guid id, CancellationToken ct)
{ {
var result = await taskService.ApproveAsync(id, ct); var result = await taskService.ApproveAsync(id, ct);
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
return result.Outcome switch return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => Results.NotFound(), TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -49,9 +58,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
} }
[HttpPost("{id:guid}/reject")] [HttpPost("{id:guid}/reject")]
[Authorize(Roles = "owner")]
public async Task<IResult> Reject(Guid id, CancellationToken ct) public async Task<IResult> Reject(Guid id, CancellationToken ct)
{ {
var result = await taskService.RejectAsync(id, ct); var result = await taskService.RejectAsync(id, ct);
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
return result.Outcome switch return result.Outcome switch
{ {
TaskOperationOutcome.NotFound => Results.NotFound(), TaskOperationOutcome.NotFound => Results.NotFound(),
@@ -117,12 +128,12 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr. /// X-Nexus-Api-Key / JWT. Kein [AllowAnonymous] mehr.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen. /// Für Agent-zu-Agent-Kommunikation den /api/bridge/board Endpunkt nutzen.
/// </summary> /// </summary>
[AllowAnonymous]
[HttpGet("board")] [HttpGet("board")]
public async Task<IResult> GetBoard(CancellationToken ct) public async Task<IResult> GetBoard(CancellationToken ct)
{ {
// Erfordert mindestens einen identifizierbaren Agent-Aufrufer var agentHeader = await RequestAuthorizationHelper.ResolveAllowedAgentHeaderAsync(HttpContext, agentService, ct);
var agentHeader = await GetAllowedAgentHeaderAsync(ct); var isApiKey = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isApiKey = HttpContext.User.IsInRole("Service");
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; var isAuth = HttpContext.User.Identity?.IsAuthenticated == true;
if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth) if (string.IsNullOrWhiteSpace(agentHeader) && !isApiKey && !isAuth)
@@ -136,33 +147,54 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
/// Wird vom Iris Autonomous Worker genutzt. /// Wird vom Iris Autonomous Worker genutzt.
/// ///
/// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER /// SICHERHEIT: Erfordert X-Agent-Id Header (nur iris) ODER
/// X-Nexus-Api-Key / JWT-authenticated user. /// X-Nexus-Api-Key / Service-Principal ODER owner/admin JWT.
/// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen. /// Für Agent-zu-Agent-Kommunikation den /api/bridge Endpunkt nutzen.
/// </summary> /// </summary>
[AllowAnonymous]
[HttpPost("reset-stale")] [HttpPost("reset-stale")]
public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct) public async Task<IResult> ResetStale([FromBody] ResetStaleRequest request, CancellationToken ct)
{ {
var agentHeader = await GetAllowedAgentHeaderAsync(ct); var agentHeaderResolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(HttpContext, agentService, ct);
var isApiKey = HttpContext.User.IsInRole("Service"); var isService = RequestAuthorizationHelper.IsAuthenticatedService(HttpContext, configuration);
var isAuth = HttpContext.User.Identity?.IsAuthenticated == true; var isPrivilegedUser = RequestAuthorizationHelper.IsPrivilegedUser(HttpContext);
var isIris = string.Equals(agentHeaderResolution.AgentId, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isService && !isPrivilegedUser)
{
// A presented but unrecognized agent header is an invalid credential, not a missing one.
if (HttpContext.User.Identity?.IsAuthenticated == true || agentHeaderResolution.HeaderProvided)
return Results.Forbid();
// Nur iris, nexus-system (ApiKey) oder JWT-authenticated user
var isIris = string.Equals(agentHeader, "iris", StringComparison.OrdinalIgnoreCase);
if (!isIris && !isApiKey && !isAuth)
return Results.Unauthorized(); return Results.Unauthorized();
}
var count = await taskService.ResetStaleAsync(request.StaleHours, ct); var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
return Results.Ok(new ResetStaleResponse(count)); return Results.Ok(new ResetStaleResponse(count));
} }
private async Task<string?> GetAllowedAgentHeaderAsync(CancellationToken ct) private async Task WriteApprovalAuditAsync(
Guid taskId,
string action,
TaskOperationOutcome outcome,
string? state,
CancellationToken ct)
{ {
var headerValue = HttpContext.Request.Headers["X-Agent-Id"].FirstOrDefault(); await activityRepository.AddAsync(new ActivityEvent
if (string.IsNullOrWhiteSpace(headerValue)) {
return null; Type = "task_approval_audit",
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
TaskId = taskId
}, ct);
}
var normalized = headerValue.Trim().ToLowerInvariant(); private static string DescribeCaller(ClaimsPrincipal user)
var allowed = await agentService.GetAllowedAgentIdsAsync(ct); {
return allowed.Contains(normalized) ? normalized : null; var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst(ClaimTypes.Email)?.Value
?? user.Identity?.Name
?? "unknown";
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
return $"{role}:{subject}".ToLowerInvariant();
} }
} }
+6
View File
@@ -6,6 +6,12 @@ COPY . .
RUN dotnet publish -c Release -o /app/publish 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
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
LABEL org.opencontainers.image.title="Nexus API" \
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
org.opencontainers.image.version="${NEXUS_VERSION}" \
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN apk add --no-cache curl RUN apk add --no-cache curl
@@ -15,6 +15,10 @@ public static class ApplicationBuilderExtensions
/// Applies pending EF Core migrations and seeds the initial owner account if none exist. /// 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 /// 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. /// are deleted — the DB is the single source of truth for the owner password after first seed.
///
/// Single-transaction guarantee: if the seed block is entered at all (user creation needed
/// or just the audit-log write), the SeedAudit row is written inside the same transaction
/// so that a crash mid-way can never leave the DB in a re-seedable state.
/// </summary> /// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app) public static async Task EnsureDatabaseAsync(this WebApplication app)
{ {
@@ -30,25 +34,30 @@ public static class ApplicationBuilderExtensions
if (alreadySeeded) if (alreadySeeded)
return; return;
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant(); var ownerEmail = configuration["Bootstrap:OwnerEmail"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync(); var hasUsers = await db.Users.AnyAsync();
// ── Double-check SeedAudit after the migration — if another pod wrote it
// while we were reading, bail out early. ──
alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == seedKey);
if (alreadySeeded)
return;
// ── Use a strategy-based transaction so the user + audit row are
// persisted atomically. If the DB crashes after SaveChanges the
// entire transaction is rolled back, preventing partial-seed states.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
if (!hasUsers) if (!hasUsers)
{ {
if (string.IsNullOrWhiteSpace(ownerEmail)) if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup."); throw new InvalidOperationException("Bootstrap:OwnerEmail is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName) var initialDisplayName = PasswordHelper.BuildOwnerDisplayName(ownerEmail);
? PasswordHelper.BuildOwnerDisplayName(ownerEmail) var initialPassword = PasswordHelper.GenerateTemporaryPassword();
: 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 db.Users.Add(new NexusUser
{ {
@@ -58,18 +67,16 @@ public static class ApplicationBuilderExtensions
PasswordHash = PasswordSecurity.Hash(initialPassword), PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner" Role = "owner"
}); });
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}"); Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
} }
}
// Record the seed attempt regardless of whether users already existed. // Record the seed attempt regardless of whether users already existed.
// This prevents re-seeding even if the Users table is wiped. // This prevents re-seeding even if the Users table is wiped.
db.SeedAudits.Add(new SeedAudit { Key = seedKey }); db.SeedAudits.Add(new SeedAudit { Key = seedKey });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync();
});
} }
} }
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.RateLimiting; using Nexus.Api.RateLimiting;
@@ -202,6 +203,12 @@ public static class ServiceCollectionExtensions
/// </summary> /// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services) public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{ {
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<LoginAttemptTracker>(); services.AddSingleton<LoginAttemptTracker>();
services.AddTransient<ModelRoutingService>(); services.AddTransient<ModelRoutingService>();
@@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<ILiveUpdateService, LiveUpdateService>(); services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
services.AddScoped<INotificationService, NotificationService>(); services.AddScoped<INotificationService, NotificationService>();
services.AddScoped<ICalendarService, CalendarService>(); services.AddScoped<ICalendarService, CalendarService>();
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
// ── Backend Bridge (Agent-Command-Service) ── // ── Backend Bridge (Agent-Command-Service) ──
services.AddScoped<ITaskBridgeService, TaskBridgeService>(); services.AddScoped<ITaskBridgeService, TaskBridgeService>();
+2 -2
View File
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
return true; return true;
} }
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary> /// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
public static bool IsValidConfigFileName(string fileName) public static bool IsValidConfigFileName(string fileName)
{ {
if (string.IsNullOrWhiteSpace(fileName)) return false; if (string.IsNullOrWhiteSpace(fileName)) return false;
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$"); return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
} }
} }
+28 -3
View File
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
string? Goal = null, string? Goal = null,
string RoleBadge = "badge-slate", string RoleBadge = "badge-slate",
string StatusLabel = "Bereit", string StatusLabel = "Bereit",
string StatusKind = "ready",
string? StatusDetail = null,
string? Elapsed = null, string? Elapsed = null,
string? Think = null, string? Think = null,
string? Next = null string? Next = null
@@ -97,7 +99,8 @@ public sealed record DashboardTaskDto(
List<DashboardTaskDto>? ChildTasks = null, List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0, int ChildTaskCount = 0,
int OpenChildTaskCount = 0, int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false bool HasVisibleDelegation = false,
int DoneChildTaskCount = 0
); );
public sealed record CreateDashboardTaskRequest( public sealed record CreateDashboardTaskRequest(
@@ -116,7 +119,9 @@ public sealed record CreateAgentTaskRequest(
string? Priority, string? Priority,
string? AssignedTo, string? AssignedTo,
string? ExpectedFrom, string? ExpectedFrom,
Guid? ParentTaskId = null Guid? ParentTaskId = null,
bool StartsInProgress = true,
string? InitialState = null
); );
public sealed record UpdateDashboardTaskRequest( public sealed record UpdateDashboardTaskRequest(
@@ -134,7 +139,22 @@ public sealed record UpdateDashboardTaskStatusRequest(
public sealed record AgentActivityEntry( public sealed record AgentActivityEntry(
string Time, string Time,
string Text string Text,
DateTimeOffset Timestamp,
string Source = "gateway-session-history"
);
public sealed record GatewayRuntimeInfo(
bool Reachable,
string BaseUrl,
string? Version,
string? RequiredVersion,
bool VersionPinned,
bool VersionMatches,
string VersionStatus,
DateTimeOffset CheckedAt,
string? Message,
string? Warning = null
); );
// ── Task Board DTOs ── // ── Task Board DTOs ──
@@ -164,6 +184,11 @@ public sealed record PostActivityRequest(
string? Type = null string? Type = null
); );
public sealed record RequestChangesRequest(
string Comment,
string? TargetState = null
);
// ── Agent Workflow DTOs ── // ── Agent Workflow DTOs ──
/// <summary> /// <summary>
+1 -1
View File
@@ -10,9 +10,9 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1
View File
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment); app.UseNexusPipeline(app.Environment);
app.MapMcp();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
+23 -5
View File
@@ -3,7 +3,7 @@ using Nexus.Api.Data;
namespace Nexus.Api.Repositories; namespace Nexus.Api.Repositories;
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
{ {
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) 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);
@@ -39,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
return (items, totalCount); return (items, totalCount);
} }
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> db.Activity.AsNoTracking() {
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent") var candidateCount = Math.Max(take * 8, 100);
var recent = await db.Activity.AsNoTracking()
.OrderByDescending(x => x.CreatedAt) .OrderByDescending(x => x.CreatedAt)
.Take(take) .Take(candidateCount)
.ToListAsync(ct); .ToListAsync(ct);
return recent
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
.Take(take)
.ToList();
}
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default) public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
{ {
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
db.Activity.Add(activity); db.Activity.Add(activity);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
liveUpdates.Publish("activity.created", new
{
activity.Id,
activity.Type,
activity.Message,
activity.TaskId,
activity.CreatedAt,
agentIds
}, "activity");
return activity; return activity;
} }
} }
+1
View File
@@ -8,6 +8,7 @@ public interface ITaskRepository
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default); ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default); Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default); Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
Task UpdateAsync(WorkTask task, CancellationToken ct = default); Task UpdateAsync(WorkTask task, CancellationToken ct = default);
Task DeleteAsync(WorkTask task, CancellationToken ct = default); Task DeleteAsync(WorkTask task, CancellationToken ct = default);
Task<int> CountAsync(CancellationToken ct = default); Task<int> CountAsync(CancellationToken ct = default);
+35
View File
@@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
return task; return task;
} }
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
Guid id,
DateTimeOffset staleBefore,
DateTimeOffset updatedAt,
CancellationToken ct = default)
{
if (!db.Database.IsRelational())
{
var task = await db.Tasks
.FirstOrDefaultAsync(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore, ct);
if (task is null)
{
return false;
}
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
task.UpdatedAt = updatedAt;
await db.SaveChangesAsync(ct);
return true;
}
var affectedRows = await db.Tasks
.Where(task => task.Id == id
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
&& task.UpdatedAt < staleBefore)
.ExecuteUpdateAsync(setters => setters
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
return affectedRows > 0;
}
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default) public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
{ {
task.UpdatedAt = DateTimeOffset.UtcNow; task.UpdatedAt = DateTimeOffset.UtcNow;
+89
View File
@@ -0,0 +1,89 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public static class AgentActivityText
{
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
[
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
];
private static readonly Regex[] ResidualSensitivePatterns =
[
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
];
private static readonly string[] KnownActorIds =
[
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
"bao",
"nexus-system"
];
public static string RedactForDisplay(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return content ?? string.Empty;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var sanitized = lines[i];
foreach (var (pattern, replacement) in InlineRedactions)
{
sanitized = pattern.Replace(sanitized, replacement);
}
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
sanitized = "[redacted sensitive line]";
lines[i] = sanitized;
}
return string.Join('\n', lines).Trim();
}
public static bool MatchesAgent(string? content, string agentId)
{
if (string.IsNullOrWhiteSpace(agentId))
return false;
var normalized = agentId.Trim().ToLowerInvariant();
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
}
public static string[] ExtractAgentIds(string? content)
{
if (string.IsNullOrWhiteSpace(content))
return [];
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var actorId in KnownActorIds)
{
if (BuildActorRegex(actorId).IsMatch(content))
matches.Add(actorId);
}
return matches
.Select(actorId => actorId.ToLowerInvariant())
.OrderBy(actorId => actorId, StringComparer.Ordinal)
.ToArray();
}
private static Regex BuildActorRegex(string actorId)
=> ActorPatternCache.GetOrAdd(actorId, static key =>
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
}
+88 -5
View File
@@ -1,3 +1,4 @@
using System.Text.Json;
using Nexus.Api.Helpers; using Nexus.Api.Helpers;
namespace Nexus.Api.Services; namespace Nexus.Api.Services;
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null; return null;
if (!AllowedFiles.Contains(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}"; var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath)) if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
@@ -37,18 +40,44 @@ public sealed class AgentConfigService : IAgentConfigService
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc); return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
} }
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default) public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) var fileKind = DetermineFileKind(fileName);
return null; var validation = Validate(fileName, content, fileKind);
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
var reload = CreateReloadCheck();
if (validation.Errors.Count > 0)
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
var workspacePath = $"/mnt/workspace-{agentId}"; var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"workspace_not_found",
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
backup,
reload));
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath)) if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null; return new AgentConfigSaveAttempt(
null,
new AgentConfigSaveFailure(
"invalid_path",
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
backup,
reload));
var tempPath = safePath + ".tmp"; var tempPath = safePath + ".tmp";
var backupPath = safePath + ".bak";
var backupCreated = false;
try try
{ {
if (File.Exists(safePath))
{
File.Copy(safePath, backupPath, overwrite: true);
backupCreated = true;
}
await File.WriteAllTextAsync(tempPath, content, ct); await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true); File.Move(tempPath, safePath!, overwrite: true);
} }
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
} }
var fi = new FileInfo(safePath!); var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc); return new AgentConfigSaveAttempt(
new AgentConfigFileSaveResult(
fileName,
fi.Length,
fi.LastWriteTimeUtc,
new AgentConfigValidationResult("passed", fileKind, []),
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
CreateReloadCheck()),
null);
} }
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
{
var errors = new List<string>();
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
errors.Add("Filename is invalid.");
else if (!AllowedFiles.Contains(fileName))
errors.Add("File is not allowed for Mission Control editing.");
if (content.IndexOf('\0') >= 0)
errors.Add("Content contains null bytes.");
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
{
try
{
JsonDocument.Parse(content);
}
catch (JsonException ex)
{
errors.Add($"JSON validation failed: {ex.Message}");
}
}
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
}
private static string DetermineFileKind(string fileName)
{
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return "json";
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
return "markdown";
return "text";
}
private static AgentConfigReloadCheckResult CreateReloadCheck()
=> new(
"not_supported",
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
} }
+44
View File
@@ -0,0 +1,44 @@
namespace Nexus.Api.Services;
public static class AgentIdentityCatalog
{
public static readonly string[] DefaultConfiguredAgentIds =
[
"main",
"iris",
"product-owner",
"programmer",
"programmer-fast",
"reviewer",
"architekt",
"researcher",
"executor"
];
private static readonly string[] WorkflowActorIds =
[
"bao",
"nexus-system"
];
public static IReadOnlySet<string> BuildAllowedActorIds(IEnumerable<string> configuredAgentIds)
{
var ids = new HashSet<string>(WorkflowActorIds, StringComparer.OrdinalIgnoreCase);
foreach (var configuredAgentId in configuredAgentIds)
{
if (!string.IsNullOrWhiteSpace(configuredAgentId))
ids.Add(configuredAgentId.Trim().ToLowerInvariant());
}
return ids;
}
public static string? NormalizeActorId(string? actorId, IReadOnlySet<string> allowedActorIds)
{
if (string.IsNullOrWhiteSpace(actorId))
return null;
var normalized = actorId.Trim().ToLowerInvariant();
return allowedActorIds.Contains(normalized) ? normalized : null;
}
}
+83 -17
View File
@@ -20,7 +20,8 @@ public sealed record AgentConfig
public string? AgentDir { get; init; } public string? AgentDir { get; init; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public string? Model { get; init; } [JsonConverter(typeof(AgentModelConfigConverter))]
public AgentModelConfig? Model { get; init; }
[JsonPropertyName("identity")] [JsonPropertyName("identity")]
public AgentIdentityConfig? Identity { get; init; } public AgentIdentityConfig? Identity { get; init; }
@@ -44,6 +45,60 @@ public sealed record AgentIdentityConfig
public string Theme { get; init; } = string.Empty; public string Theme { get; init; } = string.Empty;
} }
public sealed record AgentModelConfig
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
}
public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
{
public override AgentModelConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
if (reader.TokenType == JsonTokenType.String)
{
var primaryModel = reader.GetString();
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Agent model must be either a string or an object.");
using var document = JsonDocument.ParseValue(ref reader);
var root = document.RootElement;
string? primary = null;
foreach (var property in root.EnumerateObject())
{
if (!string.Equals(property.Name, "primary", StringComparison.OrdinalIgnoreCase))
continue;
primary = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Null => null,
_ => throw new JsonException("Agent model primary must be a string.")
};
break;
}
return new AgentModelConfig { Primary = primary };
}
public override void Write(Utf8JsonWriter writer, AgentModelConfig value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(value.Primary))
writer.WriteString("primary", value.Primary);
else
writer.WriteNull("primary");
writer.WriteEndObject();
}
}
public sealed record AgentInfo( public sealed record AgentInfo(
string Id, string Id,
string Name, string Name,
@@ -94,7 +149,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
var agents = new List<AgentInfo>(configs.Count); var agents = new List<AgentInfo>(configs.Count);
foreach (var config in configs) foreach (var config in configs)
{ {
var model = config.Model ?? "deepseek/deepseek-v4-flash"; var model = ResolveModel(config);
var role = DeriveRole(config.Id); var role = DeriveRole(config.Id);
var description = config.Identity?.Theme ?? string.Empty; var description = config.Identity?.Theme ?? string.Empty;
@@ -141,7 +196,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
Id: config.Id, Id: config.Id,
Name: config.Identity?.Name ?? config.Name ?? config.Id, Name: config.Identity?.Name ?? config.Name ?? config.Id,
Role: role, Role: role,
Model: config.Model ?? "deepseek/deepseek-v4-flash", Model: ResolveModel(config),
Status: runtimeStatus.Status, Status: runtimeStatus.Status,
LastSeen: now, LastSeen: now,
Workspace: config.Workspace, Workspace: config.Workspace,
@@ -165,30 +220,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{ {
"iris" => "Orchestrator", "iris" => "Orchestrator",
"product-owner" => "Product Owner",
"programmer" => "Developer", "programmer" => "Developer",
"programmer-fast" => "Developer",
"reviewer" => "Reviewer", "reviewer" => "Reviewer",
"architekt" => "Architect", "architekt" => "Architect",
"main" => "Assistant", "main" => "Assistant",
_ => "Custom" _ => "Custom"
}; };
private static string ResolveModel(AgentConfig config)
=> config.Model?.Primary ?? "deepseek/deepseek-v4-flash";
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken) private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{ {
var path = configuration.GetValue<string>("AgentConfigPath") var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!File.Exists(path)) if (!File.Exists(path))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
var json = await File.ReadAllTextAsync(path, cancellationToken); var json = await File.ReadAllTextAsync(path, cancellationToken);
using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true }); using var document = JsonDocument.Parse(json, new JsonDocumentOptions { AllowTrailingCommas = true });
var root = document.RootElement; var root = document.RootElement;
if (!root.TryGetProperty("agents", out var agentsElement)) if (!root.TryGetProperty("agents", out var agentsElement))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
if (!agentsElement.TryGetProperty("list", out var listElement)) if (!agentsElement.TryGetProperty("list", out var listElement))
return Array.Empty<AgentConfig>(); return BuildFallbackConfigs();
var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement) var defaults = agentsElement.TryGetProperty("defaults", out var defaultsElement)
? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions) ? JsonSerializer.Deserialize<AgentDefaults>(defaultsElement.GetRawText(), JsonOptions)
@@ -204,29 +264,35 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
// Inherit defaults for missing fields // Inherit defaults for missing fields
if (string.IsNullOrWhiteSpace(config.Name)) if (string.IsNullOrWhiteSpace(config.Name))
config = config with { Name = config.Id }; config = config with { Name = config.Id };
if (string.IsNullOrWhiteSpace(config.Model) && defaults?.Model?.Primary is not null) if (string.IsNullOrWhiteSpace(config.Model?.Primary) && defaults?.Model?.Primary is not null)
config = config with { Model = defaults.Model.Primary }; config = config with { Model = new AgentModelConfig { Primary = defaults.Model.Primary } };
if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null) if (string.IsNullOrWhiteSpace(config.Workspace) && defaults?.Workspace is not null)
config = config with { Workspace = defaults.Workspace }; config = config with { Workspace = defaults.Workspace };
configs.Add(config); configs.Add(config);
} }
return configs.AsReadOnly(); return configs.Count > 0 ? configs.AsReadOnly() : BuildFallbackConfigs();
} }
private static IReadOnlyList<AgentConfig> BuildFallbackConfigs()
=> AgentIdentityCatalog.DefaultConfiguredAgentIds
.Select(id => new AgentConfig
{
Id = id,
Name = id,
Model = new AgentModelConfig { Primary = "deepseek/deepseek-v4-flash" }
})
.ToList()
.AsReadOnly();
private sealed record AgentDefaults private sealed record AgentDefaults
{ {
[JsonPropertyName("workspace")] [JsonPropertyName("workspace")]
public string? Workspace { get; init; } public string? Workspace { get; init; }
[JsonPropertyName("model")] [JsonPropertyName("model")]
public AgentDefaultModel? Model { get; init; } [JsonConverter(typeof(AgentModelConfigConverter))]
} public AgentModelConfig? Model { get; init; }
private sealed record AgentDefaultModel
{
[JsonPropertyName("primary")]
public string? Primary { get; init; }
} }
} }
+5
View File
@@ -56,6 +56,11 @@ public sealed class AuthService : IAuthService
user.LastLoginAt = DateTimeOffset.UtcNow; user.LastLoginAt = DateTimeOffset.UtcNow;
user.UpdatedAt = DateTimeOffset.UtcNow; user.UpdatedAt = DateTimeOffset.UtcNow;
// Persist user changes (password upgrade, login timestamp) immediately.
// Relying solely on RemoveExpiredTokensAsync / AddRefreshTokenAsync to
// trigger SaveChangesAsync is fragile — if zero tokens are expired the
// tracked changes might not be flushed before the response is produced.
await _users.UpdateAsync(user, ct);
await _users.RemoveExpiredTokensAsync(user.Id, ct); await _users.RemoveExpiredTokensAsync(user.Id, ct);
return await CreateSessionAsync(user, Guid.NewGuid(), null, ct); return await CreateSessionAsync(user, Guid.NewGuid(), null, ct);
} }
+13
View File
@@ -112,6 +112,19 @@ public sealed class DashboardService(
} }
} }
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
{
try
{
return await gateway.GetGatewayInfoAsync(ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Gateway info fetch failed");
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{ {
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
+29 -2
View File
@@ -4,11 +4,38 @@ public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime Mo
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt); public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt); public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
public sealed record AgentConfigFileSaveResult(
string FileName,
long Size,
DateTime ModifiedAt,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveFailure(
string Code,
AgentConfigValidationResult Validation,
AgentConfigBackupResult Backup,
AgentConfigReloadCheckResult ReloadCheck
);
public sealed record AgentConfigSaveAttempt(
AgentConfigFileSaveResult? SaveResult,
AgentConfigSaveFailure? Failure
);
public interface IAgentConfigService public interface IAgentConfigService
{ {
const int MaxConfigFileBytes = 500 * 1024;
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId); IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default); Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default); Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
} }
+1
View File
@@ -16,6 +16,7 @@ public interface IDashboardService
Task<ChatResponse> SendChatAsync(string agentId, string message); Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset); Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct); Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct); Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct); Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId); Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30); Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message); Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync(); Task<List<QueueItem>> GetQueueAsync();
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
Task<bool> DeleteCronJobAsync(string id); Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId); Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model); Task<bool> SetAgentModelAsync(string agentId, string model);
@@ -0,0 +1,10 @@
namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService
{
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
}
+1
View File
@@ -42,6 +42,7 @@ public interface ITaskBridgeService
string? priority = "Normal", string? priority = "Normal",
string? assignedTo = null, string? assignedTo = null,
string? expectedFrom = null, string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary> /// <summary>
+5 -1
View File
@@ -23,18 +23,22 @@ public interface ITaskService
// Dashboard-facing task operations // Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default); Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, Guid? parentTaskId = null, 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<WorkTask> CreateAgentTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default); Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, DateTimeOffset? dueDate = null, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default); Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default); Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default); Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
// Task Board // Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default); Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default); Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default); Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
+232
View File
@@ -0,0 +1,232 @@
using System.ComponentModel;
using System.Security.Claims;
using ModelContextProtocol.Server;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
[McpServerToolType]
public sealed class NexusMcpTools(
ITaskBridgeService bridge,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor,
IConfiguration configuration,
ILogger<NexusMcpTools> logger)
{
[McpServerTool(Name = "nexus_get_board")]
[Description("Get the full Nexus task board grouped by canonical states.")]
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetBoardAsync(ct);
}
[McpServerTool(Name = "nexus_agent_overview")]
[Description("Get agent workflow overview, including waiting and stale task groups.")]
public async Task<AgentWorkflowOverview> GetAgentOverview(
[Description("Stale threshold in hours. Defaults to 2.")]
int staleHours = 2,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
}
[McpServerTool(Name = "nexus_get_task")]
[Description("Get one Nexus task by ID.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
}
[McpServerTool(Name = "nexus_get_children")]
[Description("Get child tasks for a Nexus parent task.")]
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
return await bridge.GetChildTasksAsync(parentTaskId, ct);
}
[McpServerTool(Name = "nexus_get_activity")]
[Description("Get activity entries for a Nexus task.")]
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
}
[McpServerTool(Name = "nexus_create_task")]
[Description("Create a top-level Nexus task.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateTaskAsync(
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo ?? caller,
ct: ct);
return ToResponse(result, "nexus_create_task");
}
[McpServerTool(Name = "nexus_create_child_task")]
[Description("Create a visible child task under a Nexus parent task for delegation.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
Guid parentTaskId,
string title,
string? detail = null,
string? priority = "Normal",
string? assignedTo = null,
string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.CreateChildTaskAsync(
parentTaskId: parentTaskId,
title: title,
detail: detail,
source: ResolveSource(caller),
priority: priority,
assignedTo: assignedTo,
expectedFrom: expectedFrom ?? assignedTo,
startsInProgress: startsInProgress,
ct: ct);
return ToResponse(result, "nexus_create_child_task");
}
[McpServerTool(Name = "nexus_update_status")]
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
Guid taskId,
NexusMcpTaskState state,
CancellationToken ct = default)
{
var caller = await ResolveCallerAsync(ct);
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
return ToResponse(result, "nexus_update_status");
}
[McpServerTool(Name = "nexus_append_activity")]
[Description("Append an activity/checkpoint entry to a Nexus task.")]
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
Guid taskId,
string message,
string? type = "comment",
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
return ToActivityResponse(result, "nexus_append_activity");
}
[McpServerTool(Name = "nexus_handoff")]
[Description("Mark a task handoff to another known agent and append handoff activity.")]
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
Guid taskId,
string targetAgent,
string? note = null,
CancellationToken ct = default)
{
await ResolveCallerAsync(ct);
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
return ToResponse(result, "nexus_handoff");
}
private async Task<string> ResolveCallerAsync(CancellationToken ct)
{
var context = httpContextAccessor.HttpContext
?? throw new UnauthorizedAccessException("MCP request context is not available.");
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(agentHeader))
{
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
if (allowedActorIds.Contains(normalizedHeader))
return normalizedHeader;
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
normalizedHeader,
context.Connection.RemoteIpAddress);
}
if (context.User.Identity?.IsAuthenticated == true)
{
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
return normalizedClaim;
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
return "bao";
}
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
allowedActorIds.Contains("nexus-system"))
return "nexus-system";
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
}
private static string ResolveSource(string agentId) => agentId switch
{
"bao" or "nexus-system" => "bao",
_ => agentId
};
private static string ToStateString(NexusMcpTaskState state) => state switch
{
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
};
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
TaskBridgeResult<ActivityEvent> result,
string command)
=> new()
{
Ok = result.Outcome == TaskBridgeOutcome.Success,
Command = command,
Data = result.Data is null
? null
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
};
}
public enum NexusMcpTaskState
{
Backlog,
InProgress,
Blocked,
Done,
Review
}
+249 -19
View File
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
{ {
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
private static readonly string[] SensitiveMarkers =
[
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
];
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
@@ -115,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
) )
}; };
// Load agent IDs from openclaw.json config // Load agent IDs from sanitized agents config (no secrets)
var agentIds = LoadAgentIdsFromConfig(); var agentIds = LoadAgentIdsFromConfig();
var agents = new List<DashboardAgentInfo>(); var agents = new List<DashboardAgentInfo>();
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 3. Extract activity from session_status // 3. Extract activity from session_status
var isActive = false; var isActive = false;
string? currentTask = null; string? currentTask = null;
var statusText = status?["status"]?.GetValue<string>();
if (status is not null) if (status is not null)
{ {
// Check explicit isActive field // Check explicit isActive field
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase); isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
// Fall back to status text // Fall back to status text
var statusText = status["status"]?.GetValue<string>();
if (!isActive && statusText is not null) if (!isActive && statusText is not null)
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase) isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase); || string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
// 8. Calculate workload from queue items // 8. Calculate workload from queue items
var workload = CalculateAgentWorkload(id, queueItems); var workload = CalculateAgentWorkload(id, queueItems);
var statusKind = DeriveStatusKind(status, isActive);
var statusDetail = DeriveStatusDetail(status, statusKind);
agents.Add(new DashboardAgentInfo( agents.Add(new DashboardAgentInfo(
Id: id, Id: id,
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name, Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
Workload: workload, Workload: workload,
Goal: goal, Goal: goal,
RoleBadge: DeriveRoleBadge(id), RoleBadge: DeriveRoleBadge(id),
StatusLabel: DeriveStatusLabel(isActive, status), StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
StatusKind: statusKind,
StatusDetail: statusDetail,
Elapsed: FormatElapsed(status), Elapsed: FormatElapsed(status),
Think: null, Think: null,
Next: DeriveNext(isActive, currentTask) Next: DeriveNext(isActive, currentTask)
@@ -214,7 +227,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
} }
/// <summary> /// <summary>
/// Loads agent IDs from the OpenClaw config file (openclaw.json). /// Loads agent IDs from the sanitized agents config (no secrets).
/// Falls back to the known list if the config file is unavailable. /// Falls back to the known list if the config file is unavailable.
/// </summary> /// </summary>
private List<string> LoadAgentIdsFromConfig() private List<string> LoadAgentIdsFromConfig()
@@ -222,7 +235,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try try
{ {
var configPath = configuration.GetValue<string>("AgentConfigPath") var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!System.IO.File.Exists(configPath)) if (!System.IO.File.Exists(configPath))
return GetDefaultAgentIds(); return GetDefaultAgentIds();
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
} }
} }
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
{
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
ApplyAuth(request);
using var response = await httpClient.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
? headerValues.FirstOrDefault()
: null;
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
version = TryGetString(root, "version")
?? TryGetString(root, "gatewayVersion")
?? TryGetString(root, "openclawVersion");
}
catch
{
// Health endpoint may be plain text.
}
}
version = NormalizeOptional(version);
var pinned = requiredVersion is not null;
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
var matches = versionStatus is "matched" or "unpinned";
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
return new GatewayRuntimeInfo(
response.IsSuccessStatusCode,
baseUrl,
version,
requiredVersion,
pinned,
response.IsSuccessStatusCode && matches,
versionStatus,
DateTimeOffset.UtcNow,
message,
warning);
}
catch
{
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
return new GatewayRuntimeInfo(
false,
baseUrl,
null,
requiredVersion,
requiredVersion is not null,
false,
"error",
DateTimeOffset.UtcNow,
"Gateway nicht erreichbar",
warning);
}
}
public async Task<bool> DeleteCronJobAsync(string id) public async Task<bool> DeleteCronJobAsync(string id)
{ {
try try
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
continue; continue;
// Truncate content to first 200 chars for compact display // Truncate content to first 200 chars for compact display
var text = msg.Content.Length > 200 var redacted = AgentActivityText.RedactForDisplay(msg.Content);
? msg.Content[..200] + "…" var text = redacted.Length > 200
: msg.Content; ? redacted[..200] + "…"
: redacted;
var ts = ParseTimestamp(msg.Timestamp); var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts); var timeAgo = FormatTimeAgo(ts);
entries.Add(new AgentActivityEntry(timeAgo, text)); entries.Add(new AgentActivityEntry(timeAgo, text, ts));
} }
} }
catch catch
@@ -1005,7 +1085,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try try
{ {
var configPath = configuration.GetValue<string>("AgentConfigPath") var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!System.IO.File.Exists(configPath)) if (!System.IO.File.Exists(configPath))
return GetDefaultModels(); return GetDefaultModels();
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
_ => "badge-slate" _ => "badge-slate"
}; };
private static string DeriveStatusLabel(bool isActive, JsonNode? status) private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
{ {
if (!isActive) return "Bereit"; return statusKind switch
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant(); {
return statusText switch "connected" => isActive ? "Arbeitet" : "Verbunden",
"thinking" => "Plant",
"blocked" => "Blockiert",
"stale" => "Stale",
"error" => "Fehler",
"unsupported" => "Unsupported",
"ready" => "Bereit",
_ => statusText?.ToLowerInvariant() switch
{ {
"thinking" or "think" => "Plant", "thinking" or "think" => "Plant",
"blocked" or "block" => "Blockiert", "blocked" or "block" => "Blockiert",
_ => "Arbeitet" _ => isActive ? "Arbeitet" : "Bereit"
}
};
}
private static string DeriveStatusKind(JsonNode? status, bool isActive)
{
if (status is null)
return "error";
var statusText = status["status"]?.GetValue<string>()?.Trim();
var errorText = status["error"]?.GetValue<string>()?.Trim()
?? status["message"]?.GetValue<string>()?.Trim();
var normalized = statusText?.ToLowerInvariant();
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
if (detail.Contains("unsupported", StringComparison.Ordinal))
return "unsupported";
if (!string.IsNullOrWhiteSpace(errorText)
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
return "error";
if (normalized is "blocked" or "block")
return "blocked";
if (normalized is "thinking" or "think")
return "thinking";
var lastActivity = TryGetStatusTimestamp(status);
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
return "stale";
if (isActive || normalized is "active" or "running" or "connected" or "online")
return "connected";
return "ready";
}
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
{
if (status is null)
return "Gateway-Status nicht abrufbar";
var message = NormalizeOptional(status["message"]?.GetValue<string>())
?? NormalizeOptional(status["error"]?.GetValue<string>())
?? NormalizeOptional(status["detail"]?.GetValue<string>());
if (message is not null)
return message;
return statusKind switch
{
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
"error" => "Session-Status konnte nicht gelesen werden",
_ => null
}; };
} }
private static string? FormatElapsed(JsonNode? status) private static string? FormatElapsed(JsonNode? status)
{ {
var lastActivity = status?["lastActivity"]?.GetValue<string>() var lastActivity = TryGetStatusTimestamp(status);
?? status?["lastMessage"]?.GetValue<string>();
if (lastActivity is null) return null; if (lastActivity is null) return null;
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null; var diff = DateTimeOffset.UtcNow - lastActivity.Value;
var diff = DateTimeOffset.UtcNow - ts;
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s"; if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m"; if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h"; if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
"main" => "Assistant", "main" => "Assistant",
_ => "Custom" _ => "Custom"
}; };
private static string? TryGetString(JsonElement root, string property)
=> root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty(property, out var value)
&& value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
public static string RedactSensitiveText(string content)
{
if (string.IsNullOrWhiteSpace(content))
return content;
var lines = content.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
var lower = lines[i].ToLowerInvariant();
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
{
lines[i] = "[redacted sensitive line]";
}
}
return string.Join('\n', lines);
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
{
var raw = status?["lastActivity"]?.GetValue<string>()
?? status?["lastMessage"]?.GetValue<string>()
?? status?["updatedAt"]?.GetValue<string>();
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
}
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
{
if (!reachable)
return "error";
if (requiredVersion is null)
return version is null ? "unknown" : "unpinned";
if (version is null)
return "missing";
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
}
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
{
if (!reachable)
return "Gateway nicht erreichbar";
return versionStatus switch
{
"matched" => "Gateway erreichbar und Version gepinnt",
"missing" => requiredVersion is null
? "Gateway erreichbar"
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
"unpinned" => "Gateway erreichbar",
"unknown" => "Gateway erreichbar, Version nicht erkannt",
_ => "Gateway erreichbar"
};
}
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
{
if (!reachable)
return fallback ?? "Gateway nicht erreichbar";
return versionStatus switch
{
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
_ => null
};
}
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
{
if (lastActivity is null)
return "Letzte Aktivität ist veraltet";
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
if (diff.TotalMinutes < 60)
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
if (diff.TotalHours < 24)
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
}
} }
@@ -0,0 +1,50 @@
using Microsoft.Extensions.Primitives;
namespace Nexus.Api.Services;
public static class RequestAuthorizationHelper
{
public sealed record AgentHeaderResolution(string? AgentId, bool HeaderProvided, bool IsRecognized);
public static bool IsAuthenticatedService(HttpContext httpContext, IConfiguration configuration) =>
httpContext.User.IsInRole("Service") || HasValidServiceKey(httpContext, configuration);
public static bool IsPrivilegedUser(HttpContext httpContext) =>
httpContext.User.Identity?.IsAuthenticated == true &&
(httpContext.User.IsInRole("owner") || httpContext.User.IsInRole("admin"));
public static async Task<string?> ResolveAllowedAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
=> (await ResolveAgentHeaderAsync(httpContext, agentService, ct)).AgentId;
public static async Task<AgentHeaderResolution> ResolveAgentHeaderAsync(
HttpContext httpContext,
IAgentService agentService,
CancellationToken ct)
{
var headerValue = httpContext.Request.Headers["X-Agent-Id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue))
return new AgentHeaderResolution(null, HeaderProvided: false, IsRecognized: false);
var allowed = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var normalized = AgentIdentityCatalog.NormalizeActorId(headerValue, allowed);
return new AgentHeaderResolution(
normalized,
HeaderProvided: true,
IsRecognized: normalized is not null);
}
public static bool HasValidServiceKey(HttpContext httpContext, IConfiguration configuration)
{
var configuredApiKey = configuration["NexusApiKey"];
if (string.IsNullOrWhiteSpace(configuredApiKey))
return false;
if (!httpContext.Request.Headers.TryGetValue("X-Nexus-Api-Key", out StringValues providedKey))
return false;
return string.Equals(configuredApiKey, providedKey.FirstOrDefault(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Options;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryBackgroundService(
IServiceScopeFactory scopeFactory,
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
if (flaggedCount > 0)
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Stale task recovery run failed.");
}
try
{
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
}
}
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
{
await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
}
}
@@ -0,0 +1,21 @@
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryOptions
{
public const string SectionName = "TaskRecovery";
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
public int StalledMinutes { get; set; } = 40;
/// <summary>Prüfintervall des Watchdogs.</summary>
public int IntervalMinutes { get; set; } = 10;
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
public int StaleHours { get; set; } = 2;
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
}
@@ -0,0 +1,191 @@
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService,
INotificationService notificationService) : IStaleTaskRecoveryService
{
private const string StalledActivityType = "stalled";
/// <summary>
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
/// </summary>
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var threshold = now - stalledThreshold;
var allTasks = await taskRepository.GetAllAsync(ct);
var inProgress = allTasks
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
.ToList();
if (inProgress.Count == 0)
return 0;
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
var activityByTask = activities
.Where(a => a.TaskId.HasValue)
.GroupBy(a => a.TaskId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
var flaggedCount = 0;
foreach (var task in inProgress)
{
activityByTask.TryGetValue(task.Id, out var taskActivity);
var latest = taskActivity?.FirstOrDefault();
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
if (lastProgressAt >= threshold)
continue;
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
continue;
var silentFor = now - lastProgressAt;
await activityRepository.AddAsync(new ActivityEvent
{
Type = StalledActivityType,
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
TaskId = task.Id
}, ct);
await notificationService.CreateAsync(
"task_stalled",
$"Task hängt: {task.Title}",
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
"iris",
task.Id,
ct);
flaggedCount++;
}
if (flaggedCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return flaggedCount;
}
/// <summary>
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
/// </summary>
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = await GetStaleTasksAsync(threshold, ct);
if (staleTasks.Count == 0)
return 0;
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
var now = DateTimeOffset.UtcNow;
var resetCount = 0;
foreach (var task in staleTasks)
{
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
continue;
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
currentTask.Id,
threshold,
now,
ct);
if (!updated)
continue;
await activityRepository.AddAsync(new ActivityEvent
{
Type = "task",
Message = message,
TaskId = task.Id
}, ct);
resetCount++;
}
if (resetCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return resetCount;
}
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
return allTasks
.Where(task => IsStaleInProgress(task, threshold))
.ToList();
}
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
&& task.UpdatedAt < threshold;
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
IEnumerable<Guid> taskIds,
CancellationToken ct)
{
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
return activities
.Where(activity => activity.TaskId.HasValue)
.GroupBy(activity => activity.TaskId!.Value)
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
}
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{
var allTasks = await taskRepository.GetAllAsync(ct);
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
return TaskService.BuildMasterBoard(allTasks, activity);
}
private static string BuildActivityMessage(
WorkTask task,
TimeSpan staleThreshold,
DateTimeOffset now,
DateTimeOffset? lastActivityAt)
{
var staleAge = now - task.UpdatedAt;
var details = new List<string>
{
"reason=stale-recovery",
"previous status In progress",
$"stale reference {now:O}",
$"stale age {FormatDuration(staleAge)}",
$"threshold {FormatDuration(staleThreshold)}"
};
if (lastActivityAt.HasValue)
details.Add($"last activity {lastActivityAt.Value:O}");
details.Add($"last update {task.UpdatedAt:O}");
details.Add("new status Backlog");
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
}
private static string FormatDuration(TimeSpan duration)
{
if (duration.TotalHours >= 1)
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
}
}
+23 -20
View File
@@ -15,6 +15,7 @@ namespace Nexus.Api.Services;
/// </summary> /// </summary>
public sealed class TaskBridgeService( public sealed class TaskBridgeService(
ITaskService taskService, ITaskService taskService,
IAgentService agentService,
IActivityRepository activityRepo, IActivityRepository activityRepo,
INotificationService notificationService, INotificationService notificationService,
ILiveUpdateService liveUpdateService) : ITaskBridgeService ILiveUpdateService liveUpdateService) : ITaskBridgeService
@@ -37,12 +38,11 @@ public sealed class TaskBridgeService(
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required."); return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, "Title is required.");
var normalizedSource = NormalizeSource(source); var normalizedSource = NormalizeSource(source);
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateDashboardTaskAsync( var task = await taskService.CreateDashboardTaskAsync(
title.Trim(), detail?.Trim(), normalizedSource, priority, normalizedAssignee, parentTaskId: null, ct); title.Trim(), detail?.Trim(), normalizedSource, priority, assignedTo, parentTaskId: null, ct);
var dto = MapToDto(task); var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto); return Success(dto);
} }
@@ -56,6 +56,7 @@ public sealed class TaskBridgeService(
string? priority = "Normal", string? priority = "Normal",
string? assignedTo = null, string? assignedTo = null,
string? expectedFrom = null, string? expectedFrom = null,
bool startsInProgress = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(title)) if (string.IsNullOrWhiteSpace(title))
@@ -66,19 +67,23 @@ public sealed class TaskBridgeService(
if (parent is null) if (parent is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found."); return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Parent task {parentTaskId} not found.");
var normalizedAssignee = NormalizeAssignedTo(assignedTo);
var task = await taskService.CreateAgentTaskAsync( var task = await taskService.CreateAgentTaskAsync(
title.Trim(), detail?.Trim(), NormalizeSource(source), title.Trim(), detail?.Trim(), NormalizeSource(source),
priority, normalizedAssignee, expectedFrom, parentTaskId, ct); priority, assignedTo, expectedFrom, parentTaskId, startsInProgress, null, ct);
// If parent was in Backlog, move it to InProgress (coordination starts) // If parent was in Backlog, move it to InProgress (coordination starts)
if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase)) if (string.Equals(parent.State, "Backlog", StringComparison.OrdinalIgnoreCase))
{ {
await taskService.UpdateStatusAsync(parentTaskId, "In progress", ct); var parentTransition = await taskService.StartCoordinationAsync(parentTaskId, ct);
if (parentTransition.Outcome != TaskOperationOutcome.Success)
{
return Error<DashboardTaskDto>(
TaskBridgeOutcome.InvalidState,
$"Parent task {parentTaskId} could not be moved to In progress for coordination.");
}
} }
var dto = MapToDto(task); var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto); return Success(dto);
} }
@@ -107,7 +112,7 @@ public sealed class TaskBridgeService(
if (result.Outcome != TaskOperationOutcome.Success) if (result.Outcome != TaskOperationOutcome.Success)
return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected."); return Error<DashboardTaskDto>(TaskBridgeOutcome.InvalidState, "Status update rejected.");
var dto = MapToDto(result.Task!); var dto = await taskService.GetDashboardTaskByIdAsync(result.Task!.Id, ct) ?? MapToDto(result.Task);
return Success(dto); return Success(dto);
} }
@@ -157,7 +162,10 @@ public sealed class TaskBridgeService(
if (task is null) if (task is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found."); return Error<DashboardTaskDto>(TaskBridgeOutcome.NotFound, $"Task {taskId} not found.");
var normalizedTarget = targetAgent.Trim().ToLowerInvariant(); var normalizedTarget = await NormalizeActorAsync(targetAgent, ct);
if (normalizedTarget is null)
return Error<DashboardTaskDto>(TaskBridgeOutcome.ValidationError, $"Unknown target agent '{targetAgent}'.");
var handoffNote = string.IsNullOrWhiteSpace(note) var handoffNote = string.IsNullOrWhiteSpace(note)
? $"Handoff → {normalizedTarget}" ? $"Handoff → {normalizedTarget}"
: $"Handoff → {normalizedTarget}: {note.Trim()}"; : $"Handoff → {normalizedTarget}: {note.Trim()}";
@@ -186,7 +194,7 @@ public sealed class TaskBridgeService(
task.Id, task.Id,
ct); ct);
var dto = MapToDto(task); var dto = await taskService.GetDashboardTaskByIdAsync(task.Id, ct) ?? MapToDto(task);
return Success(dto); return Success(dto);
} }
@@ -206,10 +214,7 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync( public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default) Guid parentTaskId, CancellationToken ct = default)
{ => await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
var children = await taskService.GetChildTasksAsync(parentTaskId, ct);
return children.Select(MapToDto).ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync( public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default) Guid taskId, CancellationToken ct = default)
@@ -233,12 +238,10 @@ public sealed class TaskBridgeService(
private static string NormalizeSource(string? source) => private static string NormalizeSource(string? source) =>
string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant(); string.IsNullOrWhiteSpace(source) ? "iris" : source.Trim().ToLowerInvariant();
private static string? NormalizeAssignedTo(string? assignedTo) private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(assignedTo)) return null; var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var valid = new HashSet<string> { "bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor" }; return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
var lower = assignedTo.Trim().ToLowerInvariant();
return valid.Contains(lower) ? lower : null;
} }
private static DashboardTaskDto MapToDto(WorkTask t) => new( private static DashboardTaskDto MapToDto(WorkTask t) => new(
+179 -61
View File
@@ -9,12 +9,11 @@ public sealed class TaskService(
ITaskRepository taskRepo, ITaskRepository taskRepo,
IActivityRepository activityRepo, IActivityRepository activityRepo,
INotificationService notificationService, INotificationService notificationService,
IAgentService agentService,
IHttpContextAccessor httpContextAccessor, IHttpContextAccessor httpContextAccessor,
ILiveUpdateService liveUpdateService) : ITaskService ILiveUpdateService liveUpdateService,
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
{ {
private static readonly HashSet<string> ValidAssignees =
["bao", "iris", "programmer", "reviewer", "architekt", "researcher", "executor"];
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct); => await taskRepo.GetAllAsync(ct);
@@ -90,12 +89,7 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task)) if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task {task.Title} moved to {canonical}", ct);
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);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
} }
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default) public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
@@ -204,7 +198,7 @@ public sealed class TaskService(
} }
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant(); var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo); var normalizedAssignee = await NormalizeActorAsync(assignedTo, ct);
var isVisibleDelegation = parentTaskId.HasValue; var isVisibleDelegation = parentTaskId.HasValue;
var task = new WorkTask var task = new WorkTask
@@ -250,14 +244,14 @@ public sealed class TaskService(
public async Task<WorkTask> CreateAgentTaskAsync( public async Task<WorkTask> CreateAgentTaskAsync(
string title, string? detail, string? source, string? priority, string title, string? detail, string? source, string? priority,
string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, CancellationToken ct = default) string? assignedTo, string? expectedFrom, Guid? parentTaskId = null, bool startsInProgress = true, string? initialState = null, CancellationToken ct = default)
{ {
var normalizedExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); var normalizedExpectedFrom = await NormalizeActorAsync(expectedFrom, ct);
var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct); var task = await CreateDashboardTaskAsync(title, detail, source, priority, assignedTo, parentTaskId, ct);
task.IsAgentTask = true; task.IsAgentTask = true;
task.ExpectedFrom = normalizedExpectedFrom; task.ExpectedFrom = normalizedExpectedFrom;
task.State = TaskStateHelper.ToStateString(TaskState.InProgress); task.State = ResolveInitialAgentTaskState(startsInProgress, initialState);
await taskRepo.UpdateAsync(task, ct); await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent await activityRepo.AddAsync(new ActivityEvent
@@ -322,7 +316,7 @@ public sealed class TaskService(
} }
if (assignedTo is not null) if (assignedTo is not null)
{ {
var validated = ValidateAssignedTo(assignedTo); var validated = await NormalizeActorAsync(assignedTo, ct);
if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase)) if (!string.Equals(task.AssignedTo ?? "", validated ?? "", StringComparison.OrdinalIgnoreCase))
{ {
changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}"); changes.Add($"Zuständig: {task.AssignedTo ?? "niemand"} → {validated ?? "niemand"}");
@@ -373,12 +367,24 @@ public sealed class TaskService(
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase)); var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", null, ct);
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); public async Task<TaskOperationResult> StartCoordinationAsync(Guid id, CancellationToken ct = default)
await PublishBoardSnapshotAsync(ct); {
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.Equals(task.State, "Backlog", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.Success, task); return new TaskOperationResult(TaskOperationOutcome.Success, task);
return await UpdateTaskStatusInternalAsync(
task,
canonical: TaskStateHelper.ToStateString(TaskState.InProgress),
actor: "nexus-system",
activityType: "delegation",
activityMessage: $"Task \"{task.Title}\" → In progress (coordination started by child-task creation)",
ct: ct);
} }
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default) public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
@@ -416,6 +422,19 @@ public sealed class TaskService(
{ {
var all = (await taskRepo.GetAllAsync(ct)).ToList(); var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct); var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return BuildMasterBoard(all, activity);
}
/// <summary>
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
/// damit nichts unsichtbar wird.
/// </summary>
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
{
var ids = all.Select(t => t.Id).ToHashSet();
var offen = new List<DashboardTaskDto>(); var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>(); var inProgress = new List<DashboardTaskDto>();
@@ -425,7 +444,10 @@ public sealed class TaskService(
foreach (var task in all) foreach (var task in all)
{ {
var dto = MapToDtoWithChildren(task, all, activity); var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
if (!isMaster) continue;
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
switch (task.State.ToLowerInvariant()) switch (task.State.ToLowerInvariant())
{ {
case "backlog": offen.Add(dto); break; case "backlog": offen.Add(dto); break;
@@ -481,12 +503,79 @@ public sealed class TaskService(
if (!TaskStateHelper.CanChangeState(caller, task)) if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState); return new TaskOperationResult(TaskOperationOutcome.InvalidState);
task.State = canonical; return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
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); /// <summary>
await PublishBoardSnapshotAsync(ct); /// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
return new TaskOperationResult(TaskOperationOutcome.Success, task); /// </summary>
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.ExpectedFrom = null;
return await UpdateTaskStatusInternalAsync(
task,
TaskStateHelper.ToStateString(TaskState.Done),
caller,
"review",
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
ct);
}
/// <summary>
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
/// </summary>
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.ToStateString(TaskState.InProgress);
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
target = TaskStateHelper.ToStateString(TaskState.InProgress);
var trimmed = comment.Trim();
await activityRepo.AddAsync(new ActivityEvent
{
Type = "review_changes_requested",
Message = $"Änderung angefordert von {caller}: {trimmed}",
TaskId = task.Id
}, ct);
task.ExpectedFrom = "iris";
var result = await UpdateTaskStatusInternalAsync(
task, target, caller, "review",
$"Review zurückgegeben von {caller} → {target}", ct);
await notificationService.CreateAsync(
"task_changes_requested",
$"Änderung angefordert: {task.Title}",
trimmed,
"iris",
task.Id,
ct);
return result;
} }
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default) public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
@@ -495,30 +584,8 @@ public sealed class TaskService(
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct); return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
} }
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ => staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks)
{
var prevState = task.State;
task.State = "Backlog";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = "task",
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
TaskId = task.Id
}, ct);
}
if (staleTasks.Count > 0)
await PublishBoardSnapshotAsync(ct);
return staleTasks.Count;
}
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default) public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
{ {
@@ -528,28 +595,46 @@ public sealed class TaskService(
.ToList(); .ToList();
} }
/// <summary>
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
/// </summary>
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.UpdatedAt)
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default) public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{ {
var all = await activityRepo.GetRecentAsync(100, ct); var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList(); return all.Where(e => e.TaskId == taskId).ToList();
} }
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity) private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
{ {
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id) var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.UpdatedAt)
.ToList(); .ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList(); // includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
var childDtos = includeChildren
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
: null;
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase)); var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks); var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with return dto with
{ {
ChildTasks = childDtos, ChildTasks = childDtos,
ChildTaskCount = childDtos.Count, ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount, OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask DoneChildTaskCount = childTasks.Count - openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
}; };
} }
@@ -577,11 +662,25 @@ public sealed class TaskService(
t.ParentTaskId.HasValue || t.IsAgentTask); t.ParentTaskId.HasValue || t.IsAgentTask);
} }
private static string? ValidateAssignedTo(string? assignedTo) private async Task<string?> NormalizeActorAsync(string? actorId, CancellationToken ct)
{ {
if (string.IsNullOrWhiteSpace(assignedTo)) return null; var allowedActors = AgentIdentityCatalog.BuildAllowedActorIds(await agentService.GetAllowedAgentIdsAsync(ct));
var lower = assignedTo.Trim().ToLowerInvariant(); return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
return ValidAssignees.Contains(lower) ? lower : null; }
private static string ResolveInitialAgentTaskState(bool startsInProgress, string? initialState)
{
if (!string.IsNullOrWhiteSpace(initialState))
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(state =>
state.Equals(initialState, StringComparison.OrdinalIgnoreCase));
if (canonical is not null)
return canonical;
}
return startsInProgress
? TaskStateHelper.ToStateString(TaskState.InProgress)
: TaskStateHelper.ToStateString(TaskState.Backlog);
} }
private string ResolveCaller() private string ResolveCaller()
@@ -598,10 +697,29 @@ public sealed class TaskService(
return nameClaim?.ToLowerInvariant() ?? ""; return nameClaim?.ToLowerInvariant() ?? "";
} }
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, CancellationToken ct) private async Task<TaskOperationResult> UpdateTaskStatusInternalAsync(
WorkTask task,
string canonical,
string actor,
string activityType,
string? activityMessage,
CancellationToken ct)
{ {
var caller = ResolveCaller(); task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent
{
Type = activityType,
Message = activityMessage ?? $"Task \"{task.Title}\" → {canonical}",
TaskId = task.Id
}, ct);
await CreateStatusChangeNotificationsAsync(task, canonical, actor, ct);
await PublishBoardSnapshotAsync(ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
private async Task CreateStatusChangeNotificationsAsync(WorkTask task, string canonical, string caller, CancellationToken ct)
{
if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase)) if (string.Equals(canonical, "Review", StringComparison.OrdinalIgnoreCase))
{ {
await notificationService.CreateAsync( await notificationService.CreateAsync(
+6
View File
@@ -5,6 +5,7 @@
"Integrations": { "Integrations": {
"OpenClaw": { "OpenClaw": {
"BaseUrl": "http://127.0.0.1:18789", "BaseUrl": "http://127.0.0.1:18789",
"RequiredVersion": "",
"Token": "", "Token": "",
"Password": "" "Password": ""
}, },
@@ -21,5 +22,10 @@
"AccessTokenExpirationMinutes": 15, "AccessTokenExpirationMinutes": 15,
"RefreshTokenExpirationDays": 7 "RefreshTokenExpirationDays": 7
}, },
"TaskRecovery": {
"StalledMinutes": 40,
"IntervalMinutes": 10,
"StaleHours": 2
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+25 -28
View File
@@ -1,9 +1,17 @@
name: nexus name: nexus
services: services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped # WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
command:
- postgres
- -c
- archive_mode=off
- -c
- archive_command=
restart: always
deploy: deploy:
resources: resources:
limits: limits:
@@ -29,22 +37,19 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
api: api:
build: build:
context: ./backend context: ./backend
restart: unless-stopped args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 512M
reservations: reservations:
memory: 128M 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
@@ -52,22 +57,19 @@ services:
Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env} Jwt__Key: ${JWT_KEY:?Set JWT_KEY in .env}
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} Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
# OWNER_PASSWORD is only used during initial seed (first deploy). # Initial owner password is generated once at first seed and then lives only in the DB.
# After that the DB is the single source of truth, enforced by SeedAudit. Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
# Default: empty (seed uses a random password if unset on first run).
Owner__Password: ${OWNER_PASSWORD:-}
Owner__DisplayName: ${OWNER_DISPLAY_NAME:-Owner}
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-} Admin__ResetToken: ${Admin__ResetToken:-}
NexusApiKey: ${NEXUS_API_KEY:-} NexusApiKey: ${NEXUS_API_KEY:-}
AgentConfigPath: /etc/nexus/agents-sanitized.json
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
postgres: postgres:
condition: service_started condition: service_healthy
restart: true restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"] test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
@@ -76,7 +78,7 @@ services:
retries: 3 retries: 3
start_period: 15s start_period: 15s
volumes: volumes:
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro - /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/etc/nexus/agents-sanitized.json:ro
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris - /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer - /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer - /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
@@ -91,22 +93,19 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
web: web:
build: build:
context: ./frontend context: ./frontend
restart: unless-stopped args:
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
restart: always
deploy: deploy:
resources: resources:
limits: limits:
memory: 128M memory: 128M
reservations: reservations:
memory: 32M memory: 32M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)" - "traefik.http.routers.nexus.rule=Host(`nexus.noveria.net`)"
@@ -117,7 +116,7 @@ services:
- "127.0.0.1:18880:80" - "127.0.0.1:18880:80"
depends_on: depends_on:
api: api:
condition: service_started condition: service_healthy
restart: true restart: true
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
@@ -133,13 +132,11 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
networks: networks:
nexus: nexus:
openclaw_default: openclaw_default:
external: true external: true
proxy: proxy:
external: true external: true
volumes: volumes:
nexus-postgres: nexus-postgres:
+16 -21
View File
@@ -68,8 +68,8 @@ Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren
│ │ │ │
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │ │ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
└──────────────┬──────────────────────────────┬────────────────────┘ └──────────────┬──────────────────────────────┬────────────────────┘
host.docker.internal:18789 │ openclaw-gateway-bao:18789 │
│ (Gateway loopback/lan) │ (internes Docker-DNS)
▼ │ ▼ │
┌──────────────────────────────┐ │ ┌──────────────────────────────┐ │
│ OpenClaw Gateway Container │ │ │ OpenClaw Gateway Container │ │
@@ -199,7 +199,7 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
``` ```
POST /api/v1/operations/snapshot POST /api/v1/operations/snapshot
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync() → DashboardService → OpenClawGatewayClient.InvokeToolAsync()
→ POST http://host.docker.internal:18789/tools/invoke → POST http://openclaw-gateway-bao:18789/tools/invoke
Authorization: Bearer <Gateway-Password> Authorization: Bearer <Gateway-Password>
``` ```
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
### 5.2 Docker-Netzwerk & Gateway-Bind ### 5.2 Docker-Netzwerk & Gateway-Bind
**Aktuelles Problem:** **Aktueller Stand (2026-07-09):**
``` ```
compose.yaml: compose.yaml:
api: api:
@@ -228,32 +228,27 @@ compose.yaml:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
networks: networks:
- nexus - nexus
- openclaw_default ← API-Container ist im Gateway-Netzwerk - openclaw_default
Gateway-Konfiguration: Gateway-Konfiguration:
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER gateway.bind: "lan"
Nexus-Konfiguration:
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
``` ```
**Ergebnis:** **Ergebnis:**
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt - Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback - Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt - Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):** Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
```json5
// openclaw.json
{
gateway: {
bind: "lan" // war "loopback"
}
}
```
Alternativ: API-Container über Gateway-Container-Namen ansprechen: Produktive Einstellung:
```yaml ```yaml
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789 Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
``` ```
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk) Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
### 5.3 MCP-artige Integration: Bewertung ### 5.3 MCP-artige Integration: Bewertung
+13 -13
View File
@@ -290,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
```yaml ```yaml
api: api:
extra_hosts:
- host.docker.internal:host-gateway
environment: environment:
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789} Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
networks:
- nexus
- openclaw_default
``` ```
The API container: The API container:
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host - Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
- Has `extra_hosts` configured for `host.docker.internal` - Does not depend on a published host port for the Gateway
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD` - Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
### Known Issue: Gateway Bind = loopback ### Resolved Routing Issue (2026-07-09)
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface. The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
| Scenario | Works? | Why | | Scenario | Works? | Why |
|----------|--------|-----| |----------|--------|-----|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly | | `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback | | `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
| 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`): The Gateway must listen on its container interface (`gateway.bind: "lan"`):
```json5 ```json5
{ {
@@ -325,8 +325,8 @@ The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it onl
**Test command (from Nexus API container):** **Test command (from Nexus API container):**
```bash ```bash
curl -s http://host.docker.internal:18789/health curl -s http://openclaw-gateway-bao:18789/
# Expected: 200 if gateway bind is lan/container IP is reachable # Expected: HTTP 200 from inside nexus-api-1
``` ```
### Required .env Vars for Nexus ### Required .env Vars for Nexus
+21 -3
View File
@@ -10,6 +10,7 @@ Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
- **Sub-Agenten** als ausführende Spezialisten - **Sub-Agenten** als ausführende Spezialisten
- **OpenClaw** als Agent-Runtime - **OpenClaw** als Agent-Runtime
- **Nexus Task Board** als sichtbare Aufgabenquelle - **Nexus Task Board** als sichtbare Aufgabenquelle
- **MCP `/mcp`** als Agent Data Plane fuer Board-Operationen
--- ---
@@ -24,6 +25,8 @@ Das bedeutet:
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten - **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership - **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
- **OpenClaw** = Ausführungspfad für Agentenarbeit - **OpenClaw** = Ausführungspfad für Agentenarbeit
- **MCP** = bevorzugter Agentenpfad zu Nexus; `/api/bridge` bleibt
kompatible interne Fassade, `/api/dashboard` bleibt UI/Admin
--- ---
@@ -57,7 +60,7 @@ flowchart LR
Iris -->|delegiert konkrete Arbeit| OC Iris -->|delegiert konkrete Arbeit| OC
OC -->|führt Agenten-Task aus| Agents OC -->|führt Agenten-Task aus| Agents
Iris -->|legt Child-Tasks an| Board Iris -->|legt Child-Tasks an| Board
Agents -->|arbeiten gegen Child-Tasks| Board Agents -->|MCP Tools /mcp| Board
Agents -->|liefern Ergebnis / melden Blocker| Iris Agents -->|liefern Ergebnis / melden Blocker| Iris
Iris -->|integriert Ergebnis| Board Iris -->|integriert Ergebnis| Board
Board -->|Review für Bao| Bao Board -->|Review für Bao| Bao
@@ -89,6 +92,13 @@ flowchart LR
- liefert Nachrichten, Status und Arbeitsergebnisse zurück - liefert Nachrichten, Status und Arbeitsergebnisse zurück
- ersetzt nicht das Board als Aufgabenwahrheit - ersetzt nicht das Board als Aufgabenwahrheit
### MCP Agent Data Plane
- stellt `nexus_get_board`, `nexus_agent_overview`, Task-, Child-,
Activity-, Status-, Checkpoint- und Handoff-Tools bereit
- nutzt nur kanonische States: `Backlog`, `In progress`, `Blocked`,
`Done`, `Review`
- ist Fassade ueber `ITaskBridgeService`, keine zweite Board-Domaenenlogik
### Nexus Task Board ### Nexus Task Board
- ist die **sichtbare operative Quelle** für Aufgaben - ist die **sichtbare operative Quelle** für Aufgaben
- zeigt Parent-Task, Child-Tasks, Ownership und Status - zeigt Parent-Task, Child-Tasks, Ownership und Status
@@ -266,8 +276,10 @@ Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Do
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris` **„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
### Mögliche Child-Tasks ### Mögliche Child-Tasks
- **Backend-State-Handling anpassen** — Owner: `developer` - **PO-Spezifikation und Akzeptanzkriterien ausarbeiten** — Owner: `product-owner`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer` - **Schnelle Voranalyse / kleiner Patch** — Owner: `programmer-fast`
- **Backend-State-Handling anpassen** — Owner: `programmer`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `programmer`
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer` - **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt` - **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
@@ -309,9 +321,15 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task - `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
- `AssignedTo` zeigt den operativen Owner - `AssignedTo` zeigt den operativen Owner
- Child-Tasks dürfen geplant in `Backlog` erstellt werden; nur aktiv gestartete Delegationen beginnen direkt in `In progress`
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen - Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden - Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
- UI und Doku müssen dieselbe Sprache sprechen - UI und Doku müssen dieselbe Sprache sprechen
- Mission-Control-Gateway-Daten bleiben read-only im Browser: Nexus proxyt Status,
Version und redigierte Activity; Gateway-Token und direkte Gateway-URLs bleiben
im Backend.
- Config-Writes sind Bao/Owner-only, legen vor dem Austausch ein `.bak` an und
schreiben einen `config_audit` Activity-Eintrag.
--- ---
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
.pnpm-store/
.pnpm-home/
.corepack-home/
.git/
.gitignore
.env
*.log
+6
View File
@@ -7,6 +7,12 @@ COPY . .
RUN pnpm build RUN pnpm build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
LABEL org.opencontainers.image.title="Nexus Web" \
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
org.opencontainers.image.version="${NEXUS_VERSION}" \
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80
+9
View File
@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB wenige KB).
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "no-referrer" always; add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
+7 -141
View File
@@ -1,148 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' /**
import { Activity } from '@lucide/vue' * App nur noch Router-Einstieg + Toasts.
import { RouterView, useRoute, useRouter } from 'vue-router' * Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
import { useOperationsStore } from './stores/operations' * und umschließt alle Seiten außer dem Login.
import { useAuthStore } from './stores/auth' */
import AppSidebar from './components/layout/AppSidebar.vue' import { RouterView } from 'vue-router'
import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue' import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const activeView = computed(() => {
if (route.name === 'Settings') return 'Settings'
if (route.name === 'ProjectDetail') return 'ProjectDetail'
return String(route.name ?? 'Dashboard')
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
}
const navigate = (label: string) => {
mobileNavOpen.value = false
return router.push(routePaths[label] ?? '/dashboard')
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents', 'Task Board', 'TaskDetail', 'Notifications'].includes(activeView.value))
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
})
</script> </script>
<template> <template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" /> <RouterView />
<div v-else class="shell">
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileNavOpen"
:queued-tasks="store.snapshot.metrics.queuedTasks"
:incidents="store.snapshot.metrics.incidents"
@navigate="navigate"
/>
<main>
<AppHeader
:connected="store.connected"
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
/>
<section class="content">
<RouterView v-if="standaloneViews" />
<template v-else>
<div class="page-heading">
<div>
<span class="eyebrow">MISSION CONTROL</span>
<h1>{{ activeView }}</h1>
<p>System overview and operational intelligence across Noveria.</p>
</div>
<button class="refresh" @click="store.refresh()">
<Activity :size="15" :class="{ spin: store.loading }" />
Refresh
</button>
</div>
<ModuleView
:view="activeView"
:snapshot="store.snapshot"
:routing="store.routing"
@create-project="store.createProject"
@create-task="store.createTask"
@update-task-state="store.updateTaskState"
/>
</template>
</section>
</main>
<ToastContainer /> <ToastContainer />
</div>
</template> </template>
<style scoped>
.shell {
display: flex;
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
}
</style>
+108 -80
View File
@@ -83,8 +83,11 @@
} }
body { body {
background: hsl(var(--background)); background:
color: hsl(var(--foreground)); radial-gradient(1100px 700px at 12% -10%, rgba(79, 124, 255, 0.10), transparent 60%),
radial-gradient(1000px 700px at 95% 8%, rgba(181, 87, 246, 0.09), transparent 60%),
var(--space-0, #050410);
color: var(--tx, #ece9ff);
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif; 'Segoe UI', sans-serif;
margin: 0; margin: 0;
@@ -92,6 +95,10 @@ body {
min-height: 100vh; min-height: 100vh;
} }
h1, h2, h3, .font-display {
font-family: 'Space Grotesk', 'Manrope', sans-serif;
}
/* Nexus overrides for existing CSS variables used in dashboard */ /* Nexus overrides for existing CSS variables used in dashboard */
:root { :root {
--nx-bg: #080a0f; --nx-bg: #080a0f;
@@ -126,9 +133,9 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 22px 14px 14px; padding: 22px 14px 14px;
border-right: 1px solid #1a1e27; border-right: 1px solid var(--line);
background: rgba(9, 11, 16, 0.94); background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
backdrop-filter: blur(18px); backdrop-filter: blur(14px);
} }
.brand { .brand {
@@ -143,15 +150,16 @@ body {
height: 35px; height: 35px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid #443d7c; border: none;
border-radius: 10px; border-radius: 11px;
background: linear-gradient(145deg, #241f44, #12121f); background: var(--grad);
color: #b8adff; color: #fff;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13); box-shadow: var(--glow-purple);
} }
.brand strong { .brand strong {
display: block; display: block;
font-family: 'Space Grotesk', sans-serif;
font-size: 13px; font-size: 13px;
letter-spacing: 0.14em; letter-spacing: 0.14em;
} }
@@ -178,36 +186,38 @@ body {
gap: 10px; gap: 10px;
border: 0; border: 0;
padding: 9px 10px; padding: 9px 10px;
border-radius: 7px; border-radius: 10px;
background: transparent; background: transparent;
color: #8991a1; color: var(--tx-2);
font-size: 12px; font-size: 12px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.nav button:hover, .nav button:hover {
.nav button.active { color: var(--tx);
color: #ececf5; background: rgba(124, 108, 255, 0.08);
background: var(--nx-accent-soft);
} }
.nav button.active { .nav button.active {
box-shadow: inset 2px 0 var(--nx-accent); color: #fff;
background: linear-gradient(90deg, rgba(124, 108, 255, 0.22), rgba(124, 108, 255, 0.04));
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, 0.25);
} }
.nav button i { .nav button i {
margin-left: auto; margin-left: auto;
padding: 1px 6px; padding: 1px 6px;
border: 1px solid #343947; border: 1px solid var(--line-2);
border-radius: 8px; border-radius: 8px;
background: rgba(124, 108, 255, 0.10);
font-size: 9px; font-size: 9px;
font-style: normal; font-style: normal;
} }
.sidebar-bottom { .sidebar-bottom {
margin-top: auto; margin-top: auto;
border-top: 1px solid #1b1f28; border-top: 1px solid var(--line);
padding-top: 10px; padding-top: 10px;
} }
@@ -240,10 +250,12 @@ body {
height: 31px; height: 31px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 50%; border-radius: 10px;
background: #28243f; background: var(--grad-soft);
color: #bcb3ff; border: 1px solid var(--line-2);
color: var(--tx);
font-size: 10px; font-size: 10px;
font-weight: 700;
} }
main { main {
@@ -256,9 +268,9 @@ main {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 30px; padding: 0 30px;
border-bottom: 1px solid #191d25; border-bottom: 1px solid var(--line);
background: rgba(8, 10, 15, 0.68); background: rgba(8, 6, 20, 0.5);
backdrop-filter: blur(16px); backdrop-filter: blur(14px);
} }
.search { .search {
@@ -266,19 +278,20 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 8px 10px; padding: 8px 12px;
border: 1px solid #202530; border: 1px solid var(--line);
border-radius: 7px; border-radius: 11px;
color: #6f7889; background: rgba(124, 108, 255, 0.06);
color: var(--tx-3);
font-size: 11px; font-size: 11px;
} }
.search kbd { .search kbd {
margin-left: auto; margin-left: auto;
padding: 2px 5px; padding: 2px 5px;
border: 1px solid #2c313d; border: 1px solid var(--line-2);
border-radius: 4px; border-radius: 4px;
color: #606979; color: var(--tx-3);
font-size: 9px; font-size: 9px;
} }
@@ -293,15 +306,20 @@ main {
gap: 6px; gap: 6px;
align-items: center; align-items: center;
font-size: 10px; font-size: 10px;
color: #8c95a5; font-weight: 600;
color: var(--tx-2);
border: 1px solid var(--line-2);
background: rgba(124, 108, 255, 0.07);
border-radius: 20px;
padding: 4px 11px;
} }
.connection.live { .connection.live {
color: var(--nx-green); color: var(--st-work);
} }
.connection.preview { .connection.preview {
color: #e6b75d; color: var(--st-queue);
} }
.ask, .ask,
@@ -309,13 +327,20 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 8px 11px; padding: 8px 13px;
border: 1px solid #37315e; border: none;
border-radius: 7px; border-radius: 10px;
background: #18152a; background: var(--grad);
color: #c4bbff; color: #fff;
font-weight: 600;
box-shadow: var(--glow-purple);
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
transition: filter .16s;
}
.ask:hover {
filter: brightness(1.08);
} }
.content { .content {
@@ -331,7 +356,7 @@ main {
.eyebrow, .eyebrow,
.kicker { .kicker {
color: #7065c8; color: var(--a-mid);
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.18em; letter-spacing: 0.18em;
@@ -351,9 +376,10 @@ h1 {
} }
.refresh-btn { .refresh-btn {
border-color: var(--nx-line); background: rgba(124, 108, 255, 0.07);
background: var(--nx-panel); border: 1px solid var(--line-2);
color: #a5adba; box-shadow: none;
color: var(--tx-2);
} }
.spin { .spin {
@@ -370,7 +396,7 @@ h1 {
display: none; display: none;
border: 0; border: 0;
background: transparent; background: transparent;
color: #aaa4e7; color: var(--tx-2);
} }
/* ── Keep existing module/layout styles for non-dashboard pages ── */ /* ── Keep existing module/layout styles for non-dashboard pages ── */
@@ -383,9 +409,10 @@ h1 {
.metrics article, .metrics article,
.panel { .panel {
border: 1px solid var(--nx-line); border: 1px solid var(--line);
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96)); background: var(--glass);
border-radius: 9px; border-radius: var(--r);
backdrop-filter: blur(12px);
} }
.metrics article { .metrics article {
@@ -393,7 +420,7 @@ h1 {
} }
.metrics span { .metrics span {
color: #717a8a; color: var(--tx-3);
font-size: 8px; font-size: 8px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 0.14em;
@@ -407,12 +434,12 @@ h1 {
} }
.metrics small { .metrics small {
color: #687181; color: var(--tx-3);
font-size: 9px; font-size: 9px;
} }
.metrics small.up { .metrics small.up {
color: #55c995; color: var(--st-work);
} }
.dashboard-grid { .dashboard-grid {
@@ -435,7 +462,7 @@ h1 {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-bottom: 15px; padding-bottom: 15px;
border-bottom: 1px solid #1d222c; border-bottom: 1px solid var(--line);
} }
.panel-head h2 { .panel-head h2 {
@@ -446,7 +473,7 @@ h1 {
.panel-head button { .panel-head button {
border: 0; border: 0;
background: transparent; background: transparent;
color: #8e96a5; color: var(--tx-2);
font-size: 9px; font-size: 9px;
} }
@@ -457,18 +484,18 @@ h1 {
} }
.badge.positive { .badge.positive {
color: var(--nx-green); color: var(--st-work);
background: rgba(81, 212, 154, 0.1); background: rgba(61, 220, 151, 0.12);
} }
.badge.warning { .badge.warning {
color: #e7b660; color: var(--st-queue);
background: rgba(231, 182, 96, 0.1); background: rgba(251, 191, 36, 0.12);
} }
.badge.negative { .badge.negative {
color: #e16e75; color: var(--st-block);
background: rgba(225, 110, 117, 0.1); background: rgba(251, 113, 133, 0.12);
} }
.runtime-row { .runtime-row {
@@ -483,9 +510,10 @@ h1 {
height: 45px; height: 45px;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 9px; border-radius: var(--r-sm);
color: #ad9fff; color: var(--a-mid);
background: var(--nx-accent-soft); background: var(--grad-soft);
border: 1px solid var(--line-2);
} }
.runtime-main strong, .runtime-main strong,
@@ -517,7 +545,7 @@ h1 {
width: 3px; width: 3px;
min-height: 5px; min-height: 5px;
border-radius: 3px; border-radius: 3px;
background: linear-gradient(#927fff, #443b7c); background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
} }
.model { .model {
@@ -526,11 +554,11 @@ h1 {
align-items: center; align-items: center;
gap: 9px; gap: 9px;
padding: 12px 2px; padding: 12px 2px;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.model > span:last-child { .model > span:last-child {
color: #687181; color: var(--tx-3);
font-size: 8px; font-size: 8px;
} }
@@ -538,16 +566,16 @@ h1 {
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: #657083; background: var(--st-idle);
} }
.status-dot.online { .status-dot.online {
background: var(--nx-green); background: var(--st-work);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4); box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
} }
.status-dot.offline { .status-dot.offline {
background: #e16e75; background: var(--st-block);
} }
.project { .project {
@@ -556,7 +584,7 @@ h1 {
align-items: center; align-items: center;
gap: 11px; gap: 11px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.project-letter { .project-letter {
@@ -564,9 +592,9 @@ h1 {
height: 31px; height: 31px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 1px solid #353047; border: 1px solid var(--line-2);
border-radius: 7px; border-radius: 7px;
color: #a99cf5; color: var(--a-mid);
font-size: 10px; font-size: 10px;
} }
@@ -581,7 +609,7 @@ h1 {
} }
.project b { .project b {
color: #838c9c; color: var(--tx-2);
font-size: 9px; font-size: 9px;
} }
@@ -590,14 +618,14 @@ h1 {
margin-top: 8px; margin-top: 8px;
overflow: hidden; overflow: hidden;
border-radius: 4px; border-radius: 4px;
background: #242936; background: var(--space-3);
} }
.progress i { .progress i {
display: block; display: block;
height: 100%; height: 100%;
border-radius: inherit; border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff); background: var(--grad);
} }
.event { .event {
@@ -605,7 +633,7 @@ h1 {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
gap: 10px; gap: 10px;
padding: 12px 0; padding: 12px 0;
border-bottom: 1px solid #1b2029; border-bottom: 1px solid var(--line);
} }
.event > span { .event > span {
@@ -613,19 +641,19 @@ h1 {
height: 6px; height: 6px;
margin-top: 4px; margin-top: 4px;
border-radius: 50%; border-radius: 50%;
background: #657083; background: var(--st-idle);
} }
.event > span.runtime { .event > span.runtime {
background: var(--nx-green); background: var(--st-work);
} }
.event > span.deploy { .event > span.deploy {
background: #8b7cf6; background: var(--a-mid);
} }
.event > span.security { .event > span.security {
background: #e5ad52; background: var(--st-queue);
} }
.placeholder { .placeholder {
@@ -639,7 +667,7 @@ h1 {
.placeholder svg { .placeholder svg {
margin-bottom: 18px; margin-bottom: 18px;
color: #8074d8; color: var(--a-mid);
} }
.placeholder h2 { .placeholder h2 {
+97
View File
@@ -37,6 +37,14 @@
--st-block: #fb7185; --st-block: #fb7185;
--st-idle: #6b6796; --st-idle: #6b6796;
/* ── Agent/Role Semantic Colors ───────────────────── */
--clr-iris: #c084fc;
--clr-bao: #60a5fa;
--clr-agent: #6ee7b7;
--clr-review: #fdba74;
--clr-stale: #fda4af;
--clr-other: #fb923c;
/* ── Glows ────────────────────────────────────────── */ /* ── Glows ────────────────────────────────────────── */
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55); --glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65); --glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
@@ -53,6 +61,45 @@
--sidebar-w: 248px; --sidebar-w: 248px;
--topbar-h: 62px; --topbar-h: 62px;
--rail-w: 360px; --rail-w: 360px;
/* Legacy-Aliasse (v1-Views) V2-Tokens
Ältere Views konsumieren noch die v1-Variablennamen.
Nicht anfassen: --border/--accent (shadcn-HSL-Tripel,
werden als hsl(var(--)) konsumiert). */
--nx-bg: var(--space-1);
--nx-panel: var(--glass);
--nx-panel-soft: var(--glass-2);
--nx-line: var(--line);
--nx-muted: var(--tx-3);
--nx-accent: var(--a-mid);
--nx-accent-soft: rgba(124, 108, 255, 0.10);
--nx-green: var(--st-work);
--nx-text: var(--tx);
--nx-text-dim: var(--tx-2);
--panel: var(--glass);
--card-color: var(--glass);
--surface: var(--space-2);
--surface-raised: var(--space-3);
--accent-soft: rgba(124, 108, 255, 0.10);
--accent-secondary: var(--a-purple);
--text-primary: var(--tx);
--text-secondary: var(--tx-2);
--text-muted: var(--tx-3);
--text-dim: var(--tx-3);
/* ── State Pill Colors ───────────────────────────── */
--pill-backlog: #fde68a;
--pill-progress: #86efac;
--pill-done: #86efac;
--pill-review: #fdba74;
--pill-blocked: #fda4af;
/* Agent semantic colors (legacy aliases) */
--nx-iris: var(--clr-iris);
--nx-bao: var(--clr-bao);
--nx-agent: var(--clr-agent);
--nx-review: var(--clr-review);
--nx-stale: var(--clr-stale);
} }
/* ── Glass card utility ────────────────────────────── */ /* ── Glass card utility ────────────────────────────── */
@@ -108,3 +155,53 @@
/* ── Typography helpers ────────────────────────────── */ /* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; } .font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; } .font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
/* ── Button Variants (nexus-token-basiert) ──────────── */
.nexus-btn-gradient {
border: none;
background: var(--grad);
color: var(--tx);
box-shadow: var(--glow-purple);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.nexus-btn-gradient:hover { opacity: .85; transform: translateY(-1px); }
.nexus-btn-gradient:active { transform: translateY(0); }
.nexus-btn-icon {
display: grid;
place-items: center;
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-icon:hover { background: rgba(124,108,255,.10); color: var(--tx); }
.nexus-btn-ghost-subtle {
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-ghost-subtle:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.nexus-btn-danger {
border: 1px solid rgba(251,113,133,.3);
background: rgba(251,113,133,.12);
color: var(--st-block);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s;
}
.nexus-btn-danger:hover { background: rgba(251,113,133,.22); }
-630
View File
@@ -1,630 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api'
import { useOperationsStore } from '../stores/operations'
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
createProject: [name: string]
createTask: [title: string, priority: string]
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
async function loadAgents() {
if (agentsLoading.value) return
agentsLoading.value = true
agents.value = await store.fetchAgents()
agentsLoading.value = false
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
})
const newProject = ref('')
const newTask = ref('')
const message = ref('')
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
const chatPending = ref(false)
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
localStorage.setItem('nexus-conversation-id', conversationId.value)
// Task editing state
const editingTaskId = ref<string | null>(null)
// Task approval / rejection state
const approvingTaskId = ref<string | null>(null)
const taskActionError = ref('')
async function handleApproveTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.approveTask(id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally {
approvingTaskId.value = null
}
}
async function handleRejectTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.rejectTask(id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally {
approvingTaskId.value = null
}
}
// Task deletion state
const deletingTaskId = ref<string | null>(null)
const deleteError = ref('')
async function confirmDeleteTask(id: string) {
deleteError.value = ''
try {
await store.deleteTask(id)
deletingTaskId.value = null
} catch (e) {
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
}
}
function cancelDeleteTask() {
deletingTaskId.value = null
deleteError.value = ''
}
const editTaskTitle = ref('')
const editTaskPriority = ref('')
const editTaskProjectId = ref<string | null>(null)
// Activity filtering and pagination
const activityTypeFilter = ref('')
const activitySort = ref('newest')
const activityPage = ref(1)
const activityPageSize = 20
const activityTotalPages = ref(1)
const activityTotalCount = ref(0)
const columns = computed(() =>
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
const availableTypes = computed(() => {
const types = new Set(props.snapshot.activity.map(e => e.type))
return Array.from(types)
})
const filteredActivity = computed(() => {
let items = [...props.snapshot.activity]
if (activityTypeFilter.value) {
items = items.filter(e => e.type === activityTypeFilter.value)
}
if (activitySort.value === 'oldest') {
items.reverse()
}
const total = items.length
activityTotalCount.value = total
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
const start = (activityPage.value - 1) * activityPageSize
return items.slice(start, start + activityPageSize)
})
watch(activityTypeFilter, () => { activityPage.value = 1 })
watch(activitySort, () => { activityPage.value = 1 })
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
editingTaskId.value = task.id
editTaskTitle.value = task.title
editTaskPriority.value = task.priority
editTaskProjectId.value = task.projectId ?? null
}
async function saveEditTask(id: string) {
try {
await store.updateTask(id, {
title: editTaskTitle.value.trim() || undefined,
priority: editTaskPriority.value || undefined,
projectId: editTaskProjectId.value || undefined,
})
editingTaskId.value = null
} catch (e) {
console.error('Failed to update task', e)
}
}
function cancelEditTask() {
editingTaskId.value = null
}
async function sendMessage() {
const value = message.value.trim()
if (!value || chatPending.value) return
chatMessages.value.push({ role: 'owner', content: value })
message.value = ''
chatPending.value = true
try {
const response = await apiFetch('/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
chatMessages.value.push({ role: 'iris', content: payload.content })
} catch (error) {
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
} finally {
chatPending.value = false
}
}
</script>
<template>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
<div v-if="view === 'Projects'" class="module-grid">
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
</article>
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
<article v-for="task in column.items" :key="task.id" class="task-card">
<template v-if="editingTaskId === task.id">
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
<div class="task-edit-row">
<select v-model="editTaskPriority">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Normal">Normal</option>
<option value="Low">Low</option>
</select>
<select v-model="editTaskProjectId">
<option :value="null">No project</option>
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="task-edit-actions">
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
</div>
</template>
<template v-else>
<div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions">
<template v-if="task.state === 'In progress'">
<button
class="task-approve-btn"
title="Approve"
:disabled="approvingTaskId === task.id"
@click="handleApproveTask(task.id)"
><CheckCircle2 :size="13" /></button>
<button
class="task-reject-btn"
title="Reject"
:disabled="approvingTaskId === task.id"
@click="handleRejectTask(task.id)"
><X :size="13" /></button>
</template>
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button
v-if="task.state === 'Done' || task.state === 'Backlog'"
class="task-delete-btn"
title="Delete task"
@click="deletingTaskId = task.id; deleteError = ''"
><Trash2 :size="12" /></button>
</div>
</div>
<h3>{{ task.title }}</h3>
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
</select>
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
</template>
</article>
<div v-if="!column.items.length" class="empty-state">No tasks</div>
</section>
</div>
<div v-else-if="view === 'Agents'" class="module-grid">
<div v-if="agentsLoading" class="loading-agents">Loading agents</div>
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
<Zap v-else :size="22" />
</div>
<div>
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
<h3>{{ agent.name }}</h3>
<p>{{ agent.description || agent.model }}</p>
</div>
<div class="agent-status-group">
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
</div>
</article>
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
</div>
<div v-else-if="view === 'Models'" class="module-list panel">
<div v-for="model in routing" :key="model.model" class="model-detail">
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
</div>
</div>
<div v-else-if="view === 'Activity'" class="activity-panel panel">
<div class="activity-filters">
<div class="filter-group">
<label>Type</label>
<select v-model="activityTypeFilter">
<option value="">All types</option>
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>Sort</label>
<select v-model="activitySort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</div>
</div>
<div class="timeline">
<article v-for="event in filteredActivity" :key="event.message + event.at">
<div :class="['timeline-icon', event.type]">
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
<ShieldAlert v-else :size="15" />
</div>
<div>
<span class="kicker">{{ event.type }}</span>
<h3>{{ event.message }}</h3>
<p>{{ new Date(event.at).toLocaleString() }}</p>
</div>
</article>
</div>
<div v-if="activityTotalPages > 1" class="activity-pagination">
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
</div>
</div>
<div v-else-if="view === 'Settings'" class="settings-redirect">
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
</div>
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
</div>
<!-- Task deletion confirmation dialog -->
<Teleport to="body">
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
<div class="delete-dialog">
<h3>Delete Task?</h3>
<p>This action cannot be undone. The task will be permanently removed.</p>
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
<div class="delete-actions">
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.task-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.task-card-actions {
display: flex;
gap: 0.15rem;
align-items: center;
}
.task-edit-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s;
}
.task-card:hover .task-edit-btn {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-delete-btn {
opacity: 1;
}
.task-delete-btn:hover {
color: var(--danger, #e74c3c);
}
.task-edit-input {
width: 100%;
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 0.4rem;
}
.task-edit-row {
display: flex;
gap: 0.35rem;
margin-bottom: 0.4rem;
}
.task-edit-row select {
flex: 1;
padding: 0.25rem 0.4rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-primary);
}
.task-edit-actions {
display: flex;
gap: 0.35rem;
}
.task-edit-save {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.task-edit-cancel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.activity-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-group label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.filter-group select {
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-primary);
}
.activity-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.activity-pagination button {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.activity-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.activity-pagination span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.project-card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.project-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.settings-redirect {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
text-decoration: underline;
}
/* Task deletion confirmation */
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.delete-dialog h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.delete-dialog p {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.4;
}
.delete-error {
color: var(--danger, #e74c3c) !important;
font-weight: 600;
}
.delete-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.delete-cancel {
padding: 0.45rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.delete-confirm {
padding: 0.45rem 1rem;
background: var(--danger, #e74c3c);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.delete-confirm:hover {
opacity: 0.9;
}
/* Agent card enhancements */
.agent-status-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
}
.agent-model-tag {
font-size: 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--text-muted);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-agents {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* Task approve/reject buttons */
.task-approve-btn,
.task-reject-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-approve-btn,
.task-card:hover .task-reject-btn {
opacity: 1;
}
.task-approve-btn {
color: var(--success, #27ae60);
}
.task-approve-btn:hover {
color: var(--success, #27ae60);
filter: brightness(1.2);
}
.task-reject-btn {
color: var(--warning, #f39c12);
}
.task-reject-btn:hover {
color: var(--danger, #e74c3c);
}
.task-approve-btn:disabled,
.task-reject-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
/**
* BoardCard eine Master-Task-Karte im Board.
*
* Zeigt nur Top-Level-Tasks; Child-Tasks der Agenten leben ausklappbar
* IN der Karte (gruppiert nach Agent) statt als eigene Spalten-Karten
* so bleibt das Board übersichtlich, auch wenn Iris groß zerlegt.
*
* Ball = wer gerade dran ist. Stalled = In-Bearbeitung ohne Aktivität
* seit der Schwelle (Watchdog meldet parallel an Iris).
*/
import { computed, ref } from 'vue'
import { ChevronRight, Check, RotateCcw, Bot, User, AlertTriangle } from '@lucide/vue'
import type { DashboardTaskDto } from '../../stores/tasks'
import { TASK_AGENT_LABELS } from '../../constants/agentPool'
const props = defineProps<{
task: DashboardTaskDto
column: string
canReview: boolean
stallThresholdMin: number
}>()
const emit = defineEmits<{
open: [id: string]
approve: [id: string]
requestChanges: [task: DashboardTaskDto]
dragstart: [e: DragEvent, id: string]
dragend: [e: DragEvent]
}>()
const expanded = ref(false)
const children = computed(() => props.task.childTasks ?? [])
const hasChildren = computed(() => children.value.length > 0)
const totalChildren = computed(() => props.task.childTaskCount ?? children.value.length)
const doneChildren = computed(() =>
props.task.doneChildTaskCount ?? children.value.filter(c => c.state === 'Done').length
)
const progressPct = computed(() =>
totalChildren.value > 0 ? Math.round((doneChildren.value / totalChildren.value) * 100) : 0
)
/* ── Ball: wer ist dran ───────────────────────────── */
const ballAgent = computed(() => {
const s = props.task.state.toLowerCase()
if (s === 'review') return 'bao'
if (s === 'done') return null
if (s === 'backlog') return props.task.expectedFrom || 'iris'
return props.task.expectedFrom || props.task.assignedTo || 'iris'
})
function agentLabel(id?: string | null): string {
if (!id) return '—'
return TASK_AGENT_LABELS[id.toLowerCase()] ?? id
}
function agentClass(id?: string | null): string {
const lower = (id ?? '').toLowerCase()
if (lower === 'iris') return 'is-iris'
if (lower === 'bao') return 'is-bao'
return 'is-agent'
}
/* ── Stalled-Erkennung (rein aus Aktivitätszeit) ──── */
function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
function isStalled(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > props.stallThresholdMin
}
const masterStalled = computed(() => {
if (hasChildren.value) return children.value.some(isStalled)
return isStalled(props.task)
})
/* ── Child-Gruppierung nach Agent ─────────────────── */
const childrenByAgent = computed(() => {
const groups = new Map<string, DashboardTaskDto[]>()
for (const child of children.value) {
const key = child.assignedTo || 'unassigned'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(child)
}
return [...groups.entries()].map(([agent, tasks]) => ({ agent, tasks }))
})
const assigneeInitials = computed(() => {
const unique = new Set(children.value.map(c => c.assignedTo).filter(Boolean) as string[])
if (!unique.size && props.task.assignedTo) unique.add(props.task.assignedTo)
return [...unique].slice(0, 4).map(a => agentLabel(a).replace(/^[^\w]+/, '').slice(0, 2).toUpperCase())
})
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'High'
if (lower === 'low' || lower === 'minor') return 'Low'
return 'Med'
}
function priorityClass(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'prio-high'
if (lower === 'low' || lower === 'minor') return 'prio-low'
return 'prio-med'
}
function childStateLabel(state: string): string {
const map: Record<string, string> = {
'backlog': 'Offen', 'in progress': 'Aktiv', 'review': 'Review', 'blocked': 'Blockiert', 'done': 'Fertig',
}
return map[state.toLowerCase()] ?? state
}
function childStateClass(state: string): string {
const s = state.toLowerCase()
if (s === 'done') return 'cs-done'
if (s === 'blocked') return 'cs-blocked'
if (s === 'review') return 'cs-review'
if (s === 'in progress') return 'cs-active'
return 'cs-backlog'
}
function relTime(date?: string | null): string {
if (!date) return 'keine Aktivität'
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const h = Math.round(mins / 60)
if (h < 24) return `vor ${h} h`
return `vor ${Math.round(h / 24)} d`
}
function toggleExpand(e: MouseEvent) {
e.stopPropagation()
expanded.value = !expanded.value
}
</script>
<template>
<div
class="mcard"
:class="{ 'mcard-blocked': column === 'blocked', 'mcard-stalled': masterStalled }"
draggable="true"
@click="emit('open', task.id)"
@dragstart="emit('dragstart', $event, task.id)"
@dragend="emit('dragend', $event)"
>
<!-- Kopf: Ball + Priorität + Stalled -->
<div class="mcard-top">
<span v-if="ballAgent" class="ball" :class="agentClass(ballAgent)" :title="'Ball bei ' + agentLabel(ballAgent)">
<Bot v-if="ballAgent === 'iris'" :size="11" />
<User v-else-if="ballAgent === 'bao'" :size="11" />
<span v-else class="ball-dot"></span>
{{ agentLabel(ballAgent) }}
</span>
<span class="prio" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="masterStalled" class="stalled-chip" title="Keine Aktivität seit der Schwelle — Iris benachrichtigt">
<AlertTriangle :size="11" /> hängt
</span>
</div>
<!-- Titel -->
<div class="mcard-title">{{ task.title }}</div>
<!-- Fortschritt aus Children -->
<div v-if="hasChildren" class="mcard-progress">
<div class="progress-row">
<button class="expand-btn" :class="{ open: expanded }" @click="toggleExpand" :aria-label="expanded ? 'Einklappen' : 'Ausklappen'">
<ChevronRight :size="14" />
</button>
<span class="progress-text">{{ doneChildren }}/{{ totalChildren }} Teilaufgaben</span>
<div class="avatars">
<span v-for="(ini, i) in assigneeInitials" :key="i" class="avatar-mini">{{ ini }}</span>
</div>
</div>
<div class="progress-track"><div class="progress-fill" :style="{ width: progressPct + '%' }"></div></div>
</div>
<div v-else-if="task.detail" class="mcard-preview">{{ task.detail }}</div>
<!-- Ausgeklappte Children, gruppiert nach Agent -->
<div v-if="expanded && hasChildren" class="children" @click.stop>
<div v-for="group in childrenByAgent" :key="group.agent" class="child-group">
<div class="child-group-head">
<span class="child-agent" :class="agentClass(group.agent)">{{ agentLabel(group.agent) }}</span>
<span class="child-group-count">{{ group.tasks.length }}</span>
</div>
<button
v-for="child in group.tasks"
:key="child.id"
type="button"
class="child-row"
@click.stop="emit('open', child.id)"
>
<span class="child-title">{{ child.title }}</span>
<span class="child-tail">
<span v-if="isStalled(child)" class="child-stalled" title="hängt"><AlertTriangle :size="10" /></span>
<span class="child-state" :class="childStateClass(child.state)">{{ childStateLabel(child.state) }}</span>
</span>
</button>
</div>
</div>
<!-- Review-Aktionen -->
<div v-if="column === 'review' && canReview" class="review-actions" @click.stop>
<button class="rv-approve" @click="emit('approve', task.id)"><Check :size="13" /> Abnehmen</button>
<button class="rv-changes" @click="emit('requestChanges', task)"><RotateCcw :size="13" /> Änderung</button>
</div>
<div class="mcard-meta">
<span>Update {{ relTime(task.lastActivityAt ?? task.updatedAt) }}</span>
</div>
</div>
</template>
<style scoped>
.mcard {
padding: 11px 12px;
border-radius: var(--r-sm, 10px);
background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35));
border: 1px solid var(--line);
cursor: pointer;
transition: transform .15s, box-shadow .2s, border-color .15s;
text-align: left;
width: 100%;
}
.mcard:hover { transform: translateY(-1px); border-color: var(--line-2); box-shadow: 0 8px 24px -6px rgba(0,0,0,.4); }
.mcard-blocked { border-left: 3px solid var(--st-block); }
.mcard-stalled { border-left: 3px solid var(--st-queue); }
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); }
.prio-med { color: var(--st-queue); border-color: var(--st-queue); }
.prio-low { color: var(--a-blue); border-color: var(--a-blue); }
.stalled-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; font-size: 9.5px; font-weight: 600; color: var(--st-queue); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); padding: 1px 6px; border-radius: 20px; }
.mcard-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.mcard-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mcard-progress { margin-top: 9px; }
.progress-row { display: flex; align-items: center; gap: 8px; }
.expand-btn { display: grid; place-items: center; width: 20px; height: 20px; border: none; border-radius: 6px; background: rgba(124,108,255,.08); color: var(--tx-2); cursor: pointer; transition: transform .15s, background .15s; flex: 0 0 auto; }
.expand-btn:hover { background: rgba(124,108,255,.16); color: var(--tx); }
.expand-btn.open { transform: rotate(90deg); }
.progress-text { font-size: 10.5px; color: var(--tx-2); font-family: 'Manrope', sans-serif; }
.avatars { margin-left: auto; display: flex; }
.avatar-mini { width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; background: var(--grad-soft); border: 1px solid var(--space-1); display: grid; place-items: center; font-size: 8px; font-weight: 700; color: var(--tx); font-family: 'JetBrains Mono', monospace; }
.avatar-mini:first-child { margin-left: 0; }
.progress-track { height: 4px; margin-top: 6px; border-radius: 2px; background: var(--space-3); overflow: hidden; }
.progress-fill { height: 100%; border-radius: 2px; background: var(--grad); transition: width .3s; }
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); }
.child-title { flex: 1; font-size: 11px; line-height: 1.35; word-break: break-word; }
.child-tail { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.child-stalled { color: var(--st-queue); display: inline-flex; }
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
.review-actions { display: flex; gap: 6px; margin-top: 10px; }
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); }
.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
.rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
</style>
@@ -10,6 +10,9 @@ defineProps<{
saving: boolean saving: boolean
saveStatus: 'idle' | 'saved' | 'error' saveStatus: 'idle' | 'saved' | 'error'
saveMessage: string saveMessage: string
backupStatus: string
reloadStatus: string
reloadMessage: string
}>() }>()
defineEmits<{ defineEmits<{
@@ -60,6 +63,12 @@ function onInput(event: Event) {
</div> </div>
</div> </div>
<div v-if="reloadMessage" class="editor-health">
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
<span class="health-note">{{ reloadMessage }}</span>
</div>
<!-- Text editor --> <!-- Text editor -->
<textarea <textarea
class="config-editor" class="config-editor"
@@ -89,6 +98,17 @@ function onInput(event: Event) {
border-bottom: 1px solid var(--line, #1e2030); border-bottom: 1px solid var(--line, #1e2030);
gap: 12px; gap: 12px;
} }
.editor-health {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid var(--line, #1e2030);
background: rgba(255,255,255,.015);
color: #8e96a8;
font-size: 10.5px;
flex-wrap: wrap;
}
.editor-file-info { .editor-file-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -165,6 +185,30 @@ function onInput(event: Event) {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }
.health-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line, #1e2030);
border-radius: 999px;
padding: 2px 8px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.health-pill.created {
color: #51d49a;
border-color: rgba(81,212,154,.3);
}
.health-pill.not_applicable {
color: #d4b26a;
border-color: rgba(212,178,106,.25);
}
.health-pill.not_supported {
color: #9aa4bb;
border-color: rgba(154,164,187,.25);
}
.health-note {
color: #7e8799;
}
.config-editor { .config-editor {
width: 100%; width: 100%;
@@ -298,7 +298,7 @@ function avatarLabel() {
.m-av.iris { .m-av.iris {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -328,12 +328,12 @@ function avatarLabel() {
font-weight: 600; font-weight: 600;
border: 1px solid transparent; border: 1px solid transparent;
} }
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); } .badge-blue { background:rgba(79,124,255,.14); color:var(--a-blue); border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); } .badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); } .badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); } .badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); } .badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); } .badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); } .badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill { .m-pill {
@@ -428,7 +428,7 @@ function avatarLabel() {
} }
.m-bar.work i { .m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97); background: linear-gradient(90deg, var(--st-work), var(--st-work));
box-shadow: var(--glow-work); box-shadow: var(--glow-work);
} }
@@ -510,7 +510,7 @@ function avatarLabel() {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #9fe8fb; color: var(--st-think);
min-height: 72px; min-height: 72px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -593,7 +593,7 @@ function avatarLabel() {
.m-model-btn.active { .m-model-btn.active {
background: var(--grad); background: var(--grad);
border: none; border: none;
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -157,7 +157,7 @@ defineEmits<{
.nc-av.iris-av { .nc-av.iris-av {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -255,7 +255,7 @@ defineEmits<{
} }
.node.is-work .nc-bar i { .node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97); background: linear-gradient(90deg, var(--st-work), var(--st-work));
} }
/* ── Meta ────────────────────────────────────── */ /* ── Meta ────────────────────────────────────── */
@@ -159,7 +159,7 @@ defineEmits<{
border: 1px solid rgba(251,113,133,.3); border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
color: #fda4b0; color: var(--st-block);
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
@@ -86,7 +86,7 @@ function renderEdges() {
const edgeList = buildEdges(props.agents) const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>` let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--a-blue)"/><stop offset="1" stop-color="var(--a-purple)"/></linearGradient></defs>`
let paths = '' let paths = ''
let pulses = '' let pulses = ''
let idCounter = 0 let idCounter = 0
@@ -105,17 +105,17 @@ function renderEdges() {
if (e.kind === 'flow' && live) { if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse // Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>` paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>` paths += `<path d="${d}" fill="none" stroke="var(--st-work)" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>` pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') { } else if (e.kind === 'flow') {
// Inactive flow // Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>` paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>` pulses += `<circle r="2.8" fill="var(--a-mid)" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else { } else {
// Orchestration (Iris Agent) // Orchestration (Iris Agent)
const targetAgent = props.agents.find(a => a.id === e.b) const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34 const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>` paths += `<path d="${d}" fill="none" stroke="var(--a-mid)" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
} }
}) })
@@ -336,7 +336,7 @@ function handleReset() {
border-radius: 10px; border-radius: 10px;
background: var(--grad); background: var(--grad);
border: none; border: none;
color: #fff; color: var(--tx);
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -123,7 +123,7 @@ watch(
.iris-av :deep(svg) { .iris-av :deep(svg) {
width: 18px; width: 18px;
height: 18px; height: 18px;
color: #fff; color: var(--tx);
} }
.iris-name { .iris-name {
@@ -196,7 +196,7 @@ watch(
padding: 24px 0; padding: 24px 0;
} }
.chat-msg-info.error { color: #fda4b0; font-style: normal; } .chat-msg-info.error { color: var(--st-block); font-style: normal; }
.chat-row { .chat-row {
display: flex; display: flex;
@@ -222,7 +222,7 @@ watch(
.bubble.me { .bubble.me {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
margin-left: auto; margin-left: auto;
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
@@ -313,6 +313,6 @@ watch(
.send :deep(svg) { .send :deep(svg) {
width: 17px; width: 17px;
height: 17px; height: 17px;
color: #fff; color: var(--tx);
} }
</style> </style>
@@ -12,7 +12,7 @@ function prioLabel(p: TaskItem['priority']): string {
} }
function prioColor(p: TaskItem['priority']): string { function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff' return p === 'high' ? 'var(--st-block)' : p === 'medium' ? 'var(--st-queue)' : 'var(--a-blue)'
} }
function dotClass(s: TaskItem['status']): string { function dotClass(s: TaskItem['status']): string {
@@ -1,94 +0,0 @@
<script setup lang="ts">
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
defineProps<{
connected: boolean
}>()
defineEmits<{
toggleMobileNav: []
}>()
</script>
<template>
<header class="topbar">
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
<Command :size="19" />
</button>
<div class="search">
<Search :size="16" />
<span>Search operations</span>
<kbd> K</kbd>
</div>
<div class="top-actions">
<span :class="['connection', connected ? 'live' : 'preview']">
<CircleDot :size="13" />
{{ connected ? 'Live' : 'Preview data' }}
</span>
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--nx-panel, #11141b);
}
.mobile-menu { display: none; }
.search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
padding: 6px 12px;
border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px;
color: var(--nx-text-dim, #6f7889);
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 1px 4px;
border: 1px solid #2a2f3d;
border-radius: 4px;
font-size: 8px;
color: #4a5266;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.connection {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
padding: 4px 9px;
border-radius: 6px;
}
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
.ask {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
border: none;
border-radius: 6px;
background: var(--nx-accent, #7b6ef2);
color: #fff;
font-size: 10px;
cursor: pointer;
}
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
}
</style>
@@ -1,216 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { initials } from '../../utils/format'
const props = defineProps<{
activeView: string
mobileNavOpen: boolean
queuedTasks: number
incidents: number
}>()
const emit = defineEmits<{
navigate: [label: string]
}>()
const auth = useAuthStore()
const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle },
{ separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal },
{ label: 'Activity', icon: Activity },
{ label: 'Mobile Chat', icon: MessageSquareText },
]
function onNavigate(label: string) {
emit('navigate', label)
}
async function logout() {
await auth.logout()
await router.replace('/login')
}
</script>
<template>
<aside :class="['sidebar', { open: mobileNavOpen }]">
<div class="brand">
<div class="brand-mark"><Command :size="18" /></div>
<div>
<strong>NEXUS</strong>
<span>Noveria Operations</span>
</div>
</div>
<nav class="nav">
<template v-for="item in navigation" :key="item.label ?? 'sep'">
<div v-if="item.separator" class="nav-separator"></div>
<button
v-else
:class="{ active: activeView === item.label }"
@click="onNavigate(item.label)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
</button>
</template>
</nav>
<div class="sidebar-bottom">
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
<button class="owner" type="button" title="Sign out" @click="logout">
<div class="avatar">{{ ownerInitials }}</div>
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
<LogOut :size="15" />
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 210px;
display: flex;
flex-direction: column;
background: var(--panel, #11141b);
border-right: 1px solid var(--line, #1f2330);
flex-shrink: 0;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 10px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--accent, #7b6ef2);
color: #fff;
}
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
overflow-y: auto;
}
.nav button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
}
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
.nav button i {
margin-left: auto;
background: var(--accent, #7b6ef2);
color: #fff;
font-style: normal;
font-size: 8px;
font-weight: 700;
padding: 1px 5px;
border-radius: 5px;
line-height: 1.4;
}
.nav button i.badge-red {
background: #e16e75;
}
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--nx-line, #1f2330);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
.sidebar-bottom > button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #9ea5b3;
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
.owner {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.owner div strong { display: block; font-size: 9px; }
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
.owner:hover > svg:last-child { opacity: 1; }
.avatar {
width: 26px;
height: 26px;
border-radius: 6px;
display: grid;
place-items: center;
background: var(--accent, #7b6ef2);
color: #fff;
font-size: 9px;
font-weight: 700;
}
@media (max-width: 860px) {
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
.sidebar.open { transform: translateX(0); }
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+269 -148
View File
@@ -1,139 +1,180 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Sidebar kompakte Icon-Rail (V2-Shell, alle Seiten)
*
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
* Mobile: als Drawer über mobileOpen/close.
*/
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents' import { useNotificationStore } from '../../stores/notifications'
import { useTaskStore } from '../../stores/tasks' import { useLiveSyncStore } from '../../stores/liveSync'
import { navigation, icons } from '../../composables/icons' import { railNav, railFooterNav, svg } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons' import { initials } from '../../utils/format'
defineProps<{ defineProps<{
mobileOpen?: boolean mobileOpen?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
close: [] close: []
}>() }>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const agentStore = useAgentStore() const notificationStore = useNotificationStore()
const taskStore = useTaskStore() const liveSync = useLiveSyncStore()
const ownerInitials = computed(() => const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW' auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
) )
function logout() { function isActive(itemRoute?: string): boolean {
auth.logout() if (!itemRoute) return false
router.replace('/login') if (route.path === itemRoute) return true
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
return route.path.startsWith(itemRoute + '/')
} }
/** function navigate(itemRoute?: string) {
* Dynamische Nav-Item-Counts aus den Stores. if (!itemRoute) return
* Überschreibt die hartcodierten `count`-Werte im navigation-Array. emit('close')
*/ router.push(itemRoute)
const dynamicNavigation = computed<NavGroupDef[]>(() => { }
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
switch (item.label) { async function logout() {
case 'Agenten': await auth.logout()
case 'Hosts · OpenClaw': await router.replace('/login')
dynamicCount = String(agentStore.agentList.length) }
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
}
return { const statusLabel = computed(() => {
...item, if (liveSync.connected) return 'Live'
count: dynamicCount ?? item.count, if (liveSync.connecting) return 'Verbinde…'
} return 'Polling'
}),
}))
}) })
</script> </script>
<template> <template>
<aside :class="['sidebar', { open: mobileOpen }]"> <aside :class="['rail', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand --> <!-- Brand -->
<div class="side-top"> <button class="rail-brand" @click="navigate('/dashboard')">
<div class="brand-mark" v-html="icons.command || ''"></div> <span class="brand-mark" v-html="svg('command')"></span>
<div> <span class="rail-label brand-label">NEXUS</span>
<div class="brand-name">NEXUS</div> </button>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation --> <!-- Navigation -->
<nav class="nav"> <nav class="rail-nav v2-scroll">
<NavGroup <button
v-for="(group, idx) in dynamicNavigation" v-for="item in railNav"
:key="idx" :key="item.route"
:label="group.group" :class="['rail-item', { active: isActive(item.route) }]"
:items="group.items" :title="item.label"
/> @click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-dot"
></span>
<span class="rail-label">{{ item.label }}</span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-count"
>{{ notificationStore.unreadCount }}</span>
</button>
</nav> </nav>
<!-- Footer --> <!-- Footer -->
<div class="side-foot"> <div class="rail-foot">
<div class="avatar">{{ ownerInitials }}</div> <div class="rail-item static" :title="statusLabel">
<div class="owner-info"> <span class="rail-icon">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div> <span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div> </span>
<span class="rail-label dim">{{ statusLabel }}</span>
</div>
<button
v-for="item in railFooterNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span class="rail-label">{{ item.label }}</span>
</button>
<div class="rail-owner">
<span class="avatar">{{ ownerInitials }}</span>
<span class="rail-label owner-label">
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
</span>
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
</div> </div>
</div> </div>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.sidebar { .rail {
width: 248px; position: absolute;
flex: 0 0 248px; inset: 0 auto 0 0;
height: 100vh; width: 68px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92)); background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
padding: 0; overflow: hidden;
position: relative; transition: width .18s ease, box-shadow .18s ease;
z-index: 2; z-index: 100;
} }
.side-top { .rail:hover {
width: 232px;
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
}
/* Labels: unsichtbar bis die Rail expandiert */
.rail-label {
opacity: 0;
white-space: nowrap;
transition: opacity .14s ease .04s;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.rail:hover .rail-label,
.rail.open .rail-label {
opacity: 1;
}
/* ── Brand ── */
.rail-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 11px; gap: 13px;
padding: 18px 18px 16px; padding: 15px;
border: none;
background: transparent;
cursor: pointer;
} }
.brand-mark { .brand-mark {
width: 38px; width: 38px;
height: 38px; height: 38px;
flex: 0 0 38px;
border-radius: 11px; border-radius: 11px;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--grad); background: var(--grad);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
flex: 0 0 auto;
} }
.brand-mark :deep(svg) { .brand-mark :deep(svg) {
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
color: #fff; color: #fff;
} }
.brand-name { .brand-label {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-weight: 700; font-weight: 700;
font-size: 17px; font-size: 16px;
letter-spacing: .14em; letter-spacing: .14em;
line-height: 1; color: var(--tx);
} }
.brand-sub { /* ── Nav ── */
font-size: 10.5px; .rail-nav {
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1; flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
padding: 6px 12px;
overflow-y: auto;
overflow-x: hidden;
} }
.side-foot { .rail-item {
padding: 12px; position: relative;
border-top: 1px solid var(--line);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 13px;
cursor: pointer; height: 42px;
transition: background .15s; padding: 0 13px;
} flex: 0 0 auto;
.side-foot:hover {
background: rgba(124,108,255,.06);
}
.sidebar-close {
display: none;
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 18px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none; border: none;
border-radius: 11px;
background: transparent; background: transparent;
color: var(--tx-2); color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-weight: 500;
text-align: left;
cursor: pointer; cursor: pointer;
z-index: 1; transition: background .15s, color .15s;
} }
.sidebar-close:hover { .rail-item:not(.static):hover {
background: rgba(124,108,255,.1); background: rgba(124, 108, 255, .08);
color: var(--tx); color: var(--tx);
} }
.sidebar-close :deep(svg) { .rail-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
}
.rail-item.static {
cursor: default;
}
.rail-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
} flex: 0 0 18px;
display: grid;
place-items: center;
opacity: .9;
}
.rail-icon :deep(svg) {
width: 18px;
height: 18px;
}
.rail-item .rail-label {
flex: 1;
}
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
.rail-dot {
position: absolute;
left: 24px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--st-block);
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
}
.rail-count {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(251, 113, 133, .16);
border: 1px solid rgba(251, 113, 133, .35);
color: var(--st-block);
}
/* ── Footer ── */
.rail-foot {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 12px 10px;
border-top: 1px solid var(--line);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--st-work);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.rail-label.dim {
color: var(--tx-3);
font-size: 12px;
}
.rail-owner {
display: flex;
align-items: center;
gap: 13px;
padding: 7px 5px 2px;
} }
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
flex: 0 0 34px;
border-radius: 10px; border-radius: 10px;
background: var(--grad-soft); background: var(--grad-soft);
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 12px;
color: var(--tx); color: var(--tx);
flex-shrink: 0;
} }
.owner-info { .owner-label {
min-width: 0; flex: 1;
display: flex;
flex-direction: column;
} }
.owner-name { .owner-name {
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.owner-role { .owner-role {
font-size: 10px; font-size: 10px;
color: var(--tx-3); color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize; text-transform: capitalize;
} }
.logout-btn {
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
padding: 6px;
border-radius: 8px;
display: grid;
place-items: center;
}
.logout-btn:hover {
color: var(--st-block);
background: rgba(251, 113, 133, .1);
}
.logout-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Mobile: Drawer ── */
@media (max-width: 767px) {
.rail {
position: fixed;
width: 232px;
transform: translateX(-100%);
transition: transform .22s ease;
}
.rail.open {
transform: translateX(0);
}
}
</style> </style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>()
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* ActivityTimeline Wiederverwendbare Aktivitäts-Feed-Komponente
*
* Standardisierte Timeline-Darstellung für Task-Aktivität,
* Agent-Aktivität und allgemeine Ereignis-Feeds.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
export interface ActivityEntry {
id?: string
message: string
timestamp?: string
type?: string
}
withDefaults(defineProps<{
entries: ActivityEntry[]
loading?: boolean
emptyLabel?: string
}>(), {
emptyLabel: 'Noch keine Aktivität',
})
function formatDate(date?: string | null): string {
if (!date) return ''
return new Date(date).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
</script>
<template>
<div class="activity-timeline">
<!-- Loading -->
<div v-if="loading" class="activity-empty">Lade</div>
<!-- Empty -->
<div v-else-if="!entries.length" class="activity-empty">{{ emptyLabel }}</div>
<!-- Entries -->
<article v-for="(entry, index) in entries" :key="entry.id ?? index" class="activity-item">
<div class="activity-dot"></div>
<div class="activity-body">
<div class="activity-message">{{ entry.message }}</div>
<div v-if="entry.timestamp" class="activity-time">
{{ formatDate(entry.timestamp) }}
</div>
</div>
</article>
</div>
</template>
<style scoped>
.activity-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.activity-empty {
font-size: 11px;
color: var(--tx-3);
font-style: italic;
padding: 8px;
text-align: center;
font-family: 'Manrope', sans-serif;
}
.activity-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 999px;
margin-top: 5px;
background: var(--grad);
box-shadow: 0 0 0 4px rgba(124, 108, 255, .12);
flex-shrink: 0;
}
.activity-body {
min-width: 0;
}
.activity-message {
color: var(--tx);
font-size: 12.5px;
line-height: 1.45;
}
.activity-time {
color: var(--tx-3);
font-size: 10.5px;
margin-top: 4px;
}
</style>
+97
View File
@@ -1,4 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Badge Nexus V2 Status- und Label-Badge
*
* Erweitert: zusätzliche nexus-token-basierte Varianten für
* Agenten-Rollen, Status-Pills und Prioritäten.
* Original shadcn-Varianten bleiben erhalten.
*/
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -8,10 +15,25 @@ const badgeVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
// Original shadcn
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground', outline: 'text-foreground',
// Nexus V2 Token-basierte Varianten (Farben aus nexus-tokens.css)
work: 'nexus-badge-work',
think: 'nexus-badge-think',
blocked: 'nexus-badge-blocked',
queue: 'nexus-badge-queue',
done: 'nexus-badge-done',
review: 'nexus-badge-review',
iris: 'nexus-badge-iris',
bao: 'nexus-badge-bao',
agent: 'nexus-badge-agent',
priorityHigh: 'nexus-badge-prio-high',
priorityMed: 'nexus-badge-prio-med',
priorityLow: 'nexus-badge-prio-low',
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -35,3 +57,78 @@ const props = withDefaults(defineProps<Props>(), {
<slot /> <slot />
</span> </span>
</template> </template>
<style scoped>
/* Nexus V2 Token-basierte Badge-Varianten */
.nexus-badge-work {
border-color: rgba(61, 220, 151, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-think {
border-color: rgba(52, 214, 245, .25);
background: rgba(52, 214, 245, .1);
color: var(--st-think);
}
.nexus-badge-blocked {
border-color: rgba(251, 113, 133, .25);
background: rgba(244, 63, 94, .12);
color: var(--st-block);
}
.nexus-badge-queue {
border-color: rgba(251, 191, 36, .25);
background: rgba(251, 191, 36, .12);
color: var(--st-queue);
}
.nexus-badge-done {
border-color: rgba(34, 197, 94, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-review {
border-color: rgba(249, 115, 22, .25);
background: rgba(249, 115, 22, .12);
color: var(--clr-review);
}
.nexus-badge-iris {
border-color: rgba(147, 51, 234, .25);
background: rgba(147, 51, 234, .12);
color: var(--clr-iris);
}
.nexus-badge-bao {
border-color: rgba(59, 130, 246, .25);
background: rgba(59, 130, 246, .12);
color: var(--clr-bao);
}
.nexus-badge-agent {
border-color: rgba(16, 185, 129, .2);
background: rgba(16, 185, 129, .12);
color: var(--clr-agent);
}
.nexus-badge-prio-high {
border-color: var(--st-block);
background: transparent;
color: var(--st-block);
}
.nexus-badge-prio-med {
border-color: var(--st-queue);
background: transparent;
color: var(--st-queue);
}
.nexus-badge-prio-low {
border-color: var(--a-blue);
background: transparent;
color: var(--a-blue);
}
</style>
+40 -2
View File
@@ -1,16 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Card Nexus V2 glass-panel Karte
*
* Verwendet nexus-tokens.css als Single Source of Truth.
* Hintergrund-kompatibel mit shadcn-Card-Props.
* Wrapped content in .glass-panel styles aus tokens.css.
*/
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface Props { interface Props {
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
/** Variante: 'glass' (default) | 'raised' | 'subtle' */
variant?: 'glass' | 'raised' | 'subtle'
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
})
</script> </script>
<template> <template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)"> <div
:class="cn(
'rounded-xl border',
{
'glass-panel': variant === 'glass',
'card-raised': variant === 'raised',
'card-subtle': variant === 'subtle',
},
props.class,
)"
>
<slot /> <slot />
</div> </div>
</template> </template>
<style scoped>
/* glass-panel ist in nexus-tokens.css definiert */
.card-raised {
background: var(--glass-2);
border: 1px solid var(--line-2);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
.card-subtle {
background: rgba(255, 255, 255, .02);
border: 1px solid var(--line);
border-radius: var(--r);
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
/**
* EmptyState Standardisierte Leerzustands-Anzeige
*
* Konsistente Darstellung für "keine Daten"-Zustände im Dashboard,
* TaskBoard und allen V2-Views.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Icon-Klasse (optional, z.B. für SVG-Nutzung) */
icon?: string
/** Primärer Text */
title?: string
/** Sekundärer Erklärungstext */
description?: string
/** Kompakte Darstellung (inline-flex) */
compact?: boolean
}>(), {
title: 'Keine Einträge',
compact: false,
})
</script>
<template>
<div class="empty-state" :class="{ compact }">
<div v-if="icon" class="empty-icon" v-html="icon"></div>
<slot name="icon">
<div v-if="!icon" class="empty-icon-default">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="4" y="6" width="20" height="16" rx="3" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="10" y1="16" x2="15" y2="16" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</div>
</slot>
<div v-if="!compact" class="empty-title">{{ title }}</div>
<div v-if="description" class="empty-desc">{{ description }}</div>
<div v-if="$slots.action" class="empty-action">
<slot name="action" />
</div>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 12px;
text-align: center;
}
.empty-state.compact {
flex-direction: row;
gap: 8px;
padding: 8px 12px;
justify-content: flex-start;
}
.empty-icon,
.empty-icon-default {
color: var(--tx-3);
opacity: 0.5;
margin-bottom: 8px;
}
.compact .empty-icon,
.compact .empty-icon-default {
margin-bottom: 0;
flex-shrink: 0;
}
.empty-icon-default {
display: grid;
place-items: center;
}
.empty-title {
font-size: 12px;
font-weight: 600;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
}
.compact .empty-title {
font-size: 11px;
font-weight: 400;
font-style: italic;
color: var(--tx-3);
}
.empty-desc {
margin-top: 4px;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
max-width: 280px;
line-height: 1.4;
}
.empty-action {
margin-top: 12px;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* PageHeading Standardisierter Seiten-Header für V2 Views
*
* Bietet konsistentes grad-text Styling, Eyebrow/Subtitle und Action-Slot.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Gradient-Text Überschrift */
title: string
/** Kleiner Eyebrow-Text über der Überschrift (violett) */
eyebrow?: string
/** Subtitle unter der Überschrift */
subtitle?: string
}>(), {})
</script>
<template>
<header class="page-heading">
<div>
<p v-if="eyebrow || $slots.eyebrow" class="eyebrow">
<slot name="eyebrow">{{ eyebrow }}</slot>
</p>
<h1><span class="grad-text">{{ title }}</span></h1>
<p v-if="subtitle" class="board-subtitle">{{ subtitle }}</p>
<slot name="meta" />
</div>
<div v-if="$slots.actions" class="heading-actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-heading h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
letter-spacing: -0.02em;
}
.eyebrow {
color: var(--a-mid);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
margin: 0 0 4px 0;
}
.board-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
}
.grad-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.heading-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* SectionHeader Titelzeile für Sektionen/Spalten im Dashboard V2
*
* Kapselt wiederholtes Muster: dot + label + count aus TaskBoard-Spalten
* und Iris-Panel-Sektionen. Verwendet ausschließlich Token-Farben.
*/
withDefaults(defineProps<{
label: string
/** Status-Farbe des Dots: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao' */
dotColor?: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao'
/** Numerischer Count (rechts) */
count?: number
/** Variante: 'column' (Spalten-Header) oder 'section' (Iris-Panel) */
variant?: 'column' | 'section'
}>(), {
variant: 'column',
})
function dotColorVar(color: string): string {
const map: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
queue: 'var(--st-queue)',
review: 'var(--st-queue)', // orange-ähnlich
iris: 'var(--a-purple)',
bao: 'var(--a-blue)',
}
return map[color] || 'var(--st-idle)'
}
function dotRingVar(color: string): string {
const map: Record<string, string> = {
work: 'rgba(61, 220, 151, .25)',
think: 'rgba(52, 214, 245, .25)',
idle: 'rgba(107, 103, 150, .25)',
block: 'rgba(251, 113, 133, .25)',
queue: 'rgba(251, 191, 36, .25)',
review: 'rgba(251, 146, 60, .25)',
iris: 'rgba(181, 87, 246, .25)',
bao: 'rgba(79, 124, 255, .25)',
}
return map[color] || 'rgba(107, 103, 150, .25)'
}
</script>
<template>
<div class="section-header" :class="variant">
<span
class="section-dot"
:style="{
background: dotColorVar(dotColor || 'idle'),
boxShadow: `0 0 0 2px ${dotRingVar(dotColor || 'idle')}`,
}"
></span>
<span class="section-label">{{ label }}</span>
<span v-if="count !== undefined" class="section-count">{{ count }}</span>
<slot />
</div>
</template>
<style scoped>
.section-header {
display: flex;
align-items: center;
gap: 8px;
}
.section-header.column {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.section-header.section {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.section-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--tx-2);
font-family: 'Space Grotesk', sans-serif;
}
.section-header.section .section-label {
font-size: 11px;
}
.section-count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 10px;
background: var(--glass-2);
color: var(--tx-2);
border: 1px solid var(--line);
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* SkeletonLoader Platzhalter-Animation für Ladezustände
*
* Extrahiert aus dem Muster in TaskStrip (skeleton-pulse) und
* board-loading spinner. Zentrale Komponente mit zwei Varianten:
*
* - 'card' Rechteckiger Karten-Skeleton
* - 'text' Zeilen-Skeleton für Text
* - 'circle' Runder Skeleton (Avatar, Dot)
*
* Alle Farben aus nexus-tokens.css Token.
*/
withDefaults(defineProps<{
/** Darstellungsform */
variant?: 'card' | 'text' | 'circle'
/** Höhe in px (nur für card/text) */
height?: number
/** Breite in px (optional, sonst 100%) */
width?: number
}>(), {
variant: 'card',
height: 78,
})
</script>
<template>
<div
class="skeleton"
:class="variant"
:style="{
height: variant === 'circle' ? `${width || height || 32}px` : `${height}px`,
width: width ? `${width}px` : '100%',
}"
>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 60%"></div>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 85%; margin-top: 8px"></div>
</div>
</template>
<style scoped>
.skeleton {
border-radius: var(--r-sm, 10px);
background: var(--glass);
overflow: hidden;
position: relative;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .06) 40%,
rgba(124, 108, 255, .12) 50%,
rgba(124, 108, 255, .06) 60%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.text {
background: transparent;
display: flex;
flex-direction: column;
padding: 4px 0;
}
.skeleton-line {
height: 10px;
border-radius: 5px;
background: var(--glass-2);
position: relative;
overflow: hidden;
}
.skeleton-line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .08) 50%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.circle {
border-radius: 50%;
}
@keyframes skeleton-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* StatusDot Nexus V2 animierter Status-Indikator
*
* Kapselt die pulse-keyframes aus nexus-tokens.css.
* Status-Farben und Pulse-Animationen stammen ausschließlich aus Tokens.
*
* Props:
* status 'work' | 'think' | 'idle' | 'block' | 'queue'
* size 'sm' (8px) | 'md' (9px) | 'lg' (12px), default 'md'
* pulse Animation aktiv (default: true für work/think/block)
* label Text-Label rechts neben dem Dot (optional)
*/
withDefaults(defineProps<{
status: 'work' | 'think' | 'idle' | 'block' | 'queue'
size?: 'sm' | 'md' | 'lg'
pulse?: boolean
label?: string
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-dot-wrapper">
<span
class="dot"
:class="[status, size, { pulse: pulse !== false }]"
:aria-label="label || status"
role="status"
></span>
<span v-if="label" class="dot-label">{{ label }}</span>
</span>
</template>
<style scoped>
.status-dot-wrapper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
display: inline-block;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.sm { width: 7px; height: 7px; }
.dot.md { width: 9px; height: 9px; }
.dot.lg { width: 12px; height: 12px; }
/* Farben aus nexus-tokens.css — Single Source of Truth */
.dot.work { background: var(--st-work); }
.dot.think { background: var(--st-think); }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
.dot.queue { background: var(--st-queue); }
/* Pulse-Animationen (Keyframes in tokens.css definiert) */
.dot.work.pulse { animation: pulse-work 1.8s infinite; box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
.dot.think.pulse { animation: pulse-think 1.8s infinite; box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
.dot.block.pulse { animation: pulse-block 1.4s infinite; box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
.dot-label {
font-size: 12.5px;
font-weight: 600;
color: var(--tx-2);
white-space: nowrap;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* StatusPill Einheitliche State-Pill für Statusanzeigen
*
* Extrahiert aus dem doppelten Muster in TaskBoardView (detail-state-pill)
* und BoardCard (child-state). Verwendet ausschließlich Token aus
* nexus-tokens.css als Farbquelle.
*
* Variants:
* backlog | progress | review | blocked | done
*
* Optional: size 'sm' (kompakt) oder 'md' (default).
*/
withDefaults(defineProps<{
/** Status-Variante */
variant: 'backlog' | 'progress' | 'review' | 'blocked' | 'done'
/** Grösse: 'sm' oder 'md' */
size?: 'sm' | 'md'
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-pill" :class="[variant, size]">
<slot />
</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
font-weight: 700;
letter-spacing: .03em;
white-space: nowrap;
border: 1px solid transparent;
font-family: 'Manrope', sans-serif;
}
/* Sizes */
.status-pill.md { padding: 3px 9px; font-size: 10.5px; }
.status-pill.sm { padding: 1px 6px; font-size: 8.5px; text-transform: uppercase; }
/* Variants — alle Farben aus nexus-tokens.css */
.status-pill.backlog { color: var(--pill-backlog); background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .25); }
.status-pill.progress { color: var(--pill-progress); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
.status-pill.review { color: var(--pill-review); background: rgba(249, 115, 22, .12); border-color: rgba(249, 115, 22, .25); }
.status-pill.blocked { color: var(--pill-blocked); background: rgba(244, 63, 94, .12); border-color: rgba(244, 63, 94, .25); }
.status-pill.done { color: var(--pill-done); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
</style>
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = { const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: { success: {
icon: CheckCircle, icon: CheckCircle,
color: '#22c55e', color: 'var(--st-work)',
bg: 'rgba(34, 197, 94, 0.10)', bg: 'rgba(61, 220, 151, 0.10)',
}, },
error: { error: {
icon: XCircle, icon: XCircle,
color: '#ef4444', color: 'var(--st-block)',
bg: 'rgba(239, 68, 68, 0.10)', bg: 'rgba(251, 113, 133, 0.10)',
}, },
info: { info: {
icon: Info, icon: Info,
color: '#3b82f6', color: 'var(--a-blue)',
bg: 'rgba(59, 130, 246, 0.10)', bg: 'rgba(79, 124, 255, 0.10)',
}, },
} }
</script> </script>
@@ -76,7 +76,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
0 8px 32px rgba(0, 0, 0, 0.4), 0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent); inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto; pointer-events: auto;
color: #e8eaf0; color: var(--tx);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.4; line-height: 1.4;
} }
@@ -106,7 +106,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
border: none; border: none;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: #6b7385; color: var(--tx-3);
cursor: pointer; cursor: pointer;
opacity: 0.5; opacity: 0.5;
transition: all 0.15s; transition: all 0.15s;
@@ -114,7 +114,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
.toast-close:hover { .toast-close:hover {
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: #e8eaf0; color: var(--tx);
} }
/* Transition animations */ /* Transition animations */
+17 -7
View File
@@ -4,25 +4,35 @@ import { type VariantProps, cva } from 'class-variance-authority'
export { default as Button } from './Button.vue' export { default as Button } from './Button.vue'
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
// shadcn-original
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
outline: secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
// Nexus V2 — Token-basierte Varianten
/** CTA / primäre Aktion — Gradient */
gradient: 'nexus-btn-gradient',
/** Icon-Only — quadratischer Button ohne Text */
icon: 'nexus-btn-icon',
/** Ghost thin — minimal hover */
ghostSubtle: 'nexus-btn-ghost-subtle',
/** Danger/Blocker — rote Akzent-Aktion */
danger: 'nexus-btn-danger',
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9',
iconSm: 'h-7 w-7 rounded-md',
pill: 'h-7 px-4 rounded-full text-xs',
}, },
}, },
defaultVariants: { defaultVariants: {
+15
View File
@@ -0,0 +1,15 @@
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'
export { default as StatusDot } from './StatusDot.vue'
export { default as PageHeading } from './PageHeading.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ActivityTimeline } from './ActivityTimeline.vue'
export { default as SectionHeader } from './SectionHeader.vue'
export { default as StatusPill } from './StatusPill.vue'
export { default as SkeletonLoader } from './SkeletonLoader.vue'
export { default as Input } from './Input.vue'
export { default as Textarea } from './Textarea.vue'
export { default as Select } from './Select.vue'
export { default as Dialog } from './Dialog.vue'
export { default as ToastContainer } from './ToastContainer.vue'
export { Button } from './button'
+17 -31
View File
@@ -25,6 +25,10 @@ export const icons: Record<string, string> = {
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`, arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
plus: `<path d="M12 5v14M5 12h14"/>`, plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`, command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`, chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`, chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`, dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
@@ -50,40 +54,22 @@ export interface NavGroupDef {
} }
/** /**
* Navigation structure matching NEXUS.nav from agents.js * Rail-Navigation EINE Quelle für alle Seiten (Dashboard + Rest).
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
* unten im Fußbereich (railFooterNav).
*/ */
export const navigation: NavGroupDef[] = [ export const railNav: NavItemDef[] = [
{ { icon: 'grid', label: 'Dashboard', route: '/dashboard' },
group: 'Operations',
items: [
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
{ icon: 'cpu', label: 'Agenten', route: '/agents' }, { icon: 'cpu', label: 'Agenten', route: '/agents' },
{ icon: 'list', label: 'Task Board', route: '/tasks' }, { icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
],
},
{
group: 'Knowledge',
items: [
{ icon: 'brain', label: 'Memory', route: '/memory' }, { icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' }, { icon: 'doc', label: 'Docs', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' }, { icon: 'calendar', label: 'Kalender', route: '/calendar' },
], { icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' }, { icon: 'alert', label: 'Incidents', route: '/incidents' },
], { icon: 'shield', label: 'Security', route: '/security' },
}, ]
export const railFooterNav: NavItemDef[] = [
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
] ]
+75
View File
@@ -0,0 +1,75 @@
/**
* useConfirm Wiederverwendbare Confirmation-Dialog-Logik
*
* Extrahiert aus dem Modal-Muster in TaskBoardView
* (showCreateModal / showChangesModal / showDetailPanel),
* das sich auch in anderen Views wiederholt.
*
* Bietet reaktiven open/close-State, Form-Fehler-Management
* und Escape-Key-Bindung.
*/
import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useConfirm() {
const isOpen = ref(false)
const error = ref('')
const success = ref('')
const submitting = ref(false)
function open() {
isOpen.value = true
error.value = ''
success.value = ''
}
function close() {
isOpen.value = false
}
function setError(msg: string) {
error.value = msg
success.value = ''
}
function setSuccess(msg: string) {
success.value = msg
error.value = ''
}
/** Escape-Taste schliesst den Dialog */
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) {
close()
}
}
/** Overlay-Klick ausserhalb schliesst */
function onOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
close()
}
}
/** Sperrt Body-Scroll, wenn Dialog offen */
watch(isOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
return {
isOpen,
error,
success,
submitting,
open,
close,
setError,
setSuccess,
onOverlayClick,
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* useFormatDate Einheitliche Datums-/Zeitformatierung für Nexus V2
*
* Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
* toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
* mit anderen Views dupliziert waren.
*
* Alle Ausgaben orientieren sich an de-DE Locale.
*/
/**
* Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
*/
export function formatDate(date?: string | null, withTime = false): string {
if (!date) return '—'
return new Date(date).toLocaleString('de-DE', withTime
? { dateStyle: 'medium', timeStyle: 'short' }
: { dateStyle: 'medium' })
}
/**
* ISO-Datumsstring in YYYY-MM-DD für <input type="date">.
*/
export function toDateInputValue(date?: string | null): string {
if (!date) return ''
return new Date(date).toISOString().slice(0, 10)
}
/**
* Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
* Optionaler Fallback, wenn date fehlt.
*/
export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
if (!date) return fallback
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
/**
* Minuten seit einem Datum (oder Infinity falls kein Datum).
*/
export function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
/**
* Stunden seit einem Datum (gerundet).
*/
export function hoursSince(dateStr: string): number {
const now = Date.now()
const then = new Date(dateStr).getTime()
return Math.round((now - then) / 3600000)
}
+19
View File
@@ -1,5 +1,24 @@
import type { AgentNodeData } from '../types/agentNode' import type { AgentNodeData } from '../types/agentNode'
export const TASK_AGENT_OPTIONS = [
{ id: '', label: 'Nicht zugewiesen' },
{ id: 'bao', label: '👤 Bao' },
{ id: 'iris', label: '🤖 Iris' },
{ id: 'product-owner', label: '📋 Product Owner' },
{ id: 'programmer', label: '🛠 Programmer' },
{ id: 'programmer-fast', label: '⚡ Programmer Fast' },
{ id: 'reviewer', label: '🔎 Reviewer' },
{ id: 'architekt', label: '🏛 Architekt' },
{ id: 'researcher', label: '🔬 Researcher' },
{ id: 'executor', label: '🚀 Executor' },
] as const
export const TASK_AGENT_LABELS: Record<string, string> = Object.fromEntries(
TASK_AGENT_OPTIONS
.filter(option => option.id)
.map(option => [option.id, option.label])
) as Record<string, string>
export const EXTRA_AGENT_POOL: AgentNodeData[] = [ export const EXTRA_AGENT_POOL: AgentNodeData[] = [
{ {
id: 'qa', id: 'qa',
+113 -27
View File
@@ -1,49 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* NexusLayout V2 Dashboard Shell * NexusLayout gemeinsame Shell für ALLE Seiten
* Flex row, 100vh, overflow hidden. *
* Sidebar (248px) + Main (flex:1, flex-column) * Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
* Mobile: Sidebar als Overlay mit Hamburger-Toggle * Content bekommt die volle restliche Fläche:
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
* - alle anderen: scrollbarer Container mit Seiten-Padding
*
* Die Live-Verbindung (SSE) gehört der Shell EINE Verbindung für die
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
*/ */
import { ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard' import { useAuthStore } from '../stores/auth'
import { useLiveSyncStore } from '../stores/liveSync'
import { useNotificationStore } from '../stores/notifications'
import GalaxyBackground from '../components/background/GalaxyBackground.vue' import GalaxyBackground from '../components/background/GalaxyBackground.vue'
import Sidebar from '../components/layout/Sidebar.vue' import Sidebar from '../components/layout/Sidebar.vue'
import Topbar from '../components/layout/Topbar.vue' import { svg } from '../composables/icons'
const dashboardStore = useDashboardStore() const route = useRoute()
const auth = useAuthStore()
const liveSync = useLiveSyncStore()
const notificationStore = useNotificationStore()
/* ── Mobile Sidebar State ───────────────────────── */ const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
/* ── Mobile Drawer ─────────────────────────────── */
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
function closeMobileMenu() { function closeMobileMenu() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
} }
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
function onVisibilityChange() {
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
liveSync.connect(liveUser.value)
}
}
function onOnline() {
liveSync.reconnectNow()
}
onMounted(() => {
liveSync.connect(liveUser.value)
notificationStore.startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('online', onOnline)
})
onUnmounted(() => {
liveSync.disconnect()
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('online', onOnline)
})
</script> </script>
<template> <template>
<div class="nexus-layout"> <div class="nexus-layout">
<GalaxyBackground /> <GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop --> <div class="rail-slot">
<div <Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
v-if="mobileMenuOpen" </div>
class="mobile-backdrop"
@click="closeMobileMenu" <!-- Mobile: Hamburger + Backdrop -->
></div> <button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
<main class="nexus-main"> <main class="nexus-main">
<Topbar <div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
</main> </main>
@@ -59,6 +89,15 @@ function closeMobileMenu() {
position: relative; position: relative;
} }
/* Platzhalter in der Flex-Reihe die Rail selbst liegt absolut darüber
und kann expandieren, ohne den Content zu verschieben. */
.rail-slot {
width: 68px;
flex: 0 0 68px;
position: relative;
z-index: 2;
}
.nexus-main { .nexus-main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -70,19 +109,62 @@ function closeMobileMenu() {
.nexus-content { .nexus-content {
flex: 1; flex: 1;
overflow: hidden;
min-height: 0; min-height: 0;
} }
.nexus-content.full-bleed {
overflow: hidden;
display: flex;
flex-direction: column;
}
.nexus-content.full-bleed > :deep(*) {
flex: 1;
min-height: 0;
}
.nexus-content.page-scroll {
overflow-y: auto;
padding: 24px 28px 64px;
}
.mobile-toggle,
.mobile-backdrop { .mobile-backdrop {
display: none; display: none;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.rail-slot {
width: 0;
flex: 0 0 0;
}
.nexus-main { .nexus-main {
width: 100%; width: 100%;
} }
.mobile-toggle {
display: grid;
place-items: center;
position: fixed;
top: 12px;
left: 12px;
z-index: 90;
width: 40px;
height: 40px;
border: 1px solid var(--line-2);
border-radius: 12px;
background: var(--glass);
backdrop-filter: blur(12px);
color: var(--tx-2);
cursor: pointer;
}
.mobile-toggle :deep(svg) {
width: 19px;
height: 19px;
}
.mobile-backdrop { .mobile-backdrop {
display: block; display: block;
position: fixed; position: fixed;
@@ -90,5 +172,9 @@ function closeMobileMenu() {
z-index: 99; z-index: 99;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
.nexus-content.page-scroll {
padding: 60px 16px 48px;
}
} }
</style> </style>

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