Spaces:
Sleeping
Sleeping
Nyk commited on
Commit ·
bbbc03f
0
Parent(s):
feat: initial open-source release
Browse filesOpenClaw Mission Control — agent orchestration dashboard.
Built with Next.js 16, React 19, TypeScript, SQLite, and Tailwind CSS.
MIT License.
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +89 -0
- .gitattributes +2 -0
- .github/pull_request_template.md +17 -0
- .github/workflows/quality-gate.yml +49 -0
- .gitignore +34 -0
- .npmrc +2 -0
- CONTRIBUTING.md +64 -0
- LICENSE +21 -0
- README.md +293 -0
- SECURITY.md +31 -0
- eslint.config.mjs +23 -0
- middleware.ts +100 -0
- next.config.js +46 -0
- ops/mc-provisioner-daemon.js +302 -0
- ops/templates/openclaw-gateway@.service +23 -0
- package-lock.json +0 -0
- package.json +66 -0
- playwright.config.ts +18 -0
- pnpm-lock.yaml +0 -0
- postcss.config.js +6 -0
- scripts/agent-heartbeat.sh +240 -0
- scripts/notification-daemon.sh +347 -0
- src/app/api/activities/route.ts +217 -0
- src/app/api/agents/[id]/heartbeat/route.ts +179 -0
- src/app/api/agents/[id]/memory/route.ts +213 -0
- src/app/api/agents/[id]/route.ts +206 -0
- src/app/api/agents/[id]/soul/route.ts +229 -0
- src/app/api/agents/[id]/wake/route.ts +63 -0
- src/app/api/agents/comms/route.ts +155 -0
- src/app/api/agents/message/route.ts +70 -0
- src/app/api/agents/route.ts +331 -0
- src/app/api/agents/sync/route.ts +42 -0
- src/app/api/alerts/route.ts +311 -0
- src/app/api/audit/route.ts +66 -0
- src/app/api/auth/access-requests/route.ts +147 -0
- src/app/api/auth/google/route.ts +103 -0
- src/app/api/auth/login/route.ts +48 -0
- src/app/api/auth/logout/route.ts +27 -0
- src/app/api/auth/me/route.ts +108 -0
- src/app/api/auth/users/route.ts +159 -0
- src/app/api/backup/route.ts +129 -0
- src/app/api/chat/conversations/route.ts +79 -0
- src/app/api/chat/messages/[id]/route.ts +72 -0
- src/app/api/chat/messages/route.ts +459 -0
- src/app/api/cleanup/route.ts +162 -0
- src/app/api/cron/route.ts +341 -0
- src/app/api/events/route.ts +65 -0
- src/app/api/export/route.ts +117 -0
- src/app/api/gateway-config/route.ts +132 -0
- src/app/api/gateways/health/route.ts +85 -0
.env.example
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# === Authentication ===
|
| 2 |
+
# Admin user seeded on first run (only if no users exist in DB)
|
| 3 |
+
AUTH_USER=admin
|
| 4 |
+
AUTH_PASS=change-me-on-first-login
|
| 5 |
+
|
| 6 |
+
# API key for headless/external access (x-api-key header)
|
| 7 |
+
API_KEY=generate-a-random-key
|
| 8 |
+
|
| 9 |
+
# Primary gateway defaults (used by /api/gateways seeding if DB is empty)
|
| 10 |
+
MC_DEFAULT_GATEWAY_NAME=primary
|
| 11 |
+
|
| 12 |
+
# Session cookie behavior
|
| 13 |
+
# - In production, cookies default to secure=true unless overridden.
|
| 14 |
+
# - SameSite defaults to "strict".
|
| 15 |
+
MC_COOKIE_SECURE=
|
| 16 |
+
MC_COOKIE_SAMESITE=strict
|
| 17 |
+
|
| 18 |
+
# Network access control (middleware)
|
| 19 |
+
# In production, access is blocked unless the host is explicitly allowed.
|
| 20 |
+
# Patterns supported:
|
| 21 |
+
# - Exact hosts: "app.example.com"
|
| 22 |
+
# - Subdomains: "*.example.com"
|
| 23 |
+
# - Prefix wildcard: "100.*" (useful for Tailscale IPs)
|
| 24 |
+
MC_ALLOW_ANY_HOST=
|
| 25 |
+
MC_ALLOWED_HOSTS=localhost,127.0.0.1
|
| 26 |
+
|
| 27 |
+
# Google OAuth client IDs for Google Sign-In approval workflow
|
| 28 |
+
# Create in Google Cloud Console (Web application) and set authorized origins/redirects
|
| 29 |
+
GOOGLE_CLIENT_ID=
|
| 30 |
+
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
| 31 |
+
|
| 32 |
+
# Legacy cookie auth (backward compat, can be removed once all clients use session auth)
|
| 33 |
+
AUTH_SECRET=random-secret-for-legacy-cookies
|
| 34 |
+
|
| 35 |
+
# Coordinator identity (used for coordinator chat status replies and comms UI)
|
| 36 |
+
MC_COORDINATOR_AGENT=coordinator
|
| 37 |
+
NEXT_PUBLIC_COORDINATOR_AGENT=coordinator
|
| 38 |
+
|
| 39 |
+
# === 1Password Integration (optional) ===
|
| 40 |
+
# Vault name for 1Password CLI pulls (used by Integrations panel)
|
| 41 |
+
OP_VAULT_NAME=default
|
| 42 |
+
|
| 43 |
+
# === OpenClaw Integration ===
|
| 44 |
+
# Path to .openclaw home directory (required for memory browser, gateway config, logs)
|
| 45 |
+
OPENCLAW_HOME=
|
| 46 |
+
# Optional: explicitly point at openclaw.json
|
| 47 |
+
# OPENCLAW_CONFIG_PATH=
|
| 48 |
+
|
| 49 |
+
# Gateway connection (used by frontend WebSocket)
|
| 50 |
+
OPENCLAW_GATEWAY_HOST=127.0.0.1
|
| 51 |
+
OPENCLAW_GATEWAY_PORT=18789
|
| 52 |
+
# Optional: token used by server-side gateway calls
|
| 53 |
+
OPENCLAW_GATEWAY_TOKEN=
|
| 54 |
+
|
| 55 |
+
# Frontend env vars (NEXT_PUBLIC_ prefix = available in browser)
|
| 56 |
+
NEXT_PUBLIC_GATEWAY_HOST=
|
| 57 |
+
NEXT_PUBLIC_GATEWAY_PORT=18789
|
| 58 |
+
NEXT_PUBLIC_GATEWAY_PROTOCOL=
|
| 59 |
+
NEXT_PUBLIC_GATEWAY_URL=
|
| 60 |
+
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
|
| 61 |
+
|
| 62 |
+
# === Data Paths (all optional, defaults to .data/ in project root) ===
|
| 63 |
+
# MISSION_CONTROL_DATA_DIR=.data
|
| 64 |
+
# MISSION_CONTROL_DB_PATH=.data/mission-control.db
|
| 65 |
+
# MISSION_CONTROL_TOKENS_PATH=.data/mission-control-tokens.json
|
| 66 |
+
|
| 67 |
+
# === OpenClaw Paths (derived from OPENCLAW_HOME if not set) ===
|
| 68 |
+
# OPENCLAW_LOG_DIR=/home/openclaw/.openclaw/logs
|
| 69 |
+
# OPENCLAW_MEMORY_DIR=/home/openclaw/.openclaw/memory
|
| 70 |
+
# OPENCLAW_SOUL_TEMPLATES_DIR=/home/openclaw/.openclaw/templates/souls
|
| 71 |
+
# OPENCLAW_BIN=openclaw
|
| 72 |
+
|
| 73 |
+
# === Super Admin / Provisioning (optional) ===
|
| 74 |
+
# Path to this repo root, needed if you use the super-admin provisioning helpers.
|
| 75 |
+
# MISSION_CONTROL_REPO_ROOT=/path/to/mission-control
|
| 76 |
+
# Template openclaw.json used to seed new tenant state (required for tenant bootstrap).
|
| 77 |
+
# MC_SUPER_TEMPLATE_OPENCLAW_JSON=/path/to/openclaw.json
|
| 78 |
+
# Base path used for provisioned linux user homes (default: /home)
|
| 79 |
+
# MC_TENANT_HOME_ROOT=/home
|
| 80 |
+
# Workspace directory name under each tenant user home (default: workspace)
|
| 81 |
+
# MC_TENANT_WORKSPACE_DIRNAME=workspace
|
| 82 |
+
|
| 83 |
+
# === Data Retention (days, 0 = keep forever) ===
|
| 84 |
+
# MC_RETAIN_ACTIVITIES_DAYS=90
|
| 85 |
+
# MC_RETAIN_AUDIT_DAYS=365
|
| 86 |
+
# MC_RETAIN_LOGS_DAYS=30
|
| 87 |
+
# MC_RETAIN_NOTIFICATIONS_DAYS=60
|
| 88 |
+
# MC_RETAIN_PIPELINE_RUNS_DAYS=90
|
| 89 |
+
# MC_RETAIN_TOKEN_USAGE_DAYS=90
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Summary
|
| 2 |
+
Describe what changed and why.
|
| 3 |
+
|
| 4 |
+
# Risk Level
|
| 5 |
+
Low / Medium / High (pick one)
|
| 6 |
+
|
| 7 |
+
# Tests
|
| 8 |
+
List commands run and results.
|
| 9 |
+
|
| 10 |
+
# Contribution Checklist
|
| 11 |
+
- [ ] Tests added/updated for behavior changes
|
| 12 |
+
- [ ] Lint/typecheck/build passing
|
| 13 |
+
- [ ] Security review done if auth/data/crypto touched
|
| 14 |
+
- [ ] DB migration tested if schema changed
|
| 15 |
+
|
| 16 |
+
# Notes
|
| 17 |
+
Anything reviewers should know.
|
.github/workflows/quality-gate.yml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Quality Gate
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
push:
|
| 6 |
+
branches: [main]
|
| 7 |
+
|
| 8 |
+
concurrency:
|
| 9 |
+
group: quality-gate-${{ github.ref }}
|
| 10 |
+
cancel-in-progress: true
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
quality-gate:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
steps:
|
| 16 |
+
- name: Checkout
|
| 17 |
+
uses: actions/checkout@v4
|
| 18 |
+
|
| 19 |
+
- name: Setup Node
|
| 20 |
+
uses: actions/setup-node@v4
|
| 21 |
+
with:
|
| 22 |
+
node-version: 20
|
| 23 |
+
cache: 'pnpm'
|
| 24 |
+
|
| 25 |
+
- name: Setup pnpm
|
| 26 |
+
uses: pnpm/action-setup@v4
|
| 27 |
+
with:
|
| 28 |
+
version: 10
|
| 29 |
+
|
| 30 |
+
- name: Install dependencies
|
| 31 |
+
run: pnpm install --frozen-lockfile
|
| 32 |
+
|
| 33 |
+
- name: Install Playwright browsers
|
| 34 |
+
run: pnpm exec playwright install --with-deps
|
| 35 |
+
|
| 36 |
+
- name: Lint
|
| 37 |
+
run: pnpm lint
|
| 38 |
+
|
| 39 |
+
- name: Typecheck
|
| 40 |
+
run: pnpm typecheck
|
| 41 |
+
|
| 42 |
+
- name: Unit tests
|
| 43 |
+
run: pnpm test
|
| 44 |
+
|
| 45 |
+
- name: E2E tests
|
| 46 |
+
run: pnpm test:e2e
|
| 47 |
+
|
| 48 |
+
- name: Build
|
| 49 |
+
run: pnpm build
|
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dependencies
|
| 2 |
+
node_modules/
|
| 3 |
+
|
| 4 |
+
# Next.js
|
| 5 |
+
.next/
|
| 6 |
+
out/
|
| 7 |
+
|
| 8 |
+
# Production
|
| 9 |
+
build/
|
| 10 |
+
dist/
|
| 11 |
+
|
| 12 |
+
# Misc
|
| 13 |
+
.DS_Store
|
| 14 |
+
*.pem
|
| 15 |
+
.env*.local
|
| 16 |
+
|
| 17 |
+
# Debug
|
| 18 |
+
npm-debug.log*
|
| 19 |
+
yarn-debug.log*
|
| 20 |
+
yarn-error.log*
|
| 21 |
+
|
| 22 |
+
# Vercel
|
| 23 |
+
.vercel
|
| 24 |
+
|
| 25 |
+
# TypeScript
|
| 26 |
+
*.tsbuildinfo
|
| 27 |
+
next-env.d.ts
|
| 28 |
+
.env
|
| 29 |
+
.data/
|
| 30 |
+
aegis/
|
| 31 |
+
|
| 32 |
+
# Claude Code context files
|
| 33 |
+
CLAUDE.md
|
| 34 |
+
**/CLAUDE.md
|
.npmrc
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
onlyBuiltDependenciesFile=
|
| 2 |
+
ignore-scripts=false
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to Mission Control
|
| 2 |
+
|
| 3 |
+
Thank you for your interest in contributing to Mission Control.
|
| 4 |
+
|
| 5 |
+
## Getting Started
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Clone the repo
|
| 9 |
+
git clone https://github.com/builderz-labs/mission-control.git
|
| 10 |
+
cd mission-control
|
| 11 |
+
|
| 12 |
+
# Install dependencies
|
| 13 |
+
pnpm install
|
| 14 |
+
|
| 15 |
+
# Copy environment config
|
| 16 |
+
cp .env.example .env
|
| 17 |
+
# Edit .env with your values
|
| 18 |
+
|
| 19 |
+
# Start development server
|
| 20 |
+
pnpm dev
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Development Workflow
|
| 24 |
+
|
| 25 |
+
1. Fork the repository and create a feature branch from `main`.
|
| 26 |
+
2. Make your changes — keep commits focused and descriptive.
|
| 27 |
+
3. Run the quality gate before submitting:
|
| 28 |
+
```bash
|
| 29 |
+
pnpm quality:gate # lint + typecheck + test + e2e + build
|
| 30 |
+
```
|
| 31 |
+
4. Open a pull request against `main` using the PR template.
|
| 32 |
+
|
| 33 |
+
## Code Style
|
| 34 |
+
|
| 35 |
+
- TypeScript strict mode — no `any` unless absolutely necessary.
|
| 36 |
+
- Tailwind CSS for styling — use semantic design tokens (`text-foreground`, `bg-card`, etc.).
|
| 37 |
+
- Server components by default; `'use client'` only when needed.
|
| 38 |
+
- API routes use `requireRole()` for auth and return JSON responses.
|
| 39 |
+
|
| 40 |
+
## Project Structure
|
| 41 |
+
|
| 42 |
+
- `src/app/api/` — Next.js API routes (REST endpoints)
|
| 43 |
+
- `src/components/panels/` — Feature panels rendered by the SPA shell
|
| 44 |
+
- `src/components/layout/` — Navigation, header, and layout components
|
| 45 |
+
- `src/lib/` — Shared utilities (auth, database, config, scheduler)
|
| 46 |
+
- `src/store/` — Zustand state management
|
| 47 |
+
|
| 48 |
+
## Testing
|
| 49 |
+
|
| 50 |
+
- **Unit tests**: Vitest — `pnpm test`
|
| 51 |
+
- **E2E tests**: Playwright — `pnpm test:e2e`
|
| 52 |
+
- **Type checking**: `pnpm typecheck`
|
| 53 |
+
- **Lint**: `pnpm lint`
|
| 54 |
+
|
| 55 |
+
## Reporting Bugs
|
| 56 |
+
|
| 57 |
+
Open an issue with:
|
| 58 |
+
- Steps to reproduce
|
| 59 |
+
- Expected vs actual behavior
|
| 60 |
+
- Browser/OS/Node version
|
| 61 |
+
|
| 62 |
+
## License
|
| 63 |
+
|
| 64 |
+
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Builderz Labs
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div align="center">
|
| 2 |
+
|
| 3 |
+
# Mission Control
|
| 4 |
+
|
| 5 |
+
**The open-source dashboard for AI agent orchestration.**
|
| 6 |
+
|
| 7 |
+
Manage agent fleets, track tasks, monitor costs, and orchestrate workflows — all from a single pane of glass.
|
| 8 |
+
|
| 9 |
+
[](LICENSE)
|
| 10 |
+
[](https://nextjs.org/)
|
| 11 |
+
[](https://react.dev/)
|
| 12 |
+
[](https://typescriptlang.org/)
|
| 13 |
+
[](https://sqlite.org/)
|
| 14 |
+
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## Why Mission Control?
|
| 20 |
+
|
| 21 |
+
Running AI agents at scale means juggling sessions, tasks, costs, and reliability across multiple models and channels. Mission Control gives you:
|
| 22 |
+
|
| 23 |
+
- **20+ panels** — Tasks, agents, logs, tokens, memory, cron, alerts, webhooks, pipelines, and more
|
| 24 |
+
- **Real-time everything** — WebSocket + SSE push updates, smart polling that pauses when you're away
|
| 25 |
+
- **Zero external dependencies** — SQLite database, single `pnpm start` to run, no Redis/Postgres/Docker required
|
| 26 |
+
- **Role-based access** — Viewer, operator, and admin roles with session + API key auth
|
| 27 |
+
- **Quality gates** — Built-in review system that blocks task completion without sign-off
|
| 28 |
+
- **Multi-gateway** — Connect to multiple OpenClaw gateways simultaneously
|
| 29 |
+
|
| 30 |
+
## Quick Start
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
git clone https://github.com/builderz-labs/mission-control.git
|
| 34 |
+
cd mission-control
|
| 35 |
+
pnpm install
|
| 36 |
+
cp .env.example .env # edit with your values
|
| 37 |
+
pnpm dev # http://localhost:3000
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
Initial login is seeded from `AUTH_USER` / `AUTH_PASS` on first run.
|
| 41 |
+
|
| 42 |
+
## Features
|
| 43 |
+
|
| 44 |
+
### Agent Management
|
| 45 |
+
Monitor agent status, spawn new sessions, view heartbeats, and manage the full agent lifecycle from registration to retirement.
|
| 46 |
+
|
| 47 |
+
### Task Board
|
| 48 |
+
Kanban board with six columns (inbox → backlog → todo → in-progress → review → done), drag-and-drop, priority levels, assignments, and threaded comments.
|
| 49 |
+
|
| 50 |
+
### Real-time Monitoring
|
| 51 |
+
Live activity feed, session inspector, and log viewer with filtering. WebSocket connection to OpenClaw gateway for instant event delivery.
|
| 52 |
+
|
| 53 |
+
### Cost Tracking
|
| 54 |
+
Token usage dashboard with per-model breakdowns, trend charts, and cost analysis powered by Recharts.
|
| 55 |
+
|
| 56 |
+
### Background Automation
|
| 57 |
+
Scheduled tasks for database backups, stale record cleanup, and agent heartbeat monitoring. Configurable via UI or API.
|
| 58 |
+
|
| 59 |
+
### Integrations
|
| 60 |
+
Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
|
| 61 |
+
|
| 62 |
+
## Architecture
|
| 63 |
+
|
| 64 |
+
```
|
| 65 |
+
mission-control/
|
| 66 |
+
├── middleware.ts # Auth gate + network access control
|
| 67 |
+
├── src/
|
| 68 |
+
│ ├── app/
|
| 69 |
+
│ │ ├── page.tsx # SPA shell — routes all panels
|
| 70 |
+
│ │ ├── login/page.tsx # Login page
|
| 71 |
+
│ │ └── api/ # 25+ REST API routes
|
| 72 |
+
│ ├── components/
|
| 73 |
+
│ │ ├── layout/ # NavRail, HeaderBar, LiveFeed
|
| 74 |
+
│ │ ├── dashboard/ # Overview dashboard
|
| 75 |
+
│ │ ├── panels/ # 23 feature panels
|
| 76 |
+
│ │ └── chat/ # Agent chat UI
|
| 77 |
+
│ ├── lib/
|
| 78 |
+
│ │ ├── auth.ts # Session + API key auth, RBAC
|
| 79 |
+
│ │ ├── db.ts # SQLite (better-sqlite3, WAL mode)
|
| 80 |
+
│ │ ├── migrations.ts # 11 schema migrations
|
| 81 |
+
│ │ ├── scheduler.ts # Background task scheduler
|
| 82 |
+
│ │ ├── webhooks.ts # Outbound webhook delivery
|
| 83 |
+
│ │ └── websocket.ts # Gateway WebSocket client
|
| 84 |
+
│ └── store/index.ts # Zustand state management
|
| 85 |
+
└── .data/ # Runtime data (SQLite DB, token logs)
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
## Tech Stack
|
| 89 |
+
|
| 90 |
+
| Layer | Technology |
|
| 91 |
+
|-------|-----------|
|
| 92 |
+
| Framework | Next.js 16 (App Router) |
|
| 93 |
+
| UI | React 19, Tailwind CSS 3.4 |
|
| 94 |
+
| Language | TypeScript 5.7 |
|
| 95 |
+
| Database | SQLite via better-sqlite3 (WAL mode) |
|
| 96 |
+
| State | Zustand 5 |
|
| 97 |
+
| Charts | Recharts 3 |
|
| 98 |
+
| Real-time | WebSocket + Server-Sent Events |
|
| 99 |
+
| Auth | scrypt hashing, session tokens, RBAC |
|
| 100 |
+
| Testing | Vitest + Playwright |
|
| 101 |
+
|
| 102 |
+
## Authentication
|
| 103 |
+
|
| 104 |
+
Three auth methods, three roles:
|
| 105 |
+
|
| 106 |
+
| Method | Details |
|
| 107 |
+
|--------|---------|
|
| 108 |
+
| Session cookie | `POST /api/auth/login` sets `mc-session` (7-day expiry) |
|
| 109 |
+
| API key | `x-api-key` header matches `API_KEY` env var |
|
| 110 |
+
| Google Sign-In | OAuth with admin approval workflow |
|
| 111 |
+
|
| 112 |
+
| Role | Access |
|
| 113 |
+
|------|--------|
|
| 114 |
+
| `viewer` | Read-only |
|
| 115 |
+
| `operator` | Read + write (tasks, agents, chat) |
|
| 116 |
+
| `admin` | Full access (users, settings, system ops) |
|
| 117 |
+
|
| 118 |
+
## API Reference
|
| 119 |
+
|
| 120 |
+
All endpoints require authentication unless noted. Full reference below.
|
| 121 |
+
|
| 122 |
+
<details>
|
| 123 |
+
<summary><strong>Auth</strong></summary>
|
| 124 |
+
|
| 125 |
+
| Method | Path | Description |
|
| 126 |
+
|--------|------|-------------|
|
| 127 |
+
| `POST` | `/api/auth/login` | Login with username/password |
|
| 128 |
+
| `POST` | `/api/auth/google` | Google Sign-In |
|
| 129 |
+
| `POST` | `/api/auth/logout` | Destroy session |
|
| 130 |
+
| `GET` | `/api/auth/me` | Current user info |
|
| 131 |
+
| `GET` | `/api/auth/access-requests` | List pending access requests (admin) |
|
| 132 |
+
| `POST` | `/api/auth/access-requests` | Approve/reject requests (admin) |
|
| 133 |
+
|
| 134 |
+
</details>
|
| 135 |
+
|
| 136 |
+
<details>
|
| 137 |
+
<summary><strong>Core Resources</strong></summary>
|
| 138 |
+
|
| 139 |
+
| Method | Path | Role | Description |
|
| 140 |
+
|--------|------|------|-------------|
|
| 141 |
+
| `GET` | `/api/agents` | viewer | List agents with task stats |
|
| 142 |
+
| `POST` | `/api/agents` | operator | Register/update agent |
|
| 143 |
+
| `GET` | `/api/tasks` | viewer | List tasks (filter: `?status=`, `?assigned_to=`, `?priority=`) |
|
| 144 |
+
| `POST` | `/api/tasks` | operator | Create task |
|
| 145 |
+
| `GET` | `/api/tasks/[id]` | viewer | Task details |
|
| 146 |
+
| `PUT` | `/api/tasks/[id]` | operator | Update task |
|
| 147 |
+
| `DELETE` | `/api/tasks/[id]` | admin | Delete task |
|
| 148 |
+
| `GET` | `/api/tasks/[id]/comments` | viewer | Task comments |
|
| 149 |
+
| `POST` | `/api/tasks/[id]/comments` | operator | Add comment |
|
| 150 |
+
| `POST` | `/api/tasks/[id]/broadcast` | operator | Broadcast task to agents |
|
| 151 |
+
|
| 152 |
+
</details>
|
| 153 |
+
|
| 154 |
+
<details>
|
| 155 |
+
<summary><strong>Monitoring</strong></summary>
|
| 156 |
+
|
| 157 |
+
| Method | Path | Role | Description |
|
| 158 |
+
|--------|------|------|-------------|
|
| 159 |
+
| `GET` | `/api/status` | viewer | System status (uptime, memory, disk) |
|
| 160 |
+
| `GET` | `/api/activities` | viewer | Activity feed |
|
| 161 |
+
| `GET` | `/api/notifications` | viewer | Notifications for recipient |
|
| 162 |
+
| `GET` | `/api/sessions` | viewer | Active gateway sessions |
|
| 163 |
+
| `GET` | `/api/tokens` | viewer | Token usage and cost data |
|
| 164 |
+
| `GET` | `/api/standup` | viewer | Standup report history |
|
| 165 |
+
| `POST` | `/api/standup` | operator | Generate standup |
|
| 166 |
+
|
| 167 |
+
</details>
|
| 168 |
+
|
| 169 |
+
<details>
|
| 170 |
+
<summary><strong>Configuration</strong></summary>
|
| 171 |
+
|
| 172 |
+
| Method | Path | Role | Description |
|
| 173 |
+
|--------|------|------|-------------|
|
| 174 |
+
| `GET/PUT` | `/api/settings` | admin | App settings |
|
| 175 |
+
| `GET/PUT` | `/api/gateway-config` | admin | OpenClaw gateway config |
|
| 176 |
+
| `GET/POST` | `/api/cron` | admin | Cron management |
|
| 177 |
+
|
| 178 |
+
</details>
|
| 179 |
+
|
| 180 |
+
<details>
|
| 181 |
+
<summary><strong>Operations</strong></summary>
|
| 182 |
+
|
| 183 |
+
| Method | Path | Role | Description |
|
| 184 |
+
|--------|------|------|-------------|
|
| 185 |
+
| `GET/POST` | `/api/scheduler` | admin | Background task scheduler |
|
| 186 |
+
| `GET` | `/api/audit` | admin | Audit log |
|
| 187 |
+
| `GET` | `/api/logs` | viewer | Agent log browser |
|
| 188 |
+
| `GET` | `/api/memory` | viewer | Memory file browser/search |
|
| 189 |
+
| `GET` | `/api/search` | viewer | Global search |
|
| 190 |
+
| `GET` | `/api/export` | admin | CSV export |
|
| 191 |
+
|
| 192 |
+
</details>
|
| 193 |
+
|
| 194 |
+
<details>
|
| 195 |
+
<summary><strong>Integrations</strong></summary>
|
| 196 |
+
|
| 197 |
+
| Method | Path | Role | Description |
|
| 198 |
+
|--------|------|------|-------------|
|
| 199 |
+
| `GET/POST/PUT/DELETE` | `/api/webhooks` | admin | Webhook CRUD |
|
| 200 |
+
| `POST` | `/api/webhooks/test` | admin | Test delivery |
|
| 201 |
+
| `GET` | `/api/webhooks/deliveries` | admin | Delivery history |
|
| 202 |
+
| `GET/POST/PUT/DELETE` | `/api/alerts` | admin | Alert rules |
|
| 203 |
+
| `GET/POST/PUT/DELETE` | `/api/gateways` | admin | Gateway connections |
|
| 204 |
+
| `GET/PUT/DELETE/POST` | `/api/integrations` | admin | Integration management |
|
| 205 |
+
|
| 206 |
+
</details>
|
| 207 |
+
|
| 208 |
+
<details>
|
| 209 |
+
<summary><strong>Chat & Real-time</strong></summary>
|
| 210 |
+
|
| 211 |
+
| Method | Path | Description |
|
| 212 |
+
|--------|------|-------------|
|
| 213 |
+
| `GET` | `/api/events` | SSE stream of DB changes |
|
| 214 |
+
| `GET/POST` | `/api/chat/conversations` | Conversation CRUD |
|
| 215 |
+
| `GET/POST` | `/api/chat/messages` | Message CRUD |
|
| 216 |
+
|
| 217 |
+
</details>
|
| 218 |
+
|
| 219 |
+
<details>
|
| 220 |
+
<summary><strong>Agent Lifecycle</strong></summary>
|
| 221 |
+
|
| 222 |
+
| Method | Path | Role | Description |
|
| 223 |
+
|--------|------|------|-------------|
|
| 224 |
+
| `POST` | `/api/spawn` | operator | Spawn agent session |
|
| 225 |
+
| `POST` | `/api/agents/[id]/heartbeat` | operator | Agent heartbeat |
|
| 226 |
+
| `POST` | `/api/agents/[id]/wake` | operator | Wake sleeping agent |
|
| 227 |
+
| `POST` | `/api/quality-review` | operator | Submit quality review |
|
| 228 |
+
|
| 229 |
+
</details>
|
| 230 |
+
|
| 231 |
+
<details>
|
| 232 |
+
<summary><strong>Pipelines</strong></summary>
|
| 233 |
+
|
| 234 |
+
| Method | Path | Role | Description |
|
| 235 |
+
|--------|------|------|-------------|
|
| 236 |
+
| `GET` | `/api/pipelines` | viewer | List pipeline runs |
|
| 237 |
+
| `POST` | `/api/pipelines/run` | operator | Start pipeline |
|
| 238 |
+
| `GET/POST` | `/api/workflows` | viewer/admin | Workflow templates |
|
| 239 |
+
|
| 240 |
+
</details>
|
| 241 |
+
|
| 242 |
+
## Environment Variables
|
| 243 |
+
|
| 244 |
+
See [`.env.example`](.env.example) for the complete list. Key variables:
|
| 245 |
+
|
| 246 |
+
| Variable | Required | Description |
|
| 247 |
+
|----------|----------|-------------|
|
| 248 |
+
| `AUTH_USER` | No | Initial admin username (default: `admin`) |
|
| 249 |
+
| `AUTH_PASS` | No | Initial admin password |
|
| 250 |
+
| `API_KEY` | No | API key for headless access |
|
| 251 |
+
| `OPENCLAW_HOME` | Yes* | Path to `.openclaw` directory |
|
| 252 |
+
| `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
|
| 253 |
+
| `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
|
| 254 |
+
| `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
|
| 255 |
+
|
| 256 |
+
*Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`.
|
| 257 |
+
|
| 258 |
+
## Deployment
|
| 259 |
+
|
| 260 |
+
```bash
|
| 261 |
+
# Build
|
| 262 |
+
pnpm install --frozen-lockfile
|
| 263 |
+
pnpm build
|
| 264 |
+
|
| 265 |
+
# Run
|
| 266 |
+
OPENCLAW_HOME=/path/to/.openclaw pnpm start
|
| 267 |
+
```
|
| 268 |
+
|
| 269 |
+
Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access.
|
| 270 |
+
|
| 271 |
+
## Development
|
| 272 |
+
|
| 273 |
+
```bash
|
| 274 |
+
pnpm dev # Dev server
|
| 275 |
+
pnpm build # Production build
|
| 276 |
+
pnpm typecheck # TypeScript check
|
| 277 |
+
pnpm lint # ESLint
|
| 278 |
+
pnpm test # Vitest unit tests
|
| 279 |
+
pnpm test:e2e # Playwright E2E
|
| 280 |
+
pnpm quality:gate # All checks
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
## Contributing
|
| 284 |
+
|
| 285 |
+
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions and guidelines.
|
| 286 |
+
|
| 287 |
+
## Security
|
| 288 |
+
|
| 289 |
+
To report a vulnerability, see [SECURITY.md](SECURITY.md).
|
| 290 |
+
|
| 291 |
+
## License
|
| 292 |
+
|
| 293 |
+
[MIT](LICENSE) © 2026 [Builderz Labs](https://github.com/builderz-labs)
|
SECURITY.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Reporting a Vulnerability
|
| 4 |
+
|
| 5 |
+
If you discover a security vulnerability in Mission Control, please report it responsibly.
|
| 6 |
+
|
| 7 |
+
**Do not open a public issue.** Instead, email security@builderz.dev with:
|
| 8 |
+
|
| 9 |
+
- Description of the vulnerability
|
| 10 |
+
- Steps to reproduce
|
| 11 |
+
- Potential impact
|
| 12 |
+
- Suggested fix (if any)
|
| 13 |
+
|
| 14 |
+
We will acknowledge receipt within 48 hours and aim to provide a fix or mitigation within 7 days for critical issues.
|
| 15 |
+
|
| 16 |
+
## Supported Versions
|
| 17 |
+
|
| 18 |
+
| Version | Supported |
|
| 19 |
+
|---------|-----------|
|
| 20 |
+
| latest `main` | Yes |
|
| 21 |
+
| older releases | Best effort |
|
| 22 |
+
|
| 23 |
+
## Security Considerations
|
| 24 |
+
|
| 25 |
+
Mission Control handles authentication credentials and API keys. When deploying:
|
| 26 |
+
|
| 27 |
+
- Always set strong values for `AUTH_PASS` and `API_KEY`.
|
| 28 |
+
- Use `MC_ALLOWED_HOSTS` to restrict network access in production.
|
| 29 |
+
- Keep `.env` files out of version control (already in `.gitignore`).
|
| 30 |
+
- Enable `MC_COOKIE_SECURE=true` when serving over HTTPS.
|
| 31 |
+
- Review the [Environment Variables](README.md#environment-variables) section for all security-relevant configuration.
|
eslint.config.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import next from 'eslint-config-next'
|
| 2 |
+
|
| 3 |
+
const config = [
|
| 4 |
+
...next,
|
| 5 |
+
{
|
| 6 |
+
ignores: [
|
| 7 |
+
'.data/**',
|
| 8 |
+
'ops/**',
|
| 9 |
+
],
|
| 10 |
+
},
|
| 11 |
+
// The React 19/ESLint ecosystem is still settling. These rules are valuable,
|
| 12 |
+
// but they currently trigger a lot of false positives in this codebase.
|
| 13 |
+
// Keep them off until we do a dedicated refactor pass.
|
| 14 |
+
{
|
| 15 |
+
rules: {
|
| 16 |
+
'react-hooks/set-state-in-effect': 'off',
|
| 17 |
+
'react-hooks/purity': 'off',
|
| 18 |
+
'react-hooks/immutability': 'off',
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
export default config
|
middleware.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server'
|
| 2 |
+
import type { NextRequest } from 'next/server'
|
| 3 |
+
|
| 4 |
+
function envFlag(name: string): boolean {
|
| 5 |
+
const raw = process.env[name]
|
| 6 |
+
if (raw === undefined) return false
|
| 7 |
+
const v = String(raw).trim().toLowerCase()
|
| 8 |
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function getRequestHostname(request: NextRequest): string {
|
| 12 |
+
const raw = request.headers.get('x-forwarded-host') || request.headers.get('host') || ''
|
| 13 |
+
// If multiple hosts are present, take the first (proxy chain).
|
| 14 |
+
const first = raw.split(',')[0] || ''
|
| 15 |
+
return first.trim().split(':')[0] || ''
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function hostMatches(pattern: string, hostname: string): boolean {
|
| 19 |
+
const p = pattern.trim().toLowerCase()
|
| 20 |
+
const h = hostname.trim().toLowerCase()
|
| 21 |
+
if (!p || !h) return false
|
| 22 |
+
|
| 23 |
+
// "*.example.com" matches "a.example.com" (but not bare "example.com")
|
| 24 |
+
if (p.startsWith('*.')) {
|
| 25 |
+
const suffix = p.slice(2)
|
| 26 |
+
return h.endsWith(`.${suffix}`)
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
// "100.*" matches "100.64.0.1"
|
| 30 |
+
if (p.endsWith('.*')) {
|
| 31 |
+
const prefix = p.slice(0, -1)
|
| 32 |
+
return h.startsWith(prefix)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return h === p
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export function middleware(request: NextRequest) {
|
| 39 |
+
// Network access control.
|
| 40 |
+
// In production: default-deny unless explicitly allowed.
|
| 41 |
+
// In dev/test: allow all hosts unless overridden.
|
| 42 |
+
const hostName = getRequestHostname(request)
|
| 43 |
+
const allowAnyHost = envFlag('MC_ALLOW_ANY_HOST') || process.env.NODE_ENV !== 'production'
|
| 44 |
+
const allowedPatterns = String(process.env.MC_ALLOWED_HOSTS || '')
|
| 45 |
+
.split(',')
|
| 46 |
+
.map((s) => s.trim())
|
| 47 |
+
.filter(Boolean)
|
| 48 |
+
|
| 49 |
+
const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName))
|
| 50 |
+
|
| 51 |
+
if (!isAllowedHost) {
|
| 52 |
+
return new NextResponse('Forbidden', { status: 403 })
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const { pathname } = request.nextUrl
|
| 56 |
+
|
| 57 |
+
// Allow login page and auth API without session
|
| 58 |
+
if (pathname === '/login' || pathname.startsWith('/api/auth/')) {
|
| 59 |
+
return NextResponse.next()
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Check for session cookie
|
| 63 |
+
const sessionToken = request.cookies.get('mc-session')?.value
|
| 64 |
+
|
| 65 |
+
// API routes: accept session cookie OR API key
|
| 66 |
+
if (pathname.startsWith('/api/')) {
|
| 67 |
+
const apiKey = request.headers.get('x-api-key')
|
| 68 |
+
if (sessionToken || (apiKey && apiKey === process.env.API_KEY)) {
|
| 69 |
+
return NextResponse.next()
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Backward compat: accept legacy cookie during migration
|
| 73 |
+
const legacyCookie = request.cookies.get('mission-control-auth')
|
| 74 |
+
if (legacyCookie?.value === process.env.AUTH_SECRET) {
|
| 75 |
+
return NextResponse.next()
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// Page routes: redirect to login if no session
|
| 82 |
+
if (sessionToken) {
|
| 83 |
+
return NextResponse.next()
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Backward compat: accept legacy cookie
|
| 87 |
+
const legacyCookie = request.cookies.get('mission-control-auth')
|
| 88 |
+
if (legacyCookie?.value === process.env.AUTH_SECRET) {
|
| 89 |
+
return NextResponse.next()
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Redirect to login
|
| 93 |
+
const loginUrl = request.nextUrl.clone()
|
| 94 |
+
loginUrl.pathname = '/login'
|
| 95 |
+
return NextResponse.redirect(loginUrl)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export const config = {
|
| 99 |
+
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
|
| 100 |
+
}
|
next.config.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
turbopack: {},
|
| 4 |
+
|
| 5 |
+
// Security headers
|
| 6 |
+
async headers() {
|
| 7 |
+
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
| 8 |
+
|
| 9 |
+
const csp = [
|
| 10 |
+
`default-src 'self'`,
|
| 11 |
+
`script-src 'self' 'unsafe-inline' 'unsafe-eval'${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 12 |
+
`style-src 'self' 'unsafe-inline'`,
|
| 13 |
+
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:*`,
|
| 14 |
+
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
|
| 15 |
+
`font-src 'self' data:`,
|
| 16 |
+
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
|
| 17 |
+
].join('; ')
|
| 18 |
+
|
| 19 |
+
return [
|
| 20 |
+
{
|
| 21 |
+
source: '/:path*',
|
| 22 |
+
headers: [
|
| 23 |
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
| 24 |
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
| 25 |
+
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
| 26 |
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
| 27 |
+
{ key: 'Content-Security-Policy', value: csp },
|
| 28 |
+
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
| 29 |
+
],
|
| 30 |
+
},
|
| 31 |
+
];
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
webpack: (config) => {
|
| 35 |
+
config.resolve.fallback = {
|
| 36 |
+
...config.resolve.fallback,
|
| 37 |
+
net: false,
|
| 38 |
+
os: false,
|
| 39 |
+
fs: false,
|
| 40 |
+
path: false,
|
| 41 |
+
};
|
| 42 |
+
return config;
|
| 43 |
+
},
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
module.exports = nextConfig;
|
ops/mc-provisioner-daemon.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
const fs = require('fs')
|
| 4 |
+
const net = require('net')
|
| 5 |
+
const { spawn } = require('child_process')
|
| 6 |
+
const path = require('path')
|
| 7 |
+
|
| 8 |
+
const SOCKET_PATH = process.env.MC_PROVISIONER_SOCKET || '/run/mc-provisioner.sock'
|
| 9 |
+
const TOKEN = String(process.env.MC_PROVISIONER_TOKEN || '')
|
| 10 |
+
const SOCKET_GROUP = process.env.MC_PROVISIONER_GROUP || 'openclaw'
|
| 11 |
+
const REPO_ROOT = process.env.MISSION_CONTROL_REPO_ROOT || path.resolve(__dirname, '..')
|
| 12 |
+
const DATA_DIR = process.env.MISSION_CONTROL_DATA_DIR || path.join(REPO_ROOT, '.data')
|
| 13 |
+
const TENANT_HOME_ROOT = String(process.env.MC_TENANT_HOME_ROOT || '/home').trim() || '/home'
|
| 14 |
+
const TENANT_WORKSPACE_DIRNAME = String(process.env.MC_TENANT_WORKSPACE_DIRNAME || 'workspace').trim() || 'workspace'
|
| 15 |
+
const TEMPLATE_OPENCLAW_JSON = process.env.MC_SUPER_TEMPLATE_OPENCLAW_JSON || (process.env.OPENCLAW_HOME ? path.join(process.env.OPENCLAW_HOME, 'openclaw.json') : '')
|
| 16 |
+
const GATEWAY_SYSTEMD_TEMPLATE = path.join(REPO_ROOT, 'ops', 'templates', 'openclaw-gateway@.service')
|
| 17 |
+
|
| 18 |
+
if (!TOKEN) {
|
| 19 |
+
console.error('MC_PROVISIONER_TOKEN is required')
|
| 20 |
+
process.exit(1)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function escapeRegExp(str) {
|
| 24 |
+
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function isSafeUser(user) {
|
| 28 |
+
return /^[a-z_][a-z0-9_-]{1,30}$/.test(user)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function pathJoinPosix(...parts) {
|
| 32 |
+
// Use posix paths for allowlisting because provisioner executes linux commands.
|
| 33 |
+
const cleaned = parts.map((p) => String(p || '').replace(/\/+$/g, ''))
|
| 34 |
+
return path.posix.join(...cleaned)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function isSafeHomePath(path, user, suffix) {
|
| 38 |
+
return path === pathJoinPosix(TENANT_HOME_ROOT, user, suffix)
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function validateCommand(command, args) {
|
| 42 |
+
const cmd = String(command || '').split('/').pop()
|
| 43 |
+
if (!command || !Array.isArray(args)) return 'Invalid command payload'
|
| 44 |
+
|
| 45 |
+
if (cmd === 'useradd') {
|
| 46 |
+
if (args.length !== 4) return 'useradd argument mismatch'
|
| 47 |
+
const [a, b, shell, user] = args
|
| 48 |
+
if (a !== '-m' || b !== '-s' || shell !== '/bin/bash') return 'useradd args not allowed'
|
| 49 |
+
if (!isSafeUser(user)) return 'Invalid username'
|
| 50 |
+
return null
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
if (cmd === 'install') {
|
| 54 |
+
if (args.length !== 8) return 'install argument mismatch'
|
| 55 |
+
const [d, mFlag, mode, oFlag, userA, gFlag, userB, target] = args
|
| 56 |
+
if (d !== '-d' || mFlag !== '-m' || oFlag !== '-o' || gFlag !== '-g') return 'install args not allowed'
|
| 57 |
+
if (!['0750', '0700'].includes(mode)) return 'install mode not allowed'
|
| 58 |
+
const isRootOwned = userA === 'root' && userB === 'root'
|
| 59 |
+
const isTenantOwned = isSafeUser(userA) && isSafeUser(userB) && userA === userB
|
| 60 |
+
if (!isRootOwned && !isTenantOwned) return 'install ownership not allowed'
|
| 61 |
+
const openclawPath = pathJoinPosix(TENANT_HOME_ROOT, userA, '.openclaw')
|
| 62 |
+
const workspacePath = pathJoinPosix(TENANT_HOME_ROOT, userA, TENANT_WORKSPACE_DIRNAME)
|
| 63 |
+
if (isRootOwned && target === '/etc/openclaw-tenants') return null
|
| 64 |
+
if (![openclawPath, workspacePath].includes(target)) return 'install path not allowed'
|
| 65 |
+
return null
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if (cmd === 'cp') {
|
| 69 |
+
if (args.length !== 3) return 'cp argument mismatch'
|
| 70 |
+
const [flag, source, target] = args
|
| 71 |
+
if (!['-n', '-f'].includes(flag)) return 'cp flag not allowed'
|
| 72 |
+
if (TEMPLATE_OPENCLAW_JSON && source === TEMPLATE_OPENCLAW_JSON) {
|
| 73 |
+
if (flag !== '-n') return 'openclaw config copy must use -n'
|
| 74 |
+
const homeRootRe = escapeRegExp(pathJoinPosix(TENANT_HOME_ROOT))
|
| 75 |
+
const match = new RegExp(`^${homeRootRe}\\/([a-z_][a-z0-9_-]{1,30})\\/\\.openclaw\\/openclaw\\.json$`).exec(target)
|
| 76 |
+
if (!match) return 'cp target not allowed'
|
| 77 |
+
return null
|
| 78 |
+
}
|
| 79 |
+
if (source === GATEWAY_SYSTEMD_TEMPLATE) {
|
| 80 |
+
if (flag !== '-n') return 'template copy must use -n'
|
| 81 |
+
if (target !== '/etc/systemd/system/openclaw-gateway@.service') return 'gateway template target not allowed'
|
| 82 |
+
return null
|
| 83 |
+
}
|
| 84 |
+
const provisionerEnvRe = new RegExp(`^${escapeRegExp(path.join(DATA_DIR, 'provisioner'))}\\/([a-z0-9-]{3,32})\\/openclaw-gateway\\.env$`)
|
| 85 |
+
if (provisionerEnvRe.test(source)) {
|
| 86 |
+
if (flag !== '-f') return 'tenant env copy must use -f'
|
| 87 |
+
if (!/^\/etc\/openclaw-tenants\/[a-z_][a-z0-9_-]{1,30}\.env$/.test(target)) return 'tenant env target not allowed'
|
| 88 |
+
return null
|
| 89 |
+
}
|
| 90 |
+
return 'cp source not allowed'
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (cmd === 'chown') {
|
| 94 |
+
if (args.length !== 3) return 'chown argument mismatch'
|
| 95 |
+
const [rFlag, owner, target] = args
|
| 96 |
+
if (rFlag !== '-R') return 'chown must use -R'
|
| 97 |
+
const [userA, userB] = owner.split(':')
|
| 98 |
+
if (!isSafeUser(userA) || userA !== userB) return 'chown owner not allowed'
|
| 99 |
+
if (target !== pathJoinPosix(TENANT_HOME_ROOT, userA)) return 'chown target not allowed'
|
| 100 |
+
return null
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if (cmd === 'rm') {
|
| 104 |
+
if (args.length !== 2) return 'rm argument mismatch'
|
| 105 |
+
const [flag, target] = args
|
| 106 |
+
|
| 107 |
+
if (flag === '-f') {
|
| 108 |
+
if (!/^\/etc\/openclaw-tenants\/[a-z_][a-z0-9_-]{1,30}\.env$/.test(target)) {
|
| 109 |
+
return 'rm -f target not allowed'
|
| 110 |
+
}
|
| 111 |
+
return null
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
if (flag === '-rf') {
|
| 115 |
+
const homeRootRe = escapeRegExp(pathJoinPosix(TENANT_HOME_ROOT))
|
| 116 |
+
const ws = escapeRegExp(TENANT_WORKSPACE_DIRNAME)
|
| 117 |
+
const match = new RegExp(`^${homeRootRe}\\/([a-z_][a-z0-9_-]{1,30})\\/(\\.openclaw|${ws})$`).exec(target)
|
| 118 |
+
if (!match) return 'rm -rf target not allowed'
|
| 119 |
+
return null
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
return 'rm flag not allowed'
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (cmd === 'userdel') {
|
| 126 |
+
if (args.length !== 2) return 'userdel argument mismatch'
|
| 127 |
+
if (args[0] !== '-r') return 'userdel must use -r'
|
| 128 |
+
if (!isSafeUser(args[1])) return 'Invalid username'
|
| 129 |
+
return null
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (cmd === 'true') {
|
| 133 |
+
if (args.length !== 0) return 'true takes no args'
|
| 134 |
+
return null
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (cmd === 'systemctl') {
|
| 138 |
+
if (args.length === 1 && args[0] === 'daemon-reload') return null
|
| 139 |
+
if (args.length === 3 && args[0] === 'enable' && args[1] === '--now') {
|
| 140 |
+
if (/^openclaw-gateway@[a-z_][a-z0-9_-]{1,30}\.service$/.test(args[2])) return null
|
| 141 |
+
return 'systemctl service name not allowed'
|
| 142 |
+
}
|
| 143 |
+
if (args.length === 3 && args[0] === 'disable' && args[1] === '--now') {
|
| 144 |
+
if (/^openclaw-gateway@[a-z_][a-z0-9_-]{1,30}\.service$/.test(args[2])) return null
|
| 145 |
+
return 'systemctl service name not allowed'
|
| 146 |
+
}
|
| 147 |
+
return 'systemctl args not allowed'
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return `Command not allowlisted: ${command}`
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function run(command, args, timeoutMs) {
|
| 154 |
+
return new Promise((resolve) => {
|
| 155 |
+
const child = spawn(command, args, { shell: false })
|
| 156 |
+
let stdout = ''
|
| 157 |
+
let stderr = ''
|
| 158 |
+
let timedOut = false
|
| 159 |
+
|
| 160 |
+
const timer = setTimeout(() => {
|
| 161 |
+
timedOut = true
|
| 162 |
+
child.kill('SIGKILL')
|
| 163 |
+
}, Math.max(1000, Number(timeoutMs || 10000)))
|
| 164 |
+
|
| 165 |
+
child.stdout.on('data', (d) => { stdout += d.toString('utf8') })
|
| 166 |
+
child.stderr.on('data', (d) => { stderr += d.toString('utf8') })
|
| 167 |
+
|
| 168 |
+
child.on('close', (code) => {
|
| 169 |
+
clearTimeout(timer)
|
| 170 |
+
resolve({
|
| 171 |
+
ok: !timedOut && code === 0,
|
| 172 |
+
code: timedOut ? 124 : code,
|
| 173 |
+
stdout,
|
| 174 |
+
stderr: timedOut ? `${stderr}\nTimed out` : stderr,
|
| 175 |
+
})
|
| 176 |
+
})
|
| 177 |
+
|
| 178 |
+
child.on('error', (err) => {
|
| 179 |
+
clearTimeout(timer)
|
| 180 |
+
resolve({ ok: false, code: 1, stdout, stderr: `${stderr}\n${err.message}` })
|
| 181 |
+
})
|
| 182 |
+
})
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function sleep(ms) {
|
| 186 |
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
async function runWithRetry(command, args, timeoutMs) {
|
| 190 |
+
const cmd = String(command || '').split('/').pop()
|
| 191 |
+
const maxAttempts = cmd === 'useradd' ? 6 : 1
|
| 192 |
+
let last = null
|
| 193 |
+
|
| 194 |
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 195 |
+
const result = await run(command, args, timeoutMs)
|
| 196 |
+
last = result
|
| 197 |
+
if (result.ok) return result
|
| 198 |
+
|
| 199 |
+
const transientLock =
|
| 200 |
+
cmd === 'useradd' &&
|
| 201 |
+
/cannot lock \/etc\/passwd/i.test(String(result.stderr || ''))
|
| 202 |
+
|
| 203 |
+
if (!transientLock || attempt === maxAttempts) {
|
| 204 |
+
return result
|
| 205 |
+
}
|
| 206 |
+
await sleep(800)
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
return last || { ok: false, code: 1, stdout: '', stderr: 'Unknown execution failure' }
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function writeResp(socket, obj) {
|
| 213 |
+
try {
|
| 214 |
+
socket.write(JSON.stringify(obj) + '\n')
|
| 215 |
+
} catch {
|
| 216 |
+
// no-op
|
| 217 |
+
} finally {
|
| 218 |
+
socket.end()
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
if (fs.existsSync(SOCKET_PATH)) {
|
| 223 |
+
try {
|
| 224 |
+
fs.unlinkSync(SOCKET_PATH)
|
| 225 |
+
} catch (err) {
|
| 226 |
+
console.error(`Failed to remove stale socket ${SOCKET_PATH}:`, err.message)
|
| 227 |
+
process.exit(1)
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const server = net.createServer((socket) => {
|
| 232 |
+
let buf = ''
|
| 233 |
+
|
| 234 |
+
socket.on('data', async (chunk) => {
|
| 235 |
+
buf += chunk.toString('utf8')
|
| 236 |
+
const idx = buf.indexOf('\n')
|
| 237 |
+
if (idx === -1) return
|
| 238 |
+
|
| 239 |
+
const line = buf.slice(0, idx)
|
| 240 |
+
buf = buf.slice(idx + 1)
|
| 241 |
+
|
| 242 |
+
let req
|
| 243 |
+
try {
|
| 244 |
+
req = JSON.parse(line)
|
| 245 |
+
} catch {
|
| 246 |
+
writeResp(socket, { ok: false, error: 'Invalid JSON' })
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (!req || req.token !== TOKEN) {
|
| 251 |
+
writeResp(socket, { ok: false, error: 'Unauthorized' })
|
| 252 |
+
return
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const command = String(req.command || '')
|
| 256 |
+
const args = Array.isArray(req.args) ? req.args.map((a) => String(a)) : []
|
| 257 |
+
const dryRun = !!req.dryRun
|
| 258 |
+
const timeoutMs = Number(req.timeoutMs || 10000)
|
| 259 |
+
|
| 260 |
+
const validationErr = validateCommand(command, args)
|
| 261 |
+
if (validationErr) {
|
| 262 |
+
writeResp(socket, { ok: false, error: validationErr })
|
| 263 |
+
return
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if (dryRun) {
|
| 267 |
+
writeResp(socket, { ok: true, code: 0, stdout: '', stderr: '', skipped: true })
|
| 268 |
+
return
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
const result = await runWithRetry(command, args, timeoutMs)
|
| 272 |
+
if (!result.ok) {
|
| 273 |
+
writeResp(socket, { ok: false, code: result.code, stdout: result.stdout, stderr: result.stderr, error: `Command failed: ${command}` })
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
writeResp(socket, { ok: true, code: result.code, stdout: result.stdout, stderr: result.stderr, skipped: false })
|
| 278 |
+
})
|
| 279 |
+
})
|
| 280 |
+
|
| 281 |
+
server.listen(SOCKET_PATH, () => {
|
| 282 |
+
fs.chmodSync(SOCKET_PATH, 0o660)
|
| 283 |
+
try {
|
| 284 |
+
const group = require('child_process').execSync(`getent group ${SOCKET_GROUP} | cut -d: -f3`).toString('utf8').trim()
|
| 285 |
+
const gid = Number(group)
|
| 286 |
+
if (Number.isInteger(gid)) {
|
| 287 |
+
fs.chownSync(SOCKET_PATH, 0, gid)
|
| 288 |
+
}
|
| 289 |
+
} catch {
|
| 290 |
+
// fallback: keep root:root
|
| 291 |
+
}
|
| 292 |
+
console.log(`mc-provisioner listening on ${SOCKET_PATH}`)
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
function shutdown() {
|
| 296 |
+
try { server.close() } catch {}
|
| 297 |
+
try { fs.unlinkSync(SOCKET_PATH) } catch {}
|
| 298 |
+
process.exit(0)
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
process.on('SIGINT', shutdown)
|
| 302 |
+
process.on('SIGTERM', shutdown)
|
ops/templates/openclaw-gateway@.service
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[Unit]
|
| 2 |
+
Description=OpenClaw Gateway (%i)
|
| 3 |
+
After=network-online.target
|
| 4 |
+
Wants=network-online.target
|
| 5 |
+
|
| 6 |
+
[Service]
|
| 7 |
+
Type=simple
|
| 8 |
+
User=%i
|
| 9 |
+
Group=%i
|
| 10 |
+
SupplementaryGroups=docker
|
| 11 |
+
WorkingDirectory=/home/%i
|
| 12 |
+
Environment=HOME=/home/%i
|
| 13 |
+
Environment=PATH=/home/%i/bin:/usr/local/bin:/usr/bin:/bin:/home/%i/.local/bin
|
| 14 |
+
Environment=NODE_OPTIONS=--experimental-sqlite
|
| 15 |
+
EnvironmentFile=-/etc/openclaw-tenants/%i.env
|
| 16 |
+
ExecStart=/usr/local/bin/openclaw gateway --port ${OPENCLAW_GATEWAY_PORT}
|
| 17 |
+
Restart=always
|
| 18 |
+
RestartSec=5
|
| 19 |
+
NoNewPrivileges=true
|
| 20 |
+
LimitNOFILE=65535
|
| 21 |
+
|
| 22 |
+
[Install]
|
| 23 |
+
WantedBy=multi-user.target
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "mission-control",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev --hostname 127.0.0.1",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start --hostname 0.0.0.0 --port 3005",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"typecheck": "tsc --noEmit",
|
| 11 |
+
"test": "vitest run",
|
| 12 |
+
"test:watch": "vitest",
|
| 13 |
+
"test:ui": "vitest --ui",
|
| 14 |
+
"test:e2e": "playwright test",
|
| 15 |
+
"test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm test:e2e && pnpm build",
|
| 16 |
+
"quality:gate": "pnpm test:all"
|
| 17 |
+
},
|
| 18 |
+
"dependencies": {
|
| 19 |
+
"@xyflow/react": "^12.10.0",
|
| 20 |
+
"autoprefixer": "^10.4.20",
|
| 21 |
+
"better-sqlite3": "^12.6.2",
|
| 22 |
+
"clsx": "^2.1.1",
|
| 23 |
+
"eslint": "^9.18.0",
|
| 24 |
+
"eslint-config-next": "^16.1.6",
|
| 25 |
+
"next": "^16.1.6",
|
| 26 |
+
"next-themes": "^0.4.6",
|
| 27 |
+
"postcss": "^8.5.2",
|
| 28 |
+
"react": "^19.0.1",
|
| 29 |
+
"react-dom": "^19.0.1",
|
| 30 |
+
"reactflow": "^11.11.4",
|
| 31 |
+
"recharts": "^3.7.0",
|
| 32 |
+
"tailwind-merge": "^3.4.0",
|
| 33 |
+
"tailwindcss": "^3.4.17",
|
| 34 |
+
"typescript": "^5.7.2",
|
| 35 |
+
"ws": "^8.19.0",
|
| 36 |
+
"zustand": "^5.0.11"
|
| 37 |
+
},
|
| 38 |
+
"devDependencies": {
|
| 39 |
+
"@playwright/test": "^1.51.0",
|
| 40 |
+
"@testing-library/dom": "^10.4.0",
|
| 41 |
+
"@testing-library/jest-dom": "^6.6.3",
|
| 42 |
+
"@testing-library/react": "^16.1.0",
|
| 43 |
+
"@types/better-sqlite3": "^7.6.13",
|
| 44 |
+
"@types/node": "^22.10.6",
|
| 45 |
+
"@types/react": "^19.0.8",
|
| 46 |
+
"@types/react-dom": "^19.0.3",
|
| 47 |
+
"@types/ws": "^8.18.1",
|
| 48 |
+
"@vitejs/plugin-react": "^4.3.4",
|
| 49 |
+
"jsdom": "^26.0.0",
|
| 50 |
+
"vite-tsconfig-paths": "^5.1.4",
|
| 51 |
+
"vitest": "^2.1.5"
|
| 52 |
+
},
|
| 53 |
+
"keywords": [
|
| 54 |
+
"openclaw",
|
| 55 |
+
"agent",
|
| 56 |
+
"orchestration",
|
| 57 |
+
"dashboard",
|
| 58 |
+
"nextjs"
|
| 59 |
+
],
|
| 60 |
+
"author": "Builderz Labs",
|
| 61 |
+
"license": "MIT",
|
| 62 |
+
"repository": {
|
| 63 |
+
"type": "git",
|
| 64 |
+
"url": "https://github.com/builderz-labs/mission-control.git"
|
| 65 |
+
}
|
| 66 |
+
}
|
playwright.config.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
export default defineConfig({
|
| 4 |
+
testDir: 'tests',
|
| 5 |
+
timeout: 60_000,
|
| 6 |
+
expect: {
|
| 7 |
+
timeout: 10_000
|
| 8 |
+
},
|
| 9 |
+
fullyParallel: true,
|
| 10 |
+
reporter: [['list']],
|
| 11 |
+
use: {
|
| 12 |
+
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:3000',
|
| 13 |
+
trace: 'retain-on-failure'
|
| 14 |
+
},
|
| 15 |
+
projects: [
|
| 16 |
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
| 17 |
+
]
|
| 18 |
+
})
|
pnpm-lock.yaml
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module.exports = {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
scripts/agent-heartbeat.sh
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Mission Control Phase 3: Agent Heartbeat Script
|
| 4 |
+
# Called by OpenClaw cron every 15 minutes to wake agents and check for work
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# scripts/agent-heartbeat.sh [agent_name]
|
| 8 |
+
#
|
| 9 |
+
# If no agent specified, checks all agents with session keys
|
| 10 |
+
|
| 11 |
+
set -e
|
| 12 |
+
|
| 13 |
+
# Configuration
|
| 14 |
+
MISSION_CONTROL_URL="${MISSION_CONTROL_URL:-http://localhost:3005}"
|
| 15 |
+
LOG_DIR="${LOG_DIR:-$HOME/.mission-control/logs}"
|
| 16 |
+
LOG_FILE="$LOG_DIR/agent-heartbeat-$(date +%Y-%m-%d).log"
|
| 17 |
+
MAX_CONCURRENT=3 # Max agents to check concurrently
|
| 18 |
+
OPENCLAW_CMD="${OPENCLAW_CMD:-openclaw}"
|
| 19 |
+
|
| 20 |
+
# Ensure log directory exists
|
| 21 |
+
mkdir -p "$LOG_DIR"
|
| 22 |
+
|
| 23 |
+
# Logging function
|
| 24 |
+
log() {
|
| 25 |
+
local level="$1"
|
| 26 |
+
shift
|
| 27 |
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Check if Mission Control is running
|
| 31 |
+
check_mission_control() {
|
| 32 |
+
if ! curl -s "$MISSION_CONTROL_URL/api/status" > /dev/null 2>&1; then
|
| 33 |
+
log "ERROR" "Mission Control not accessible at $MISSION_CONTROL_URL"
|
| 34 |
+
return 1
|
| 35 |
+
fi
|
| 36 |
+
return 0
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
# Check heartbeat for specific agent
|
| 40 |
+
check_agent_heartbeat() {
|
| 41 |
+
local agent_name="$1"
|
| 42 |
+
local agent_id="$2"
|
| 43 |
+
|
| 44 |
+
log "INFO" "Checking heartbeat for agent: $agent_name"
|
| 45 |
+
|
| 46 |
+
# Call heartbeat endpoint
|
| 47 |
+
local response
|
| 48 |
+
response=$(curl -s -w "HTTP_STATUS:%{http_code}" "$MISSION_CONTROL_URL/api/agents/$agent_id/heartbeat" 2>/dev/null)
|
| 49 |
+
|
| 50 |
+
local http_code
|
| 51 |
+
http_code=$(echo "$response" | grep -o "HTTP_STATUS:[0-9]*" | cut -d: -f2)
|
| 52 |
+
local body
|
| 53 |
+
body=$(echo "$response" | sed 's/HTTP_STATUS:[0-9]*$//')
|
| 54 |
+
|
| 55 |
+
if [[ "$http_code" != "200" ]]; then
|
| 56 |
+
log "ERROR" "Heartbeat failed for $agent_name: HTTP $http_code"
|
| 57 |
+
return 1
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
# Parse response
|
| 61 |
+
local status
|
| 62 |
+
status=$(echo "$body" | jq -r '.status // "unknown"' 2>/dev/null || echo "parse_error")
|
| 63 |
+
|
| 64 |
+
if [[ "$status" == "HEARTBEAT_OK" ]]; then
|
| 65 |
+
log "INFO" "Agent $agent_name: No work items found"
|
| 66 |
+
return 0
|
| 67 |
+
elif [[ "$status" == "WORK_ITEMS_FOUND" ]]; then
|
| 68 |
+
local total_items
|
| 69 |
+
total_items=$(echo "$body" | jq -r '.total_items // 0' 2>/dev/null || echo "0")
|
| 70 |
+
log "INFO" "Agent $agent_name: Found $total_items work items"
|
| 71 |
+
|
| 72 |
+
# If work items found and agent has session key, send wake notification
|
| 73 |
+
local session_key
|
| 74 |
+
session_key=$(get_agent_session_key "$agent_name")
|
| 75 |
+
|
| 76 |
+
if [[ -n "$session_key" && "$session_key" != "null" ]]; then
|
| 77 |
+
send_wake_notification "$agent_name" "$session_key" "$total_items" "$body"
|
| 78 |
+
else
|
| 79 |
+
log "WARN" "Agent $agent_name has work items but no session key configured"
|
| 80 |
+
fi
|
| 81 |
+
|
| 82 |
+
return 0
|
| 83 |
+
else
|
| 84 |
+
log "ERROR" "Unexpected heartbeat response for $agent_name: $status"
|
| 85 |
+
return 1
|
| 86 |
+
fi
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# Get agent session key from database
|
| 90 |
+
get_agent_session_key() {
|
| 91 |
+
local agent_name="$1"
|
| 92 |
+
|
| 93 |
+
# Query agents API to get session key
|
| 94 |
+
local agent_data
|
| 95 |
+
agent_data=$(curl -s "$MISSION_CONTROL_URL/api/agents?limit=100" 2>/dev/null | jq -r ".agents[] | select(.name == \"$agent_name\") | .session_key" 2>/dev/null || echo "")
|
| 96 |
+
|
| 97 |
+
echo "$agent_data"
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
# Send wake notification to agent session
|
| 101 |
+
send_wake_notification() {
|
| 102 |
+
local agent_name="$1"
|
| 103 |
+
local session_key="$2"
|
| 104 |
+
local work_items_count="$3"
|
| 105 |
+
local heartbeat_data="$4"
|
| 106 |
+
|
| 107 |
+
log "INFO" "Sending wake notification to $agent_name (session: $session_key)"
|
| 108 |
+
|
| 109 |
+
# Format wake message
|
| 110 |
+
local wake_message="🤖 **Mission Control Heartbeat**\n\n"
|
| 111 |
+
wake_message+="Agent: $agent_name\n"
|
| 112 |
+
wake_message+="Work items found: $work_items_count\n\n"
|
| 113 |
+
wake_message+="🔔 You have notifications or tasks that need attention.\n"
|
| 114 |
+
wake_message+="Use Mission Control to view details: $MISSION_CONTROL_URL\n\n"
|
| 115 |
+
wake_message+="⏰ $(date '+%Y-%m-%d %H:%M:%S')"
|
| 116 |
+
|
| 117 |
+
# Send via OpenClaw sessions_send
|
| 118 |
+
if "$OPENCLAW_CMD" gateway sessions_send --session "$session_key" --message "$wake_message" >> "$LOG_FILE" 2>&1; then
|
| 119 |
+
log "INFO" "Wake notification sent successfully to $agent_name"
|
| 120 |
+
else
|
| 121 |
+
log "ERROR" "Failed to send wake notification to $agent_name"
|
| 122 |
+
fi
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# Get list of agents to check
|
| 126 |
+
get_agents_to_check() {
|
| 127 |
+
local filter_agent="$1"
|
| 128 |
+
|
| 129 |
+
if [[ -n "$filter_agent" ]]; then
|
| 130 |
+
# Check specific agent
|
| 131 |
+
echo "$filter_agent"
|
| 132 |
+
return
|
| 133 |
+
fi
|
| 134 |
+
|
| 135 |
+
# Get all agents with session keys
|
| 136 |
+
curl -s "$MISSION_CONTROL_URL/api/agents?limit=100" 2>/dev/null | \
|
| 137 |
+
jq -r '.agents[] | select(.session_key != null and .session_key != "") | .name' 2>/dev/null || \
|
| 138 |
+
echo ""
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
# Main execution
|
| 142 |
+
main() {
|
| 143 |
+
local target_agent="$1"
|
| 144 |
+
|
| 145 |
+
log "INFO" "Starting agent heartbeat check (PID: $$)"
|
| 146 |
+
|
| 147 |
+
# Check if Mission Control is running
|
| 148 |
+
if ! check_mission_control; then
|
| 149 |
+
log "ERROR" "Aborting: Mission Control not accessible"
|
| 150 |
+
exit 1
|
| 151 |
+
fi
|
| 152 |
+
|
| 153 |
+
# Get agents to check
|
| 154 |
+
local agents
|
| 155 |
+
agents=$(get_agents_to_check "$target_agent")
|
| 156 |
+
|
| 157 |
+
if [[ -z "$agents" ]]; then
|
| 158 |
+
log "WARN" "No agents found with session keys configured"
|
| 159 |
+
exit 0
|
| 160 |
+
fi
|
| 161 |
+
|
| 162 |
+
local total_agents
|
| 163 |
+
total_agents=$(echo "$agents" | wc -l)
|
| 164 |
+
log "INFO" "Checking heartbeat for $total_agents agent(s)"
|
| 165 |
+
|
| 166 |
+
# Process agents (limit concurrency)
|
| 167 |
+
local processed=0
|
| 168 |
+
local successful=0
|
| 169 |
+
local failed=0
|
| 170 |
+
local pids=()
|
| 171 |
+
|
| 172 |
+
while IFS= read -r agent_name; do
|
| 173 |
+
[[ -z "$agent_name" ]] && continue
|
| 174 |
+
|
| 175 |
+
# Wait if we've reached max concurrent processes
|
| 176 |
+
while [[ ${#pids[@]} -ge $MAX_CONCURRENT ]]; do
|
| 177 |
+
for i in "${!pids[@]}"; do
|
| 178 |
+
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
|
| 179 |
+
unset "pids[$i]"
|
| 180 |
+
fi
|
| 181 |
+
done
|
| 182 |
+
pids=("${pids[@]}") # Reindex array
|
| 183 |
+
|
| 184 |
+
if [[ ${#pids[@]} -ge $MAX_CONCURRENT ]]; then
|
| 185 |
+
sleep 1
|
| 186 |
+
fi
|
| 187 |
+
done
|
| 188 |
+
|
| 189 |
+
# Start heartbeat check in background
|
| 190 |
+
(
|
| 191 |
+
if check_agent_heartbeat "$agent_name" "$agent_name"; then
|
| 192 |
+
echo "SUCCESS:$agent_name"
|
| 193 |
+
else
|
| 194 |
+
echo "FAILED:$agent_name"
|
| 195 |
+
fi
|
| 196 |
+
) &
|
| 197 |
+
|
| 198 |
+
pids+=($!)
|
| 199 |
+
((processed++))
|
| 200 |
+
done <<< "$agents"
|
| 201 |
+
|
| 202 |
+
# Wait for all background processes
|
| 203 |
+
for pid in "${pids[@]}"; do
|
| 204 |
+
if wait "$pid"; then
|
| 205 |
+
((successful++))
|
| 206 |
+
else
|
| 207 |
+
((failed++))
|
| 208 |
+
fi
|
| 209 |
+
done
|
| 210 |
+
|
| 211 |
+
log "INFO" "Heartbeat check completed: $processed processed, $successful successful, $failed failed"
|
| 212 |
+
|
| 213 |
+
# Return appropriate exit code
|
| 214 |
+
if [[ $failed -gt 0 ]]; then
|
| 215 |
+
exit 1
|
| 216 |
+
else
|
| 217 |
+
exit 0
|
| 218 |
+
fi
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
# Handle script arguments
|
| 222 |
+
case "${1:-}" in
|
| 223 |
+
--help|-h)
|
| 224 |
+
echo "Mission Control Agent Heartbeat Script"
|
| 225 |
+
echo ""
|
| 226 |
+
echo "Usage: $0 [agent_name]"
|
| 227 |
+
echo ""
|
| 228 |
+
echo "Options:"
|
| 229 |
+
echo " agent_name Check specific agent only"
|
| 230 |
+
echo " --help, -h Show this help message"
|
| 231 |
+
echo ""
|
| 232 |
+
echo "Environment variables:"
|
| 233 |
+
echo " MISSION_CONTROL_URL Mission Control base URL (default: http://localhost:3005)"
|
| 234 |
+
echo ""
|
| 235 |
+
exit 0
|
| 236 |
+
;;
|
| 237 |
+
*)
|
| 238 |
+
main "$@"
|
| 239 |
+
;;
|
| 240 |
+
esac
|
scripts/notification-daemon.sh
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Mission Control Phase 3: Notification Delivery Daemon
|
| 4 |
+
# Polls undelivered notifications and sends them to agent sessions via OpenClaw
|
| 5 |
+
#
|
| 6 |
+
# Usage:
|
| 7 |
+
# scripts/notification-daemon.sh [options]
|
| 8 |
+
#
|
| 9 |
+
# Options:
|
| 10 |
+
# --agent AGENT_NAME Only deliver notifications to specific agent
|
| 11 |
+
# --limit N Max notifications to process per batch (default: 50)
|
| 12 |
+
# --dry-run Test mode - don't actually deliver notifications
|
| 13 |
+
# --daemon Run in daemon mode (continuous polling)
|
| 14 |
+
# --interval SECONDS Polling interval in daemon mode (default: 60)
|
| 15 |
+
|
| 16 |
+
set -e
|
| 17 |
+
|
| 18 |
+
# Configuration
|
| 19 |
+
MISSION_CONTROL_URL="${MISSION_CONTROL_URL:-http://localhost:3005}"
|
| 20 |
+
LOG_DIR="${LOG_DIR:-$HOME/.mission-control/logs}"
|
| 21 |
+
LOG_FILE="$LOG_DIR/notification-daemon-$(date +%Y-%m-%d).log"
|
| 22 |
+
PID_FILE="/tmp/notification-daemon.pid"
|
| 23 |
+
DEFAULT_INTERVAL=60
|
| 24 |
+
DEFAULT_LIMIT=50
|
| 25 |
+
|
| 26 |
+
# Command line options
|
| 27 |
+
AGENT_FILTER=""
|
| 28 |
+
LIMIT=$DEFAULT_LIMIT
|
| 29 |
+
DRY_RUN=false
|
| 30 |
+
DAEMON_MODE=false
|
| 31 |
+
INTERVAL=$DEFAULT_INTERVAL
|
| 32 |
+
|
| 33 |
+
# Ensure log directory exists
|
| 34 |
+
mkdir -p "$LOG_DIR"
|
| 35 |
+
|
| 36 |
+
# Logging function
|
| 37 |
+
log() {
|
| 38 |
+
local level="$1"
|
| 39 |
+
shift
|
| 40 |
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# Check if Mission Control is running
|
| 44 |
+
check_mission_control() {
|
| 45 |
+
if ! curl -s "$MISSION_CONTROL_URL/api/status" > /dev/null 2>&1; then
|
| 46 |
+
log "ERROR" "Mission Control not accessible at $MISSION_CONTROL_URL"
|
| 47 |
+
return 1
|
| 48 |
+
fi
|
| 49 |
+
return 0
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Process and deliver notifications
|
| 53 |
+
deliver_notifications() {
|
| 54 |
+
log "INFO" "Starting notification delivery batch"
|
| 55 |
+
|
| 56 |
+
# Build API request
|
| 57 |
+
local api_payload="{\"limit\": $LIMIT"
|
| 58 |
+
|
| 59 |
+
if [[ -n "$AGENT_FILTER" ]]; then
|
| 60 |
+
api_payload+=", \"agent_filter\": \"$AGENT_FILTER\""
|
| 61 |
+
fi
|
| 62 |
+
|
| 63 |
+
if [[ "$DRY_RUN" == "true" ]]; then
|
| 64 |
+
api_payload+=", \"dry_run\": true"
|
| 65 |
+
fi
|
| 66 |
+
|
| 67 |
+
api_payload+="}"
|
| 68 |
+
|
| 69 |
+
# Call notification delivery endpoint
|
| 70 |
+
local response
|
| 71 |
+
response=$(curl -s -w "HTTP_STATUS:%{http_code}" \
|
| 72 |
+
-X POST \
|
| 73 |
+
-H "Content-Type: application/json" \
|
| 74 |
+
-d "$api_payload" \
|
| 75 |
+
"$MISSION_CONTROL_URL/api/notifications/deliver" 2>/dev/null)
|
| 76 |
+
|
| 77 |
+
local http_code
|
| 78 |
+
http_code=$(echo "$response" | grep -o "HTTP_STATUS:[0-9]*" | cut -d: -f2)
|
| 79 |
+
local body
|
| 80 |
+
body=$(echo "$response" | sed 's/HTTP_STATUS:[0-9]*$//')
|
| 81 |
+
|
| 82 |
+
if [[ "$http_code" != "200" ]]; then
|
| 83 |
+
log "ERROR" "Notification delivery failed: HTTP $http_code"
|
| 84 |
+
log "ERROR" "Response: $body"
|
| 85 |
+
return 1
|
| 86 |
+
fi
|
| 87 |
+
|
| 88 |
+
# Parse results
|
| 89 |
+
local status delivered errors total_processed
|
| 90 |
+
status=$(echo "$body" | jq -r '.status // "unknown"' 2>/dev/null || echo "parse_error")
|
| 91 |
+
delivered=$(echo "$body" | jq -r '.delivered // 0' 2>/dev/null || echo "0")
|
| 92 |
+
errors=$(echo "$body" | jq -r '.errors // 0' 2>/dev/null || echo "0")
|
| 93 |
+
total_processed=$(echo "$body" | jq -r '.total_processed // 0' 2>/dev/null || echo "0")
|
| 94 |
+
|
| 95 |
+
if [[ "$status" == "success" ]]; then
|
| 96 |
+
if [[ "$total_processed" -gt 0 ]]; then
|
| 97 |
+
log "INFO" "Batch completed: $total_processed processed, $delivered delivered, $errors failed"
|
| 98 |
+
|
| 99 |
+
# Log detailed errors if any
|
| 100 |
+
if [[ "$errors" -gt 0 ]]; then
|
| 101 |
+
local error_details
|
| 102 |
+
error_details=$(echo "$body" | jq -r '.error_details[]? | "- \(.recipient): \(.error)"' 2>/dev/null || echo "")
|
| 103 |
+
if [[ -n "$error_details" ]]; then
|
| 104 |
+
log "WARN" "Error details:"
|
| 105 |
+
echo "$error_details" | while read -r line; do
|
| 106 |
+
log "WARN" " $line"
|
| 107 |
+
done
|
| 108 |
+
fi
|
| 109 |
+
fi
|
| 110 |
+
else
|
| 111 |
+
log "INFO" "No notifications to deliver"
|
| 112 |
+
fi
|
| 113 |
+
|
| 114 |
+
return 0
|
| 115 |
+
else
|
| 116 |
+
log "ERROR" "Unexpected delivery response: $status"
|
| 117 |
+
return 1
|
| 118 |
+
fi
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
# Get delivery statistics
|
| 122 |
+
get_delivery_stats() {
|
| 123 |
+
local stats_url="$MISSION_CONTROL_URL/api/notifications/deliver"
|
| 124 |
+
|
| 125 |
+
if [[ -n "$AGENT_FILTER" ]]; then
|
| 126 |
+
stats_url+="?agent=$AGENT_FILTER"
|
| 127 |
+
fi
|
| 128 |
+
|
| 129 |
+
local response
|
| 130 |
+
response=$(curl -s "$stats_url" 2>/dev/null)
|
| 131 |
+
|
| 132 |
+
if [[ $? -eq 0 ]]; then
|
| 133 |
+
echo "$response" | jq -r '
|
| 134 |
+
"Delivery Statistics:",
|
| 135 |
+
" Total notifications: \(.statistics.total)",
|
| 136 |
+
" Delivered: \(.statistics.delivered)",
|
| 137 |
+
" Undelivered: \(.statistics.undelivered)",
|
| 138 |
+
" Delivery rate: \(.statistics.delivery_rate)%",
|
| 139 |
+
"",
|
| 140 |
+
"Agents with pending notifications:",
|
| 141 |
+
(.agents_with_pending[] | " \(.recipient): \(.pending_count) pending\(if .session_key then "" else " (no session key)" end)")
|
| 142 |
+
' 2>/dev/null || echo "Failed to parse statistics"
|
| 143 |
+
else
|
| 144 |
+
echo "Failed to fetch delivery statistics"
|
| 145 |
+
fi
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# Daemon mode signal handlers
|
| 149 |
+
cleanup() {
|
| 150 |
+
log "INFO" "Received shutdown signal, stopping daemon"
|
| 151 |
+
rm -f "$PID_FILE"
|
| 152 |
+
exit 0
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
# Check if daemon is already running
|
| 156 |
+
check_daemon() {
|
| 157 |
+
if [[ -f "$PID_FILE" ]]; then
|
| 158 |
+
local old_pid
|
| 159 |
+
old_pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
|
| 160 |
+
|
| 161 |
+
if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
|
| 162 |
+
log "ERROR" "Notification daemon already running with PID $old_pid"
|
| 163 |
+
exit 1
|
| 164 |
+
else
|
| 165 |
+
log "WARN" "Stale PID file found, removing"
|
| 166 |
+
rm -f "$PID_FILE"
|
| 167 |
+
fi
|
| 168 |
+
fi
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
# Run in daemon mode
|
| 172 |
+
run_daemon() {
|
| 173 |
+
log "INFO" "Starting notification daemon (PID: $$)"
|
| 174 |
+
|
| 175 |
+
# Check if already running
|
| 176 |
+
check_daemon
|
| 177 |
+
|
| 178 |
+
# Write PID file
|
| 179 |
+
echo $$ > "$PID_FILE"
|
| 180 |
+
|
| 181 |
+
# Set up signal handlers
|
| 182 |
+
trap cleanup SIGTERM SIGINT SIGQUIT
|
| 183 |
+
|
| 184 |
+
# Main daemon loop
|
| 185 |
+
while true; do
|
| 186 |
+
if ! check_mission_control; then
|
| 187 |
+
log "WARN" "Mission Control not accessible, sleeping $INTERVAL seconds"
|
| 188 |
+
sleep "$INTERVAL"
|
| 189 |
+
continue
|
| 190 |
+
fi
|
| 191 |
+
|
| 192 |
+
# Process notifications
|
| 193 |
+
if deliver_notifications; then
|
| 194 |
+
log "DEBUG" "Delivery batch completed successfully"
|
| 195 |
+
else
|
| 196 |
+
log "WARN" "Delivery batch had errors"
|
| 197 |
+
fi
|
| 198 |
+
|
| 199 |
+
# Sleep until next cycle
|
| 200 |
+
sleep "$INTERVAL"
|
| 201 |
+
done
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
# Parse command line arguments
|
| 205 |
+
parse_args() {
|
| 206 |
+
while [[ $# -gt 0 ]]; do
|
| 207 |
+
case $1 in
|
| 208 |
+
--agent)
|
| 209 |
+
AGENT_FILTER="$2"
|
| 210 |
+
shift 2
|
| 211 |
+
;;
|
| 212 |
+
--limit)
|
| 213 |
+
LIMIT="$2"
|
| 214 |
+
shift 2
|
| 215 |
+
;;
|
| 216 |
+
--dry-run)
|
| 217 |
+
DRY_RUN=true
|
| 218 |
+
shift
|
| 219 |
+
;;
|
| 220 |
+
--daemon)
|
| 221 |
+
DAEMON_MODE=true
|
| 222 |
+
shift
|
| 223 |
+
;;
|
| 224 |
+
--interval)
|
| 225 |
+
INTERVAL="$2"
|
| 226 |
+
shift 2
|
| 227 |
+
;;
|
| 228 |
+
--stats)
|
| 229 |
+
get_delivery_stats
|
| 230 |
+
exit 0
|
| 231 |
+
;;
|
| 232 |
+
--stop)
|
| 233 |
+
if [[ -f "$PID_FILE" ]]; then
|
| 234 |
+
local pid
|
| 235 |
+
pid=$(cat "$PID_FILE" 2>/dev/null || echo "")
|
| 236 |
+
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
| 237 |
+
kill -TERM "$pid"
|
| 238 |
+
log "INFO" "Sent stop signal to daemon (PID: $pid)"
|
| 239 |
+
exit 0
|
| 240 |
+
else
|
| 241 |
+
log "WARN" "No running daemon found"
|
| 242 |
+
rm -f "$PID_FILE"
|
| 243 |
+
exit 1
|
| 244 |
+
fi
|
| 245 |
+
else
|
| 246 |
+
log "WARN" "No daemon PID file found"
|
| 247 |
+
exit 1
|
| 248 |
+
fi
|
| 249 |
+
;;
|
| 250 |
+
--help|-h)
|
| 251 |
+
show_help
|
| 252 |
+
exit 0
|
| 253 |
+
;;
|
| 254 |
+
*)
|
| 255 |
+
echo "Unknown option: $1" >&2
|
| 256 |
+
show_help
|
| 257 |
+
exit 1
|
| 258 |
+
;;
|
| 259 |
+
esac
|
| 260 |
+
done
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
# Show help
|
| 264 |
+
show_help() {
|
| 265 |
+
cat << 'EOF'
|
| 266 |
+
Mission Control Notification Delivery Daemon
|
| 267 |
+
|
| 268 |
+
Usage: notification-daemon.sh [options]
|
| 269 |
+
|
| 270 |
+
Options:
|
| 271 |
+
--agent AGENT_NAME Only deliver notifications to specific agent
|
| 272 |
+
--limit N Max notifications to process per batch (default: 50)
|
| 273 |
+
--dry-run Test mode - don't actually deliver notifications
|
| 274 |
+
--daemon Run in daemon mode (continuous polling)
|
| 275 |
+
--interval SECONDS Polling interval in daemon mode (default: 60)
|
| 276 |
+
--stats Show delivery statistics and exit
|
| 277 |
+
--stop Stop running daemon
|
| 278 |
+
--help, -h Show this help message
|
| 279 |
+
|
| 280 |
+
Examples:
|
| 281 |
+
# Single batch delivery
|
| 282 |
+
./notification-daemon.sh
|
| 283 |
+
|
| 284 |
+
# Dry run to test
|
| 285 |
+
./notification-daemon.sh --dry-run
|
| 286 |
+
|
| 287 |
+
# Deliver only to specific agent
|
| 288 |
+
./notification-daemon.sh --agent "coordinator"
|
| 289 |
+
|
| 290 |
+
# Run as daemon
|
| 291 |
+
./notification-daemon.sh --daemon --interval 30
|
| 292 |
+
|
| 293 |
+
# Show statistics
|
| 294 |
+
./notification-daemon.sh --stats
|
| 295 |
+
|
| 296 |
+
# Stop daemon
|
| 297 |
+
./notification-daemon.sh --stop
|
| 298 |
+
|
| 299 |
+
Environment variables:
|
| 300 |
+
MISSION_CONTROL_URL Mission Control base URL (default: http://localhost:3005)
|
| 301 |
+
|
| 302 |
+
Log files:
|
| 303 |
+
$LOG_DIR/notification-daemon-YYYY-MM-DD.log
|
| 304 |
+
EOF
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
# Validate arguments
|
| 308 |
+
validate_args() {
|
| 309 |
+
if ! [[ "$LIMIT" =~ ^[1-9][0-9]*$ ]]; then
|
| 310 |
+
log "ERROR" "Invalid limit: $LIMIT (must be positive integer)"
|
| 311 |
+
exit 1
|
| 312 |
+
fi
|
| 313 |
+
|
| 314 |
+
if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
| 315 |
+
log "ERROR" "Invalid interval: $INTERVAL (must be positive integer)"
|
| 316 |
+
exit 1
|
| 317 |
+
fi
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
# Main execution
|
| 321 |
+
main() {
|
| 322 |
+
parse_args "$@"
|
| 323 |
+
validate_args
|
| 324 |
+
|
| 325 |
+
if [[ "$DAEMON_MODE" == "true" ]]; then
|
| 326 |
+
run_daemon
|
| 327 |
+
else
|
| 328 |
+
# Single run mode
|
| 329 |
+
log "INFO" "Starting single notification delivery run"
|
| 330 |
+
|
| 331 |
+
if ! check_mission_control; then
|
| 332 |
+
log "ERROR" "Aborting: Mission Control not accessible"
|
| 333 |
+
exit 1
|
| 334 |
+
fi
|
| 335 |
+
|
| 336 |
+
if deliver_notifications; then
|
| 337 |
+
log "INFO" "Notification delivery completed successfully"
|
| 338 |
+
exit 0
|
| 339 |
+
else
|
| 340 |
+
log "ERROR" "Notification delivery failed"
|
| 341 |
+
exit 1
|
| 342 |
+
fi
|
| 343 |
+
fi
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
# Run main function
|
| 347 |
+
main "$@"
|
src/app/api/activities/route.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase, Activity } from '@/lib/db';
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* GET /api/activities - Get activity stream or stats
|
| 6 |
+
* Query params: type, actor, entity_type, limit, offset, since, hours (for stats)
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
try {
|
| 10 |
+
const { searchParams, pathname } = new URL(request.url);
|
| 11 |
+
|
| 12 |
+
// Route to stats endpoint if requested
|
| 13 |
+
if (pathname.endsWith('/stats') || searchParams.has('stats')) {
|
| 14 |
+
return handleStatsRequest(request);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// Default activities endpoint
|
| 18 |
+
return handleActivitiesRequest(request);
|
| 19 |
+
} catch (error) {
|
| 20 |
+
console.error('GET /api/activities error:', error);
|
| 21 |
+
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Handle regular activities request
|
| 27 |
+
*/
|
| 28 |
+
async function handleActivitiesRequest(request: NextRequest) {
|
| 29 |
+
try {
|
| 30 |
+
const db = getDatabase();
|
| 31 |
+
const { searchParams } = new URL(request.url);
|
| 32 |
+
|
| 33 |
+
// Parse query parameters
|
| 34 |
+
const type = searchParams.get('type');
|
| 35 |
+
const actor = searchParams.get('actor');
|
| 36 |
+
const entity_type = searchParams.get('entity_type');
|
| 37 |
+
const limit = parseInt(searchParams.get('limit') || '50');
|
| 38 |
+
const offset = parseInt(searchParams.get('offset') || '0');
|
| 39 |
+
const since = searchParams.get('since'); // Unix timestamp for real-time updates
|
| 40 |
+
|
| 41 |
+
// Build dynamic query
|
| 42 |
+
let query = 'SELECT * FROM activities WHERE 1=1';
|
| 43 |
+
const params: any[] = [];
|
| 44 |
+
|
| 45 |
+
if (type) {
|
| 46 |
+
query += ' AND type = ?';
|
| 47 |
+
params.push(type);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (actor) {
|
| 51 |
+
query += ' AND actor = ?';
|
| 52 |
+
params.push(actor);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (entity_type) {
|
| 56 |
+
query += ' AND entity_type = ?';
|
| 57 |
+
params.push(entity_type);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
if (since) {
|
| 61 |
+
query += ' AND created_at > ?';
|
| 62 |
+
params.push(parseInt(since));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
| 66 |
+
params.push(limit, offset);
|
| 67 |
+
|
| 68 |
+
const stmt = db.prepare(query);
|
| 69 |
+
const activities = stmt.all(...params) as Activity[];
|
| 70 |
+
|
| 71 |
+
// Parse JSON data field and enhance with related entity data
|
| 72 |
+
const enhancedActivities = activities.map(activity => {
|
| 73 |
+
let entityDetails = null;
|
| 74 |
+
|
| 75 |
+
try {
|
| 76 |
+
// Fetch related entity details based on entity_type
|
| 77 |
+
switch (activity.entity_type) {
|
| 78 |
+
case 'task':
|
| 79 |
+
const task = db.prepare('SELECT id, title, status FROM tasks WHERE id = ?').get(activity.entity_id) as any;
|
| 80 |
+
if (task) {
|
| 81 |
+
entityDetails = { type: 'task', ...task };
|
| 82 |
+
}
|
| 83 |
+
break;
|
| 84 |
+
|
| 85 |
+
case 'agent':
|
| 86 |
+
const agent = db.prepare('SELECT id, name, role, status FROM agents WHERE id = ?').get(activity.entity_id) as any;
|
| 87 |
+
if (agent) {
|
| 88 |
+
entityDetails = { type: 'agent', ...agent };
|
| 89 |
+
}
|
| 90 |
+
break;
|
| 91 |
+
|
| 92 |
+
case 'comment':
|
| 93 |
+
const comment = db.prepare(`
|
| 94 |
+
SELECT c.id, c.content, c.task_id, t.title as task_title
|
| 95 |
+
FROM comments c
|
| 96 |
+
LEFT JOIN tasks t ON c.task_id = t.id
|
| 97 |
+
WHERE c.id = ?
|
| 98 |
+
`).get(activity.entity_id) as any;
|
| 99 |
+
if (comment) {
|
| 100 |
+
entityDetails = {
|
| 101 |
+
type: 'comment',
|
| 102 |
+
...comment,
|
| 103 |
+
content_preview: comment.content?.substring(0, 100) || ''
|
| 104 |
+
};
|
| 105 |
+
}
|
| 106 |
+
break;
|
| 107 |
+
}
|
| 108 |
+
} catch (error) {
|
| 109 |
+
// If entity lookup fails, continue without entity details
|
| 110 |
+
console.warn(`Failed to fetch entity details for activity ${activity.id}:`, error);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
...activity,
|
| 115 |
+
data: activity.data ? JSON.parse(activity.data) : null,
|
| 116 |
+
entity: entityDetails
|
| 117 |
+
};
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// Get total count for pagination
|
| 121 |
+
let countQuery = 'SELECT COUNT(*) as total FROM activities WHERE 1=1';
|
| 122 |
+
const countParams: any[] = [];
|
| 123 |
+
|
| 124 |
+
if (type) {
|
| 125 |
+
countQuery += ' AND type = ?';
|
| 126 |
+
countParams.push(type);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (actor) {
|
| 130 |
+
countQuery += ' AND actor = ?';
|
| 131 |
+
countParams.push(actor);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
if (entity_type) {
|
| 135 |
+
countQuery += ' AND entity_type = ?';
|
| 136 |
+
countParams.push(entity_type);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (since) {
|
| 140 |
+
countQuery += ' AND created_at > ?';
|
| 141 |
+
countParams.push(parseInt(since));
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const countResult = db.prepare(countQuery).get(...countParams) as { total: number };
|
| 145 |
+
|
| 146 |
+
return NextResponse.json({
|
| 147 |
+
activities: enhancedActivities,
|
| 148 |
+
total: countResult.total,
|
| 149 |
+
hasMore: offset + activities.length < countResult.total
|
| 150 |
+
});
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error('GET /api/activities (activities) error:', error);
|
| 153 |
+
return NextResponse.json({ error: 'Failed to fetch activities' }, { status: 500 });
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* Handle stats request
|
| 159 |
+
*/
|
| 160 |
+
async function handleStatsRequest(request: NextRequest) {
|
| 161 |
+
try {
|
| 162 |
+
const db = getDatabase();
|
| 163 |
+
const { searchParams } = new URL(request.url);
|
| 164 |
+
|
| 165 |
+
// Parse timeframe parameter (defaults to 24 hours)
|
| 166 |
+
const hours = parseInt(searchParams.get('hours') || '24');
|
| 167 |
+
const since = Math.floor(Date.now() / 1000) - (hours * 3600);
|
| 168 |
+
|
| 169 |
+
// Get activity counts by type
|
| 170 |
+
const activityStats = db.prepare(`
|
| 171 |
+
SELECT
|
| 172 |
+
type,
|
| 173 |
+
COUNT(*) as count
|
| 174 |
+
FROM activities
|
| 175 |
+
WHERE created_at > ?
|
| 176 |
+
GROUP BY type
|
| 177 |
+
ORDER BY count DESC
|
| 178 |
+
`).all(since) as { type: string; count: number }[];
|
| 179 |
+
|
| 180 |
+
// Get most active actors
|
| 181 |
+
const activeActors = db.prepare(`
|
| 182 |
+
SELECT
|
| 183 |
+
actor,
|
| 184 |
+
COUNT(*) as activity_count
|
| 185 |
+
FROM activities
|
| 186 |
+
WHERE created_at > ?
|
| 187 |
+
GROUP BY actor
|
| 188 |
+
ORDER BY activity_count DESC
|
| 189 |
+
LIMIT 10
|
| 190 |
+
`).all(since) as { actor: string; activity_count: number }[];
|
| 191 |
+
|
| 192 |
+
// Get activity timeline (hourly buckets)
|
| 193 |
+
const timeline = db.prepare(`
|
| 194 |
+
SELECT
|
| 195 |
+
(created_at / 3600) * 3600 as hour_bucket,
|
| 196 |
+
COUNT(*) as count
|
| 197 |
+
FROM activities
|
| 198 |
+
WHERE created_at > ?
|
| 199 |
+
GROUP BY hour_bucket
|
| 200 |
+
ORDER BY hour_bucket ASC
|
| 201 |
+
`).all(since) as { hour_bucket: number; count: number }[];
|
| 202 |
+
|
| 203 |
+
return NextResponse.json({
|
| 204 |
+
timeframe: `${hours} hours`,
|
| 205 |
+
activityByType: activityStats,
|
| 206 |
+
topActors: activeActors,
|
| 207 |
+
timeline: timeline.map(item => ({
|
| 208 |
+
timestamp: item.hour_bucket,
|
| 209 |
+
count: item.count,
|
| 210 |
+
hour: new Date(item.hour_bucket * 1000).toISOString()
|
| 211 |
+
}))
|
| 212 |
+
});
|
| 213 |
+
} catch (error) {
|
| 214 |
+
console.error('GET /api/activities (stats) error:', error);
|
| 215 |
+
return NextResponse.json({ error: 'Failed to fetch activity stats' }, { status: 500 });
|
| 216 |
+
}
|
| 217 |
+
}
|
src/app/api/agents/[id]/heartbeat/route.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase, db_helpers } from '@/lib/db';
|
| 3 |
+
import { requireRole } from '@/lib/auth';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/agents/[id]/heartbeat - Agent heartbeat check
|
| 7 |
+
*
|
| 8 |
+
* Checks for:
|
| 9 |
+
* - @mentions in recent comments
|
| 10 |
+
* - Assigned tasks
|
| 11 |
+
* - Recent activity feed items
|
| 12 |
+
*
|
| 13 |
+
* Returns work items or "HEARTBEAT_OK" if nothing to do
|
| 14 |
+
*/
|
| 15 |
+
export async function GET(
|
| 16 |
+
request: NextRequest,
|
| 17 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 18 |
+
) {
|
| 19 |
+
try {
|
| 20 |
+
const db = getDatabase();
|
| 21 |
+
const resolvedParams = await params;
|
| 22 |
+
const agentId = resolvedParams.id;
|
| 23 |
+
|
| 24 |
+
// Get agent by ID or name
|
| 25 |
+
let agent;
|
| 26 |
+
if (isNaN(Number(agentId))) {
|
| 27 |
+
// Lookup by name
|
| 28 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 29 |
+
} else {
|
| 30 |
+
// Lookup by ID
|
| 31 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (!agent) {
|
| 35 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const workItems: any[] = [];
|
| 39 |
+
const now = Math.floor(Date.now() / 1000);
|
| 40 |
+
const fourHoursAgo = now - (4 * 60 * 60); // Check last 4 hours
|
| 41 |
+
|
| 42 |
+
// 1. Check for @mentions in recent comments
|
| 43 |
+
const mentions = db.prepare(`
|
| 44 |
+
SELECT c.*, t.title as task_title
|
| 45 |
+
FROM comments c
|
| 46 |
+
JOIN tasks t ON c.task_id = t.id
|
| 47 |
+
WHERE c.mentions LIKE ?
|
| 48 |
+
AND c.created_at > ?
|
| 49 |
+
ORDER BY c.created_at DESC
|
| 50 |
+
LIMIT 10
|
| 51 |
+
`).all(`%"${agent.name}"%`, fourHoursAgo);
|
| 52 |
+
|
| 53 |
+
if (mentions.length > 0) {
|
| 54 |
+
workItems.push({
|
| 55 |
+
type: 'mentions',
|
| 56 |
+
count: mentions.length,
|
| 57 |
+
items: mentions.map((m: any) => ({
|
| 58 |
+
id: m.id,
|
| 59 |
+
task_title: m.task_title,
|
| 60 |
+
author: m.author,
|
| 61 |
+
content: m.content.substring(0, 100) + '...',
|
| 62 |
+
created_at: m.created_at
|
| 63 |
+
}))
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// 2. Check for assigned tasks
|
| 68 |
+
const assignedTasks = db.prepare(`
|
| 69 |
+
SELECT * FROM tasks
|
| 70 |
+
WHERE assigned_to = ?
|
| 71 |
+
AND status IN ('assigned', 'in_progress')
|
| 72 |
+
ORDER BY priority DESC, created_at ASC
|
| 73 |
+
LIMIT 10
|
| 74 |
+
`).all(agent.name);
|
| 75 |
+
|
| 76 |
+
if (assignedTasks.length > 0) {
|
| 77 |
+
workItems.push({
|
| 78 |
+
type: 'assigned_tasks',
|
| 79 |
+
count: assignedTasks.length,
|
| 80 |
+
items: assignedTasks.map((t: any) => ({
|
| 81 |
+
id: t.id,
|
| 82 |
+
title: t.title,
|
| 83 |
+
status: t.status,
|
| 84 |
+
priority: t.priority,
|
| 85 |
+
due_date: t.due_date
|
| 86 |
+
}))
|
| 87 |
+
});
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
// 3. Check for unread notifications
|
| 91 |
+
const notifications = db_helpers.getUnreadNotifications(agent.name);
|
| 92 |
+
|
| 93 |
+
if (notifications.length > 0) {
|
| 94 |
+
workItems.push({
|
| 95 |
+
type: 'notifications',
|
| 96 |
+
count: notifications.length,
|
| 97 |
+
items: notifications.slice(0, 5).map(n => ({
|
| 98 |
+
id: n.id,
|
| 99 |
+
type: n.type,
|
| 100 |
+
title: n.title,
|
| 101 |
+
message: n.message,
|
| 102 |
+
created_at: n.created_at
|
| 103 |
+
}))
|
| 104 |
+
});
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
// 4. Check for urgent activities that might need attention
|
| 108 |
+
const urgentActivities = db.prepare(`
|
| 109 |
+
SELECT * FROM activities
|
| 110 |
+
WHERE type IN ('task_created', 'task_assigned', 'high_priority_alert')
|
| 111 |
+
AND created_at > ?
|
| 112 |
+
AND description LIKE ?
|
| 113 |
+
ORDER BY created_at DESC
|
| 114 |
+
LIMIT 5
|
| 115 |
+
`).all(fourHoursAgo, `%${agent.name}%`);
|
| 116 |
+
|
| 117 |
+
if (urgentActivities.length > 0) {
|
| 118 |
+
workItems.push({
|
| 119 |
+
type: 'urgent_activities',
|
| 120 |
+
count: urgentActivities.length,
|
| 121 |
+
items: urgentActivities.map((a: any) => ({
|
| 122 |
+
id: a.id,
|
| 123 |
+
type: a.type,
|
| 124 |
+
description: a.description,
|
| 125 |
+
created_at: a.created_at
|
| 126 |
+
}))
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Update agent last_seen and status to show heartbeat activity
|
| 131 |
+
db_helpers.updateAgentStatus(agent.name, 'idle', 'Heartbeat check');
|
| 132 |
+
|
| 133 |
+
// Log heartbeat activity
|
| 134 |
+
db_helpers.logActivity(
|
| 135 |
+
'agent_heartbeat',
|
| 136 |
+
'agent',
|
| 137 |
+
agent.id,
|
| 138 |
+
agent.name,
|
| 139 |
+
`Heartbeat check completed - ${workItems.length > 0 ? `${workItems.length} work items found` : 'no work items'}`,
|
| 140 |
+
{ workItemsCount: workItems.length, workItemTypes: workItems.map(w => w.type) }
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
if (workItems.length === 0) {
|
| 144 |
+
return NextResponse.json({
|
| 145 |
+
status: 'HEARTBEAT_OK',
|
| 146 |
+
agent: agent.name,
|
| 147 |
+
checked_at: now,
|
| 148 |
+
message: 'No work items found'
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return NextResponse.json({
|
| 153 |
+
status: 'WORK_ITEMS_FOUND',
|
| 154 |
+
agent: agent.name,
|
| 155 |
+
checked_at: now,
|
| 156 |
+
work_items: workItems,
|
| 157 |
+
total_items: workItems.reduce((sum, item) => sum + item.count, 0)
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
} catch (error) {
|
| 161 |
+
console.error('GET /api/agents/[id]/heartbeat error:', error);
|
| 162 |
+
return NextResponse.json({ error: 'Failed to perform heartbeat check' }, { status: 500 });
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/**
|
| 167 |
+
* POST /api/agents/[id]/heartbeat - Manual heartbeat trigger
|
| 168 |
+
* Allows manual heartbeat checks from UI or scripts
|
| 169 |
+
*/
|
| 170 |
+
export async function POST(
|
| 171 |
+
request: NextRequest,
|
| 172 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 173 |
+
) {
|
| 174 |
+
const auth = requireRole(request, 'operator');
|
| 175 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 176 |
+
|
| 177 |
+
// Reuse GET logic for manual triggers
|
| 178 |
+
return GET(request, { params });
|
| 179 |
+
}
|
src/app/api/agents/[id]/memory/route.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase, db_helpers } from '@/lib/db';
|
| 3 |
+
import { requireRole } from '@/lib/auth';
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/agents/[id]/memory - Get agent's working memory
|
| 7 |
+
*
|
| 8 |
+
* Working memory is stored as WORKING.md content in the database
|
| 9 |
+
* Each agent has their own working memory space for temporary notes
|
| 10 |
+
*/
|
| 11 |
+
export async function GET(
|
| 12 |
+
request: NextRequest,
|
| 13 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 14 |
+
) {
|
| 15 |
+
const auth = requireRole(request, 'viewer');
|
| 16 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 17 |
+
|
| 18 |
+
try {
|
| 19 |
+
const db = getDatabase();
|
| 20 |
+
const resolvedParams = await params;
|
| 21 |
+
const agentId = resolvedParams.id;
|
| 22 |
+
|
| 23 |
+
// Get agent by ID or name
|
| 24 |
+
let agent;
|
| 25 |
+
if (isNaN(Number(agentId))) {
|
| 26 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 27 |
+
} else {
|
| 28 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if (!agent) {
|
| 32 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Check if agent has a working_memory column, if not create it
|
| 36 |
+
const columns = db.prepare("PRAGMA table_info(agents)").all();
|
| 37 |
+
const hasWorkingMemory = columns.some((col: any) => col.name === 'working_memory');
|
| 38 |
+
|
| 39 |
+
if (!hasWorkingMemory) {
|
| 40 |
+
// Add working_memory column to agents table
|
| 41 |
+
db.exec("ALTER TABLE agents ADD COLUMN working_memory TEXT DEFAULT ''");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Get working memory content
|
| 45 |
+
const memoryStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
|
| 46 |
+
const result = memoryStmt.get(agentId) as any;
|
| 47 |
+
|
| 48 |
+
const workingMemory = result?.working_memory || '';
|
| 49 |
+
|
| 50 |
+
return NextResponse.json({
|
| 51 |
+
agent: {
|
| 52 |
+
id: agent.id,
|
| 53 |
+
name: agent.name,
|
| 54 |
+
role: agent.role
|
| 55 |
+
},
|
| 56 |
+
working_memory: workingMemory,
|
| 57 |
+
updated_at: agent.updated_at,
|
| 58 |
+
size: workingMemory.length
|
| 59 |
+
});
|
| 60 |
+
} catch (error) {
|
| 61 |
+
console.error('GET /api/agents/[id]/memory error:', error);
|
| 62 |
+
return NextResponse.json({ error: 'Failed to fetch working memory' }, { status: 500 });
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* PUT /api/agents/[id]/memory - Update agent's working memory
|
| 68 |
+
*/
|
| 69 |
+
export async function PUT(
|
| 70 |
+
request: NextRequest,
|
| 71 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 72 |
+
) {
|
| 73 |
+
const auth = requireRole(request, 'operator');
|
| 74 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const db = getDatabase();
|
| 78 |
+
const resolvedParams = await params;
|
| 79 |
+
const agentId = resolvedParams.id;
|
| 80 |
+
const body = await request.json();
|
| 81 |
+
const { working_memory, append } = body;
|
| 82 |
+
|
| 83 |
+
// Get agent by ID or name
|
| 84 |
+
let agent;
|
| 85 |
+
if (isNaN(Number(agentId))) {
|
| 86 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 87 |
+
} else {
|
| 88 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (!agent) {
|
| 92 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Check if agent has a working_memory column, if not create it
|
| 96 |
+
const columns = db.prepare("PRAGMA table_info(agents)").all();
|
| 97 |
+
const hasWorkingMemory = columns.some((col: any) => col.name === 'working_memory');
|
| 98 |
+
|
| 99 |
+
if (!hasWorkingMemory) {
|
| 100 |
+
db.exec("ALTER TABLE agents ADD COLUMN working_memory TEXT DEFAULT ''");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
let newContent = working_memory || '';
|
| 104 |
+
|
| 105 |
+
// Handle append mode
|
| 106 |
+
if (append) {
|
| 107 |
+
const currentStmt = db.prepare(`SELECT working_memory FROM agents WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?`);
|
| 108 |
+
const current = currentStmt.get(agentId) as any;
|
| 109 |
+
const currentContent = current?.working_memory || '';
|
| 110 |
+
|
| 111 |
+
// Add timestamp and append
|
| 112 |
+
const timestamp = new Date().toISOString();
|
| 113 |
+
newContent = currentContent + (currentContent ? '\n\n' : '') +
|
| 114 |
+
`## ${timestamp}\n${working_memory}`;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const now = Math.floor(Date.now() / 1000);
|
| 118 |
+
|
| 119 |
+
// Update working memory
|
| 120 |
+
const updateStmt = db.prepare(`
|
| 121 |
+
UPDATE agents
|
| 122 |
+
SET working_memory = ?, updated_at = ?
|
| 123 |
+
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
|
| 124 |
+
`);
|
| 125 |
+
|
| 126 |
+
updateStmt.run(newContent, now, agentId);
|
| 127 |
+
|
| 128 |
+
// Log activity
|
| 129 |
+
db_helpers.logActivity(
|
| 130 |
+
'agent_memory_updated',
|
| 131 |
+
'agent',
|
| 132 |
+
agent.id,
|
| 133 |
+
agent.name,
|
| 134 |
+
`Working memory ${append ? 'appended' : 'updated'} for agent ${agent.name}`,
|
| 135 |
+
{
|
| 136 |
+
content_length: newContent.length,
|
| 137 |
+
append_mode: append || false,
|
| 138 |
+
timestamp: now
|
| 139 |
+
}
|
| 140 |
+
);
|
| 141 |
+
|
| 142 |
+
return NextResponse.json({
|
| 143 |
+
success: true,
|
| 144 |
+
message: `Working memory ${append ? 'appended' : 'updated'} for ${agent.name}`,
|
| 145 |
+
working_memory: newContent,
|
| 146 |
+
updated_at: now,
|
| 147 |
+
size: newContent.length
|
| 148 |
+
});
|
| 149 |
+
} catch (error) {
|
| 150 |
+
console.error('PUT /api/agents/[id]/memory error:', error);
|
| 151 |
+
return NextResponse.json({ error: 'Failed to update working memory' }, { status: 500 });
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* DELETE /api/agents/[id]/memory - Clear agent's working memory
|
| 157 |
+
*/
|
| 158 |
+
export async function DELETE(
|
| 159 |
+
request: NextRequest,
|
| 160 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 161 |
+
) {
|
| 162 |
+
const auth = requireRole(request, 'operator');
|
| 163 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 164 |
+
|
| 165 |
+
try {
|
| 166 |
+
const db = getDatabase();
|
| 167 |
+
const resolvedParams = await params;
|
| 168 |
+
const agentId = resolvedParams.id;
|
| 169 |
+
|
| 170 |
+
// Get agent by ID or name
|
| 171 |
+
let agent;
|
| 172 |
+
if (isNaN(Number(agentId))) {
|
| 173 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 174 |
+
} else {
|
| 175 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
if (!agent) {
|
| 179 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const now = Math.floor(Date.now() / 1000);
|
| 183 |
+
|
| 184 |
+
// Clear working memory
|
| 185 |
+
const updateStmt = db.prepare(`
|
| 186 |
+
UPDATE agents
|
| 187 |
+
SET working_memory = '', updated_at = ?
|
| 188 |
+
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
|
| 189 |
+
`);
|
| 190 |
+
|
| 191 |
+
updateStmt.run(now, agentId);
|
| 192 |
+
|
| 193 |
+
// Log activity
|
| 194 |
+
db_helpers.logActivity(
|
| 195 |
+
'agent_memory_cleared',
|
| 196 |
+
'agent',
|
| 197 |
+
agent.id,
|
| 198 |
+
agent.name,
|
| 199 |
+
`Working memory cleared for agent ${agent.name}`,
|
| 200 |
+
{ timestamp: now }
|
| 201 |
+
);
|
| 202 |
+
|
| 203 |
+
return NextResponse.json({
|
| 204 |
+
success: true,
|
| 205 |
+
message: `Working memory cleared for ${agent.name}`,
|
| 206 |
+
working_memory: '',
|
| 207 |
+
updated_at: now
|
| 208 |
+
});
|
| 209 |
+
} catch (error) {
|
| 210 |
+
console.error('DELETE /api/agents/[id]/memory error:', error);
|
| 211 |
+
return NextResponse.json({ error: 'Failed to clear working memory' }, { status: 500 });
|
| 212 |
+
}
|
| 213 |
+
}
|
src/app/api/agents/[id]/route.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase, db_helpers, logAuditEvent } from '@/lib/db'
|
| 3 |
+
import { getUserFromRequest, requireRole } from '@/lib/auth'
|
| 4 |
+
import { writeAgentToConfig } from '@/lib/agent-sync'
|
| 5 |
+
import { eventBus } from '@/lib/event-bus'
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* GET /api/agents/[id] - Get a single agent by ID or name
|
| 9 |
+
*/
|
| 10 |
+
export async function GET(
|
| 11 |
+
request: NextRequest,
|
| 12 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 13 |
+
) {
|
| 14 |
+
try {
|
| 15 |
+
const db = getDatabase()
|
| 16 |
+
const { id } = await params
|
| 17 |
+
|
| 18 |
+
let agent
|
| 19 |
+
if (isNaN(Number(id))) {
|
| 20 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id)
|
| 21 |
+
} else {
|
| 22 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id))
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (!agent) {
|
| 26 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const parsed = {
|
| 30 |
+
...(agent as any),
|
| 31 |
+
config: (agent as any).config ? JSON.parse((agent as any).config) : {},
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return NextResponse.json({ agent: parsed })
|
| 35 |
+
} catch (error) {
|
| 36 |
+
console.error('GET /api/agents/[id] error:', error)
|
| 37 |
+
return NextResponse.json({ error: 'Failed to fetch agent' }, { status: 500 })
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* PUT /api/agents/[id] - Update agent config with optional gateway write-back
|
| 43 |
+
*
|
| 44 |
+
* Body: {
|
| 45 |
+
* role?: string
|
| 46 |
+
* gateway_config?: object - OpenClaw agent config fields to update
|
| 47 |
+
* write_to_gateway?: boolean - If true, also write to openclaw.json
|
| 48 |
+
* }
|
| 49 |
+
*/
|
| 50 |
+
export async function PUT(
|
| 51 |
+
request: NextRequest,
|
| 52 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 53 |
+
) {
|
| 54 |
+
const auth = requireRole(request, 'operator')
|
| 55 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 56 |
+
|
| 57 |
+
try {
|
| 58 |
+
const db = getDatabase()
|
| 59 |
+
const { id } = await params
|
| 60 |
+
const body = await request.json()
|
| 61 |
+
const { role, gateway_config, write_to_gateway } = body
|
| 62 |
+
|
| 63 |
+
let agent
|
| 64 |
+
if (isNaN(Number(id))) {
|
| 65 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
|
| 66 |
+
} else {
|
| 67 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if (!agent) {
|
| 71 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const now = Math.floor(Date.now() / 1000)
|
| 75 |
+
const existingConfig = agent.config ? JSON.parse(agent.config) : {}
|
| 76 |
+
|
| 77 |
+
// Merge gateway_config into existing config
|
| 78 |
+
let newConfig = existingConfig
|
| 79 |
+
if (gateway_config && typeof gateway_config === 'object') {
|
| 80 |
+
newConfig = { ...existingConfig, ...gateway_config }
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Build update
|
| 84 |
+
const fields: string[] = ['updated_at = ?']
|
| 85 |
+
const values: any[] = [now]
|
| 86 |
+
|
| 87 |
+
if (role !== undefined) {
|
| 88 |
+
fields.push('role = ?')
|
| 89 |
+
values.push(role)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
if (gateway_config) {
|
| 93 |
+
fields.push('config = ?')
|
| 94 |
+
values.push(JSON.stringify(newConfig))
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
values.push(agent.id)
|
| 98 |
+
db.prepare(`UPDATE agents SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
| 99 |
+
|
| 100 |
+
// Write back to openclaw.json if requested
|
| 101 |
+
if (write_to_gateway && gateway_config) {
|
| 102 |
+
try {
|
| 103 |
+
const openclawId = existingConfig.openclawId || agent.name.toLowerCase().replace(/\s+/g, '-')
|
| 104 |
+
|
| 105 |
+
// Build the config to write back (full OpenClaw format)
|
| 106 |
+
const writeBack: any = { id: openclawId }
|
| 107 |
+
if (gateway_config.model) writeBack.model = gateway_config.model
|
| 108 |
+
if (gateway_config.identity) writeBack.identity = gateway_config.identity
|
| 109 |
+
if (gateway_config.sandbox) writeBack.sandbox = gateway_config.sandbox
|
| 110 |
+
if (gateway_config.tools) writeBack.tools = gateway_config.tools
|
| 111 |
+
if (gateway_config.subagents) writeBack.subagents = gateway_config.subagents
|
| 112 |
+
if (gateway_config.memorySearch) writeBack.memorySearch = gateway_config.memorySearch
|
| 113 |
+
|
| 114 |
+
await writeAgentToConfig(writeBack)
|
| 115 |
+
|
| 116 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 117 |
+
logAuditEvent({
|
| 118 |
+
action: 'agent_config_writeback',
|
| 119 |
+
actor: auth.user.username,
|
| 120 |
+
actor_id: auth.user.id,
|
| 121 |
+
target_type: 'agent',
|
| 122 |
+
target_id: agent.id,
|
| 123 |
+
detail: { agent_name: agent.name, openclaw_id: openclawId, fields: Object.keys(gateway_config) },
|
| 124 |
+
ip_address: ipAddress,
|
| 125 |
+
})
|
| 126 |
+
} catch (err: any) {
|
| 127 |
+
// Config update succeeded in DB but gateway write failed
|
| 128 |
+
return NextResponse.json({
|
| 129 |
+
warning: `Agent updated in MC but gateway write failed: ${err.message}`,
|
| 130 |
+
agent: { ...agent, config: newConfig, role: role || agent.role, updated_at: now },
|
| 131 |
+
})
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Log activity
|
| 136 |
+
db_helpers.logActivity(
|
| 137 |
+
'agent_config_updated',
|
| 138 |
+
'agent',
|
| 139 |
+
agent.id,
|
| 140 |
+
auth.user.username,
|
| 141 |
+
`Config updated for agent ${agent.name}${write_to_gateway ? ' (+ gateway)' : ''}`,
|
| 142 |
+
{ fields: Object.keys(gateway_config || {}), write_to_gateway }
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
// Broadcast update
|
| 146 |
+
eventBus.broadcast('agent.updated', {
|
| 147 |
+
id: agent.id,
|
| 148 |
+
name: agent.name,
|
| 149 |
+
config: newConfig,
|
| 150 |
+
updated_at: now,
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
return NextResponse.json({
|
| 154 |
+
success: true,
|
| 155 |
+
agent: { ...agent, config: newConfig, role: role || agent.role, updated_at: now },
|
| 156 |
+
})
|
| 157 |
+
} catch (error: any) {
|
| 158 |
+
console.error('PUT /api/agents/[id] error:', error)
|
| 159 |
+
return NextResponse.json({ error: error.message || 'Failed to update agent' }, { status: 500 })
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* DELETE /api/agents/[id] - Delete an agent
|
| 165 |
+
*/
|
| 166 |
+
export async function DELETE(
|
| 167 |
+
request: NextRequest,
|
| 168 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 169 |
+
) {
|
| 170 |
+
const auth = requireRole(request, 'admin')
|
| 171 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 172 |
+
|
| 173 |
+
try {
|
| 174 |
+
const db = getDatabase()
|
| 175 |
+
const { id } = await params
|
| 176 |
+
|
| 177 |
+
let agent
|
| 178 |
+
if (isNaN(Number(id))) {
|
| 179 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(id) as any
|
| 180 |
+
} else {
|
| 181 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(id)) as any
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
if (!agent) {
|
| 185 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
db.prepare('DELETE FROM agents WHERE id = ?').run(agent.id)
|
| 189 |
+
|
| 190 |
+
db_helpers.logActivity(
|
| 191 |
+
'agent_deleted',
|
| 192 |
+
'agent',
|
| 193 |
+
agent.id,
|
| 194 |
+
auth.user.username,
|
| 195 |
+
`Deleted agent: ${agent.name}`,
|
| 196 |
+
{ name: agent.name, role: agent.role }
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
eventBus.broadcast('agent.deleted', { id: agent.id, name: agent.name })
|
| 200 |
+
|
| 201 |
+
return NextResponse.json({ success: true, deleted: agent.name })
|
| 202 |
+
} catch (error) {
|
| 203 |
+
console.error('DELETE /api/agents/[id] error:', error)
|
| 204 |
+
return NextResponse.json({ error: 'Failed to delete agent' }, { status: 500 })
|
| 205 |
+
}
|
| 206 |
+
}
|
src/app/api/agents/[id]/soul/route.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase, db_helpers } from '@/lib/db';
|
| 3 |
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
| 4 |
+
import { join } from 'path';
|
| 5 |
+
import { config } from '@/lib/config';
|
| 6 |
+
import { resolveWithin } from '@/lib/paths';
|
| 7 |
+
import { getUserFromRequest, requireRole } from '@/lib/auth';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /api/agents/[id]/soul - Get agent's SOUL content
|
| 11 |
+
*/
|
| 12 |
+
export async function GET(
|
| 13 |
+
request: NextRequest,
|
| 14 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 15 |
+
) {
|
| 16 |
+
try {
|
| 17 |
+
const db = getDatabase();
|
| 18 |
+
const resolvedParams = await params;
|
| 19 |
+
const agentId = resolvedParams.id;
|
| 20 |
+
|
| 21 |
+
// Get agent by ID or name
|
| 22 |
+
let agent;
|
| 23 |
+
if (isNaN(Number(agentId))) {
|
| 24 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 25 |
+
} else {
|
| 26 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (!agent) {
|
| 30 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const templatesPath = config.soulTemplatesDir;
|
| 34 |
+
let availableTemplates: string[] = [];
|
| 35 |
+
|
| 36 |
+
try {
|
| 37 |
+
if (templatesPath && existsSync(templatesPath)) {
|
| 38 |
+
const files = readdirSync(templatesPath);
|
| 39 |
+
availableTemplates = files
|
| 40 |
+
.filter(file => file.endsWith('.md'))
|
| 41 |
+
.map(file => file.replace('.md', ''));
|
| 42 |
+
}
|
| 43 |
+
} catch (error) {
|
| 44 |
+
console.warn('Could not read soul templates directory:', error);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return NextResponse.json({
|
| 48 |
+
agent: {
|
| 49 |
+
id: agent.id,
|
| 50 |
+
name: agent.name,
|
| 51 |
+
role: agent.role
|
| 52 |
+
},
|
| 53 |
+
soul_content: agent.soul_content || '',
|
| 54 |
+
available_templates: availableTemplates,
|
| 55 |
+
updated_at: agent.updated_at
|
| 56 |
+
});
|
| 57 |
+
} catch (error) {
|
| 58 |
+
console.error('GET /api/agents/[id]/soul error:', error);
|
| 59 |
+
return NextResponse.json({ error: 'Failed to fetch SOUL content' }, { status: 500 });
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* PUT /api/agents/[id]/soul - Update agent's SOUL content
|
| 65 |
+
*/
|
| 66 |
+
export async function PUT(
|
| 67 |
+
request: NextRequest,
|
| 68 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 69 |
+
) {
|
| 70 |
+
const auth = requireRole(request, 'operator');
|
| 71 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
const db = getDatabase();
|
| 75 |
+
const resolvedParams = await params;
|
| 76 |
+
const agentId = resolvedParams.id;
|
| 77 |
+
const body = await request.json();
|
| 78 |
+
const { soul_content, template_name } = body;
|
| 79 |
+
|
| 80 |
+
// Get agent by ID or name
|
| 81 |
+
let agent;
|
| 82 |
+
if (isNaN(Number(agentId))) {
|
| 83 |
+
agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId);
|
| 84 |
+
} else {
|
| 85 |
+
agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
if (!agent) {
|
| 89 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
let newSoulContent = soul_content;
|
| 93 |
+
|
| 94 |
+
// If template_name is provided, load from template
|
| 95 |
+
if (template_name) {
|
| 96 |
+
if (!config.soulTemplatesDir) {
|
| 97 |
+
return NextResponse.json({ error: 'Templates directory not configured' }, { status: 500 });
|
| 98 |
+
}
|
| 99 |
+
let templatePath: string;
|
| 100 |
+
try {
|
| 101 |
+
templatePath = resolveWithin(config.soulTemplatesDir, `${template_name}.md`);
|
| 102 |
+
} catch (pathError) {
|
| 103 |
+
return NextResponse.json({ error: 'Invalid template name' }, { status: 400 });
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
try {
|
| 107 |
+
if (existsSync(templatePath)) {
|
| 108 |
+
const templateContent = readFileSync(templatePath, 'utf8');
|
| 109 |
+
// Replace placeholders with agent info
|
| 110 |
+
newSoulContent = templateContent
|
| 111 |
+
.replace(/{{AGENT_NAME}}/g, agent.name)
|
| 112 |
+
.replace(/{{AGENT_ROLE}}/g, agent.role)
|
| 113 |
+
.replace(/{{TIMESTAMP}}/g, new Date().toISOString());
|
| 114 |
+
} else {
|
| 115 |
+
return NextResponse.json({ error: 'Template not found' }, { status: 404 });
|
| 116 |
+
}
|
| 117 |
+
} catch (error) {
|
| 118 |
+
console.error('Error loading soul template:', error);
|
| 119 |
+
return NextResponse.json({ error: 'Failed to load template' }, { status: 500 });
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const now = Math.floor(Date.now() / 1000);
|
| 124 |
+
|
| 125 |
+
// Update SOUL content
|
| 126 |
+
const updateStmt = db.prepare(`
|
| 127 |
+
UPDATE agents
|
| 128 |
+
SET soul_content = ?, updated_at = ?
|
| 129 |
+
WHERE ${isNaN(Number(agentId)) ? 'name' : 'id'} = ?
|
| 130 |
+
`);
|
| 131 |
+
|
| 132 |
+
updateStmt.run(newSoulContent, now, agentId);
|
| 133 |
+
|
| 134 |
+
// Log activity
|
| 135 |
+
db_helpers.logActivity(
|
| 136 |
+
'agent_soul_updated',
|
| 137 |
+
'agent',
|
| 138 |
+
agent.id,
|
| 139 |
+
getUserFromRequest(request)?.username || 'system',
|
| 140 |
+
`SOUL content updated for agent ${agent.name}${template_name ? ` using template: ${template_name}` : ''}`,
|
| 141 |
+
{
|
| 142 |
+
template_used: template_name || null,
|
| 143 |
+
content_length: newSoulContent ? newSoulContent.length : 0,
|
| 144 |
+
previous_content_length: agent.soul_content ? agent.soul_content.length : 0
|
| 145 |
+
}
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
return NextResponse.json({
|
| 149 |
+
success: true,
|
| 150 |
+
message: `SOUL content updated for ${agent.name}`,
|
| 151 |
+
soul_content: newSoulContent,
|
| 152 |
+
updated_at: now
|
| 153 |
+
});
|
| 154 |
+
} catch (error) {
|
| 155 |
+
console.error('PUT /api/agents/[id]/soul error:', error);
|
| 156 |
+
return NextResponse.json({ error: 'Failed to update SOUL content' }, { status: 500 });
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* GET /api/agents/[id]/soul/templates - Get available SOUL templates
|
| 162 |
+
* Also handles loading specific template content
|
| 163 |
+
*/
|
| 164 |
+
export async function PATCH(
|
| 165 |
+
request: NextRequest,
|
| 166 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 167 |
+
) {
|
| 168 |
+
try {
|
| 169 |
+
const { searchParams } = new URL(request.url);
|
| 170 |
+
const templateName = searchParams.get('template');
|
| 171 |
+
|
| 172 |
+
const templatesPath = config.soulTemplatesDir;
|
| 173 |
+
|
| 174 |
+
if (!templatesPath || !existsSync(templatesPath)) {
|
| 175 |
+
return NextResponse.json({
|
| 176 |
+
templates: [],
|
| 177 |
+
message: 'Templates directory not found'
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (templateName) {
|
| 182 |
+
// Get specific template content
|
| 183 |
+
let templatePath: string;
|
| 184 |
+
try {
|
| 185 |
+
templatePath = resolveWithin(templatesPath, `${templateName}.md`);
|
| 186 |
+
} catch (pathError) {
|
| 187 |
+
return NextResponse.json({ error: 'Invalid template name' }, { status: 400 });
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
if (!existsSync(templatePath)) {
|
| 191 |
+
return NextResponse.json({ error: 'Template not found' }, { status: 404 });
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
const templateContent = readFileSync(templatePath, 'utf8');
|
| 195 |
+
|
| 196 |
+
return NextResponse.json({
|
| 197 |
+
template_name: templateName,
|
| 198 |
+
content: templateContent
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// List all available templates
|
| 203 |
+
const files = readdirSync(templatesPath);
|
| 204 |
+
const templates = files
|
| 205 |
+
.filter(file => file.endsWith('.md'))
|
| 206 |
+
.map(file => {
|
| 207 |
+
const name = file.replace('.md', '');
|
| 208 |
+
const templatePath = join(templatesPath, file);
|
| 209 |
+
const content = readFileSync(templatePath, 'utf8');
|
| 210 |
+
|
| 211 |
+
// Extract first line as description
|
| 212 |
+
const firstLine = content.split('\n')[0];
|
| 213 |
+
const description = firstLine.startsWith('#')
|
| 214 |
+
? firstLine.replace(/^#+\s*/, '')
|
| 215 |
+
: `${name} template`;
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
name,
|
| 219 |
+
description,
|
| 220 |
+
size: content.length
|
| 221 |
+
};
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
return NextResponse.json({ templates });
|
| 225 |
+
} catch (error) {
|
| 226 |
+
console.error('PATCH /api/agents/[id]/soul error:', error);
|
| 227 |
+
return NextResponse.json({ error: 'Failed to fetch templates' }, { status: 500 });
|
| 228 |
+
}
|
| 229 |
+
}
|
src/app/api/agents/[id]/wake/route.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase, db_helpers } from '@/lib/db'
|
| 3 |
+
import { runOpenClaw } from '@/lib/command'
|
| 4 |
+
import { requireRole } from '@/lib/auth'
|
| 5 |
+
|
| 6 |
+
export async function POST(
|
| 7 |
+
request: NextRequest,
|
| 8 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 9 |
+
) {
|
| 10 |
+
const auth = requireRole(request, 'operator')
|
| 11 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const resolvedParams = await params
|
| 15 |
+
const agentId = resolvedParams.id
|
| 16 |
+
const body = await request.json().catch(() => ({}))
|
| 17 |
+
const customMessage =
|
| 18 |
+
typeof body?.message === 'string' ? body.message.trim() : ''
|
| 19 |
+
|
| 20 |
+
const db = getDatabase()
|
| 21 |
+
const agent: any = isNaN(Number(agentId))
|
| 22 |
+
? db.prepare('SELECT * FROM agents WHERE name = ?').get(agentId)
|
| 23 |
+
: db.prepare('SELECT * FROM agents WHERE id = ?').get(Number(agentId))
|
| 24 |
+
|
| 25 |
+
if (!agent) {
|
| 26 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 })
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if (!agent.session_key) {
|
| 30 |
+
return NextResponse.json(
|
| 31 |
+
{ error: 'Agent has no session key configured' },
|
| 32 |
+
{ status: 400 }
|
| 33 |
+
)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const message =
|
| 37 |
+
customMessage ||
|
| 38 |
+
`Wake up check-in for ${agent.name}. Please review assigned tasks and notifications.`
|
| 39 |
+
|
| 40 |
+
const { stdout, stderr } = await runOpenClaw(
|
| 41 |
+
['gateway', 'sessions_send', '--session', agent.session_key, '--message', message],
|
| 42 |
+
{ timeoutMs: 10000 }
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if (stderr && stderr.includes('error')) {
|
| 46 |
+
return NextResponse.json(
|
| 47 |
+
{ error: stderr.trim() || 'Failed to wake agent' },
|
| 48 |
+
{ status: 500 }
|
| 49 |
+
)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
db_helpers.updateAgentStatus(agent.name, 'idle', 'Manual wake')
|
| 53 |
+
|
| 54 |
+
return NextResponse.json({
|
| 55 |
+
success: true,
|
| 56 |
+
session_key: agent.session_key,
|
| 57 |
+
stdout: stdout.trim()
|
| 58 |
+
})
|
| 59 |
+
} catch (error) {
|
| 60 |
+
console.error('POST /api/agents/[id]/wake error:', error)
|
| 61 |
+
return NextResponse.json({ error: 'Failed to wake agent' }, { status: 500 })
|
| 62 |
+
}
|
| 63 |
+
}
|
src/app/api/agents/comms/route.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { getDatabase, Message } from "@/lib/db"
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* GET /api/agents/comms - Inter-agent communication stats and timeline
|
| 6 |
+
* Query params: limit, offset, since, agent
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
try {
|
| 10 |
+
const db = getDatabase()
|
| 11 |
+
const { searchParams } = new URL(request.url)
|
| 12 |
+
|
| 13 |
+
const limit = parseInt(searchParams.get("limit") || "100")
|
| 14 |
+
const offset = parseInt(searchParams.get("offset") || "0")
|
| 15 |
+
const since = searchParams.get("since")
|
| 16 |
+
const agent = searchParams.get("agent")
|
| 17 |
+
|
| 18 |
+
// Filter out human/system messages - only agent-to-agent
|
| 19 |
+
const humanNames = ["human", "system", "operator"]
|
| 20 |
+
const humanPlaceholders = humanNames.map(() => "?").join(",")
|
| 21 |
+
|
| 22 |
+
// 1. Get inter-agent messages
|
| 23 |
+
let messagesQuery = `
|
| 24 |
+
SELECT * FROM messages
|
| 25 |
+
WHERE to_agent IS NOT NULL
|
| 26 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 27 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 28 |
+
`
|
| 29 |
+
const messagesParams: any[] = [...humanNames, ...humanNames]
|
| 30 |
+
|
| 31 |
+
if (since) {
|
| 32 |
+
messagesQuery += " AND created_at > ?"
|
| 33 |
+
messagesParams.push(parseInt(since))
|
| 34 |
+
}
|
| 35 |
+
if (agent) {
|
| 36 |
+
messagesQuery += " AND (from_agent = ? OR to_agent = ?)"
|
| 37 |
+
messagesParams.push(agent, agent)
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Deterministic chronological ordering prevents visual jumps in UI
|
| 41 |
+
messagesQuery += " ORDER BY created_at ASC, id ASC LIMIT ? OFFSET ?"
|
| 42 |
+
messagesParams.push(limit, offset)
|
| 43 |
+
|
| 44 |
+
const messages = db.prepare(messagesQuery).all(...messagesParams) as Message[]
|
| 45 |
+
|
| 46 |
+
// 2. Communication graph edges
|
| 47 |
+
let graphQuery = `
|
| 48 |
+
SELECT
|
| 49 |
+
from_agent, to_agent,
|
| 50 |
+
COUNT(*) as message_count,
|
| 51 |
+
MAX(created_at) as last_message_at
|
| 52 |
+
FROM messages
|
| 53 |
+
WHERE to_agent IS NOT NULL
|
| 54 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 55 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 56 |
+
`
|
| 57 |
+
const graphParams: any[] = [...humanNames, ...humanNames]
|
| 58 |
+
if (since) {
|
| 59 |
+
graphQuery += " AND created_at > ?"
|
| 60 |
+
graphParams.push(parseInt(since))
|
| 61 |
+
}
|
| 62 |
+
graphQuery += " GROUP BY from_agent, to_agent ORDER BY message_count DESC"
|
| 63 |
+
|
| 64 |
+
const edges = db.prepare(graphQuery).all(...graphParams)
|
| 65 |
+
|
| 66 |
+
// 3. Per-agent sent/received stats
|
| 67 |
+
const statsQuery = `
|
| 68 |
+
SELECT agent, SUM(sent) as sent, SUM(received) as received FROM (
|
| 69 |
+
SELECT from_agent as agent, COUNT(*) as sent, 0 as received
|
| 70 |
+
FROM messages WHERE to_agent IS NOT NULL
|
| 71 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 72 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 73 |
+
GROUP BY from_agent
|
| 74 |
+
UNION ALL
|
| 75 |
+
SELECT to_agent as agent, 0 as sent, COUNT(*) as received
|
| 76 |
+
FROM messages WHERE to_agent IS NOT NULL
|
| 77 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 78 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 79 |
+
GROUP BY to_agent
|
| 80 |
+
) GROUP BY agent ORDER BY (sent + received) DESC
|
| 81 |
+
`
|
| 82 |
+
const statsParams = [...humanNames, ...humanNames, ...humanNames, ...humanNames]
|
| 83 |
+
const agentStats = db.prepare(statsQuery).all(...statsParams)
|
| 84 |
+
|
| 85 |
+
// 4. Total count
|
| 86 |
+
let countQuery = `
|
| 87 |
+
SELECT COUNT(*) as total FROM messages
|
| 88 |
+
WHERE to_agent IS NOT NULL
|
| 89 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 90 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 91 |
+
`
|
| 92 |
+
const countParams: any[] = [...humanNames, ...humanNames]
|
| 93 |
+
if (since) {
|
| 94 |
+
countQuery += " AND created_at > ?"
|
| 95 |
+
countParams.push(parseInt(since))
|
| 96 |
+
}
|
| 97 |
+
if (agent) {
|
| 98 |
+
countQuery += " AND (from_agent = ? OR to_agent = ?)"
|
| 99 |
+
countParams.push(agent, agent)
|
| 100 |
+
}
|
| 101 |
+
const { total } = db.prepare(countQuery).get(...countParams) as { total: number }
|
| 102 |
+
|
| 103 |
+
let seededCountQuery = `
|
| 104 |
+
SELECT COUNT(*) as seeded FROM messages
|
| 105 |
+
WHERE to_agent IS NOT NULL
|
| 106 |
+
AND from_agent NOT IN (${humanPlaceholders})
|
| 107 |
+
AND to_agent NOT IN (${humanPlaceholders})
|
| 108 |
+
AND conversation_id LIKE ?
|
| 109 |
+
`
|
| 110 |
+
const seededParams: any[] = [...humanNames, ...humanNames, "conv-multi-%"]
|
| 111 |
+
if (since) {
|
| 112 |
+
seededCountQuery += " AND created_at > ?"
|
| 113 |
+
seededParams.push(parseInt(since))
|
| 114 |
+
}
|
| 115 |
+
if (agent) {
|
| 116 |
+
seededCountQuery += " AND (from_agent = ? OR to_agent = ?)"
|
| 117 |
+
seededParams.push(agent, agent)
|
| 118 |
+
}
|
| 119 |
+
const { seeded } = db.prepare(seededCountQuery).get(...seededParams) as { seeded: number }
|
| 120 |
+
|
| 121 |
+
const seededCount = seeded || 0
|
| 122 |
+
const liveCount = Math.max(0, total - seededCount)
|
| 123 |
+
const source =
|
| 124 |
+
total === 0 ? "empty" :
|
| 125 |
+
liveCount === 0 ? "seeded" :
|
| 126 |
+
seededCount === 0 ? "live" :
|
| 127 |
+
"mixed"
|
| 128 |
+
|
| 129 |
+
const parsed = messages.map((msg) => {
|
| 130 |
+
let parsedMetadata: any = null
|
| 131 |
+
if (msg.metadata) {
|
| 132 |
+
try {
|
| 133 |
+
parsedMetadata = JSON.parse(msg.metadata)
|
| 134 |
+
} catch {
|
| 135 |
+
// Keep endpoint resilient even if one legacy row has bad metadata
|
| 136 |
+
parsedMetadata = null
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
return {
|
| 140 |
+
...msg,
|
| 141 |
+
metadata: parsedMetadata,
|
| 142 |
+
}
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
+
return NextResponse.json({
|
| 146 |
+
messages: parsed,
|
| 147 |
+
total,
|
| 148 |
+
graph: { edges, agentStats },
|
| 149 |
+
source: { mode: source, seededCount, liveCount },
|
| 150 |
+
})
|
| 151 |
+
} catch (error) {
|
| 152 |
+
console.error("GET /api/agents/comms error:", error)
|
| 153 |
+
return NextResponse.json({ error: "Failed to fetch agent communications" }, { status: 500 })
|
| 154 |
+
}
|
| 155 |
+
}
|
src/app/api/agents/message/route.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase, db_helpers } from '@/lib/db'
|
| 3 |
+
import { runOpenClaw } from '@/lib/command'
|
| 4 |
+
import { requireRole } from '@/lib/auth'
|
| 5 |
+
|
| 6 |
+
export async function POST(request: NextRequest) {
|
| 7 |
+
const auth = requireRole(request, 'operator')
|
| 8 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 9 |
+
|
| 10 |
+
try {
|
| 11 |
+
const body = await request.json()
|
| 12 |
+
const from = (body.from || 'system') as string
|
| 13 |
+
const to = (body.to || '').trim()
|
| 14 |
+
const message = (body.message || '').trim()
|
| 15 |
+
|
| 16 |
+
if (!to || !message) {
|
| 17 |
+
return NextResponse.json(
|
| 18 |
+
{ error: 'Both "to" and "message" are required' },
|
| 19 |
+
{ status: 400 }
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const db = getDatabase()
|
| 24 |
+
const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(to) as any
|
| 25 |
+
if (!agent) {
|
| 26 |
+
return NextResponse.json({ error: 'Recipient agent not found' }, { status: 404 })
|
| 27 |
+
}
|
| 28 |
+
if (!agent.session_key) {
|
| 29 |
+
return NextResponse.json(
|
| 30 |
+
{ error: 'Recipient agent has no session key configured' },
|
| 31 |
+
{ status: 400 }
|
| 32 |
+
)
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
await runOpenClaw(
|
| 36 |
+
[
|
| 37 |
+
'gateway',
|
| 38 |
+
'sessions_send',
|
| 39 |
+
'--session',
|
| 40 |
+
agent.session_key,
|
| 41 |
+
'--message',
|
| 42 |
+
`Message from ${from}: ${message}`
|
| 43 |
+
],
|
| 44 |
+
{ timeoutMs: 10000 }
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
db_helpers.createNotification(
|
| 48 |
+
to,
|
| 49 |
+
'message',
|
| 50 |
+
'Direct Message',
|
| 51 |
+
`${from}: ${message.substring(0, 200)}${message.length > 200 ? '...' : ''}`,
|
| 52 |
+
'agent',
|
| 53 |
+
agent.id
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
db_helpers.logActivity(
|
| 57 |
+
'agent_message',
|
| 58 |
+
'agent',
|
| 59 |
+
agent.id,
|
| 60 |
+
from,
|
| 61 |
+
`Sent message to ${to}`,
|
| 62 |
+
{ to }
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
return NextResponse.json({ success: true })
|
| 66 |
+
} catch (error) {
|
| 67 |
+
console.error('POST /api/agents/message error:', error)
|
| 68 |
+
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
|
| 69 |
+
}
|
| 70 |
+
}
|
src/app/api/agents/route.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase, Agent, db_helpers } from '@/lib/db';
|
| 3 |
+
import { eventBus } from '@/lib/event-bus';
|
| 4 |
+
import { getTemplate, buildAgentConfig } from '@/lib/agent-templates';
|
| 5 |
+
import { writeAgentToConfig } from '@/lib/agent-sync';
|
| 6 |
+
import { logAuditEvent } from '@/lib/db';
|
| 7 |
+
import { getUserFromRequest, requireRole } from '@/lib/auth';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /api/agents - List all agents with optional filtering
|
| 11 |
+
* Query params: status, role, limit, offset
|
| 12 |
+
*/
|
| 13 |
+
export async function GET(request: NextRequest) {
|
| 14 |
+
try {
|
| 15 |
+
const db = getDatabase();
|
| 16 |
+
const { searchParams } = new URL(request.url);
|
| 17 |
+
|
| 18 |
+
// Parse query parameters
|
| 19 |
+
const status = searchParams.get('status');
|
| 20 |
+
const role = searchParams.get('role');
|
| 21 |
+
const limit = parseInt(searchParams.get('limit') || '50');
|
| 22 |
+
const offset = parseInt(searchParams.get('offset') || '0');
|
| 23 |
+
|
| 24 |
+
// Build dynamic query
|
| 25 |
+
let query = 'SELECT * FROM agents WHERE 1=1';
|
| 26 |
+
const params: any[] = [];
|
| 27 |
+
|
| 28 |
+
if (status) {
|
| 29 |
+
query += ' AND status = ?';
|
| 30 |
+
params.push(status);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (role) {
|
| 34 |
+
query += ' AND role = ?';
|
| 35 |
+
params.push(role);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
| 39 |
+
params.push(limit, offset);
|
| 40 |
+
|
| 41 |
+
const stmt = db.prepare(query);
|
| 42 |
+
const agents = stmt.all(...params) as Agent[];
|
| 43 |
+
|
| 44 |
+
// Parse JSON config field
|
| 45 |
+
const agentsWithParsedData = agents.map(agent => ({
|
| 46 |
+
...agent,
|
| 47 |
+
config: agent.config ? JSON.parse(agent.config) : {}
|
| 48 |
+
}));
|
| 49 |
+
|
| 50 |
+
// Get task counts for each agent
|
| 51 |
+
const agentsWithStats = agentsWithParsedData.map(agent => {
|
| 52 |
+
const taskCountStmt = db.prepare(`
|
| 53 |
+
SELECT
|
| 54 |
+
COUNT(*) as total,
|
| 55 |
+
SUM(CASE WHEN status = 'assigned' THEN 1 ELSE 0 END) as assigned,
|
| 56 |
+
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as in_progress,
|
| 57 |
+
SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END) as completed
|
| 58 |
+
FROM tasks
|
| 59 |
+
WHERE assigned_to = ?
|
| 60 |
+
`);
|
| 61 |
+
|
| 62 |
+
const taskStats = taskCountStmt.get(agent.name) as any;
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
...agent,
|
| 66 |
+
taskStats: {
|
| 67 |
+
total: taskStats.total || 0,
|
| 68 |
+
assigned: taskStats.assigned || 0,
|
| 69 |
+
in_progress: taskStats.in_progress || 0,
|
| 70 |
+
completed: taskStats.completed || 0
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
return NextResponse.json({
|
| 76 |
+
agents: agentsWithStats,
|
| 77 |
+
total: agents.length
|
| 78 |
+
});
|
| 79 |
+
} catch (error) {
|
| 80 |
+
console.error('GET /api/agents error:', error);
|
| 81 |
+
return NextResponse.json({ error: 'Failed to fetch agents' }, { status: 500 });
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* POST /api/agents - Create a new agent
|
| 87 |
+
*/
|
| 88 |
+
export async function POST(request: NextRequest) {
|
| 89 |
+
const auth = requireRole(request, 'operator');
|
| 90 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 91 |
+
|
| 92 |
+
try {
|
| 93 |
+
const db = getDatabase();
|
| 94 |
+
const body = await request.json();
|
| 95 |
+
|
| 96 |
+
const {
|
| 97 |
+
name,
|
| 98 |
+
role,
|
| 99 |
+
session_key,
|
| 100 |
+
soul_content,
|
| 101 |
+
status = 'offline',
|
| 102 |
+
config = {},
|
| 103 |
+
template,
|
| 104 |
+
gateway_config,
|
| 105 |
+
write_to_gateway
|
| 106 |
+
} = body;
|
| 107 |
+
|
| 108 |
+
// Resolve template if specified
|
| 109 |
+
let finalRole = role;
|
| 110 |
+
let finalConfig = config;
|
| 111 |
+
if (template) {
|
| 112 |
+
const tpl = getTemplate(template);
|
| 113 |
+
if (tpl) {
|
| 114 |
+
const builtConfig = buildAgentConfig(tpl, gateway_config || {});
|
| 115 |
+
finalConfig = { ...builtConfig, ...config };
|
| 116 |
+
if (!finalRole) finalRole = tpl.config.identity?.theme || tpl.type;
|
| 117 |
+
}
|
| 118 |
+
} else if (gateway_config) {
|
| 119 |
+
finalConfig = { ...config, ...gateway_config };
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
if (!name || !finalRole) {
|
| 123 |
+
return NextResponse.json({ error: 'Name and role are required' }, { status: 400 });
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Check if agent name already exists
|
| 127 |
+
const existingAgent = db.prepare('SELECT id FROM agents WHERE name = ?').get(name);
|
| 128 |
+
if (existingAgent) {
|
| 129 |
+
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const now = Math.floor(Date.now() / 1000);
|
| 133 |
+
|
| 134 |
+
const stmt = db.prepare(`
|
| 135 |
+
INSERT INTO agents (
|
| 136 |
+
name, role, session_key, soul_content, status,
|
| 137 |
+
created_at, updated_at, config
|
| 138 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 139 |
+
`);
|
| 140 |
+
|
| 141 |
+
const result = stmt.run(
|
| 142 |
+
name,
|
| 143 |
+
finalRole,
|
| 144 |
+
session_key,
|
| 145 |
+
soul_content,
|
| 146 |
+
status,
|
| 147 |
+
now,
|
| 148 |
+
now,
|
| 149 |
+
JSON.stringify(finalConfig)
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
const agentId = result.lastInsertRowid as number;
|
| 153 |
+
|
| 154 |
+
// Log activity
|
| 155 |
+
db_helpers.logActivity(
|
| 156 |
+
'agent_created',
|
| 157 |
+
'agent',
|
| 158 |
+
agentId,
|
| 159 |
+
getUserFromRequest(request)?.username || 'system',
|
| 160 |
+
`Created agent: ${name} (${finalRole})${template ? ` from template: ${template}` : ''}`,
|
| 161 |
+
{
|
| 162 |
+
name,
|
| 163 |
+
role: finalRole,
|
| 164 |
+
status,
|
| 165 |
+
session_key,
|
| 166 |
+
template: template || null
|
| 167 |
+
}
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
// Fetch the created agent
|
| 171 |
+
const createdAgent = db.prepare('SELECT * FROM agents WHERE id = ?').get(agentId) as Agent;
|
| 172 |
+
const parsedAgent = {
|
| 173 |
+
...createdAgent,
|
| 174 |
+
config: JSON.parse(createdAgent.config || '{}'),
|
| 175 |
+
taskStats: { total: 0, assigned: 0, in_progress: 0, completed: 0 }
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
// Broadcast to SSE clients
|
| 179 |
+
eventBus.broadcast('agent.created', parsedAgent);
|
| 180 |
+
|
| 181 |
+
// Write to gateway config if requested
|
| 182 |
+
if (write_to_gateway && finalConfig) {
|
| 183 |
+
try {
|
| 184 |
+
const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-');
|
| 185 |
+
await writeAgentToConfig({
|
| 186 |
+
id: openclawId,
|
| 187 |
+
name,
|
| 188 |
+
...(finalConfig.model && { model: finalConfig.model }),
|
| 189 |
+
...(finalConfig.identity && { identity: finalConfig.identity }),
|
| 190 |
+
...(finalConfig.sandbox && { sandbox: finalConfig.sandbox }),
|
| 191 |
+
...(finalConfig.tools && { tools: finalConfig.tools }),
|
| 192 |
+
...(finalConfig.subagents && { subagents: finalConfig.subagents }),
|
| 193 |
+
...(finalConfig.memorySearch && { memorySearch: finalConfig.memorySearch }),
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
const ipAddress = request.headers.get('x-forwarded-for') || 'unknown';
|
| 197 |
+
logAuditEvent({
|
| 198 |
+
action: 'agent_gateway_create',
|
| 199 |
+
actor: getUserFromRequest(request)?.username || 'system',
|
| 200 |
+
target_type: 'agent',
|
| 201 |
+
target_id: agentId as number,
|
| 202 |
+
detail: { name, openclaw_id: openclawId, template: template || null },
|
| 203 |
+
ip_address: ipAddress,
|
| 204 |
+
});
|
| 205 |
+
} catch (gwErr: any) {
|
| 206 |
+
console.error('Gateway write-back failed:', gwErr);
|
| 207 |
+
return NextResponse.json({
|
| 208 |
+
agent: parsedAgent,
|
| 209 |
+
warning: `Agent created in MC but gateway write failed: ${gwErr.message}`
|
| 210 |
+
}, { status: 201 });
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
return NextResponse.json({ agent: parsedAgent }, { status: 201 });
|
| 215 |
+
} catch (error) {
|
| 216 |
+
console.error('POST /api/agents error:', error);
|
| 217 |
+
return NextResponse.json({ error: 'Failed to create agent' }, { status: 500 });
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
/**
|
| 222 |
+
* PUT /api/agents - Update agent status (bulk operation for status updates)
|
| 223 |
+
*/
|
| 224 |
+
export async function PUT(request: NextRequest) {
|
| 225 |
+
const auth = requireRole(request, 'operator');
|
| 226 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 227 |
+
|
| 228 |
+
try {
|
| 229 |
+
const db = getDatabase();
|
| 230 |
+
const body = await request.json();
|
| 231 |
+
|
| 232 |
+
// Handle single agent update or bulk updates
|
| 233 |
+
if (body.name) {
|
| 234 |
+
// Single agent update
|
| 235 |
+
const { name, status, last_activity, config, session_key, soul_content, role } = body;
|
| 236 |
+
|
| 237 |
+
const agent = db.prepare('SELECT * FROM agents WHERE name = ?').get(name) as Agent;
|
| 238 |
+
if (!agent) {
|
| 239 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const now = Math.floor(Date.now() / 1000);
|
| 243 |
+
|
| 244 |
+
// Build dynamic update query
|
| 245 |
+
const fieldsToUpdate = [];
|
| 246 |
+
const params: any[] = [];
|
| 247 |
+
|
| 248 |
+
if (status !== undefined) {
|
| 249 |
+
fieldsToUpdate.push('status = ?');
|
| 250 |
+
params.push(status);
|
| 251 |
+
|
| 252 |
+
fieldsToUpdate.push('last_seen = ?');
|
| 253 |
+
params.push(now);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if (last_activity !== undefined) {
|
| 257 |
+
fieldsToUpdate.push('last_activity = ?');
|
| 258 |
+
params.push(last_activity);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (config !== undefined) {
|
| 262 |
+
fieldsToUpdate.push('config = ?');
|
| 263 |
+
params.push(JSON.stringify(config));
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
if (session_key !== undefined) {
|
| 267 |
+
fieldsToUpdate.push('session_key = ?');
|
| 268 |
+
params.push(session_key);
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if (soul_content !== undefined) {
|
| 272 |
+
fieldsToUpdate.push('soul_content = ?');
|
| 273 |
+
params.push(soul_content);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
if (role !== undefined) {
|
| 277 |
+
fieldsToUpdate.push('role = ?');
|
| 278 |
+
params.push(role);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
fieldsToUpdate.push('updated_at = ?');
|
| 282 |
+
params.push(now);
|
| 283 |
+
params.push(name);
|
| 284 |
+
|
| 285 |
+
if (fieldsToUpdate.length === 1) { // Only updated_at
|
| 286 |
+
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
const stmt = db.prepare(`
|
| 290 |
+
UPDATE agents
|
| 291 |
+
SET ${fieldsToUpdate.join(', ')}
|
| 292 |
+
WHERE name = ?
|
| 293 |
+
`);
|
| 294 |
+
|
| 295 |
+
stmt.run(...params);
|
| 296 |
+
|
| 297 |
+
// Log status change if status was updated
|
| 298 |
+
if (status !== undefined && status !== agent.status) {
|
| 299 |
+
db_helpers.logActivity(
|
| 300 |
+
'agent_status_change',
|
| 301 |
+
'agent',
|
| 302 |
+
agent.id,
|
| 303 |
+
name,
|
| 304 |
+
`Agent status changed from ${agent.status} to ${status}`,
|
| 305 |
+
{
|
| 306 |
+
oldStatus: agent.status,
|
| 307 |
+
newStatus: status,
|
| 308 |
+
last_activity
|
| 309 |
+
}
|
| 310 |
+
);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Broadcast update to SSE clients
|
| 314 |
+
eventBus.broadcast('agent.updated', {
|
| 315 |
+
id: agent.id,
|
| 316 |
+
name,
|
| 317 |
+
...(status !== undefined && { status }),
|
| 318 |
+
...(last_activity !== undefined && { last_activity }),
|
| 319 |
+
...(role !== undefined && { role }),
|
| 320 |
+
updated_at: now,
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
return NextResponse.json({ success: true });
|
| 324 |
+
} else {
|
| 325 |
+
return NextResponse.json({ error: 'Agent name is required' }, { status: 400 });
|
| 326 |
+
}
|
| 327 |
+
} catch (error) {
|
| 328 |
+
console.error('PUT /api/agents error:', error);
|
| 329 |
+
return NextResponse.json({ error: 'Failed to update agent' }, { status: 500 });
|
| 330 |
+
}
|
| 331 |
+
}
|
src/app/api/agents/sync/route.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { syncAgentsFromConfig, previewSyncDiff } from '@/lib/agent-sync'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* POST /api/agents/sync - Trigger agent config sync from openclaw.json
|
| 7 |
+
* Requires admin role.
|
| 8 |
+
*/
|
| 9 |
+
export async function POST(request: NextRequest) {
|
| 10 |
+
const auth = requireRole(request, 'admin')
|
| 11 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 12 |
+
|
| 13 |
+
try {
|
| 14 |
+
const result = await syncAgentsFromConfig(auth.user.username)
|
| 15 |
+
|
| 16 |
+
if (result.error) {
|
| 17 |
+
return NextResponse.json({ error: result.error }, { status: 500 })
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return NextResponse.json(result)
|
| 21 |
+
} catch (error: any) {
|
| 22 |
+
console.error('POST /api/agents/sync error:', error)
|
| 23 |
+
return NextResponse.json({ error: error.message || 'Sync failed' }, { status: 500 })
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* GET /api/agents/sync - Preview diff between openclaw.json and MC
|
| 29 |
+
* Shows what would change without writing.
|
| 30 |
+
*/
|
| 31 |
+
export async function GET(request: NextRequest) {
|
| 32 |
+
const auth = requireRole(request, 'admin')
|
| 33 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
const diff = await previewSyncDiff()
|
| 37 |
+
return NextResponse.json(diff)
|
| 38 |
+
} catch (error: any) {
|
| 39 |
+
console.error('GET /api/agents/sync error:', error)
|
| 40 |
+
return NextResponse.json({ error: error.message || 'Preview failed' }, { status: 500 })
|
| 41 |
+
}
|
| 42 |
+
}
|
src/app/api/alerts/route.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { getDatabase } from '@/lib/db'
|
| 4 |
+
|
| 5 |
+
interface AlertRule {
|
| 6 |
+
id: number
|
| 7 |
+
name: string
|
| 8 |
+
description: string | null
|
| 9 |
+
enabled: number
|
| 10 |
+
entity_type: string
|
| 11 |
+
condition_field: string
|
| 12 |
+
condition_operator: string
|
| 13 |
+
condition_value: string
|
| 14 |
+
action_type: string
|
| 15 |
+
action_config: string
|
| 16 |
+
cooldown_minutes: number
|
| 17 |
+
last_triggered_at: number | null
|
| 18 |
+
trigger_count: number
|
| 19 |
+
created_by: string
|
| 20 |
+
created_at: number
|
| 21 |
+
updated_at: number
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* GET /api/alerts - List all alert rules
|
| 26 |
+
*/
|
| 27 |
+
export async function GET(request: NextRequest) {
|
| 28 |
+
const auth = requireRole(request, 'viewer')
|
| 29 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 30 |
+
|
| 31 |
+
const db = getDatabase()
|
| 32 |
+
try {
|
| 33 |
+
const rules = db.prepare('SELECT * FROM alert_rules ORDER BY created_at DESC').all() as AlertRule[]
|
| 34 |
+
return NextResponse.json({ rules })
|
| 35 |
+
} catch {
|
| 36 |
+
return NextResponse.json({ rules: [] })
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* POST /api/alerts - Create a new alert rule or evaluate rules
|
| 42 |
+
*/
|
| 43 |
+
export async function POST(request: NextRequest) {
|
| 44 |
+
const auth = requireRole(request, 'operator')
|
| 45 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 46 |
+
|
| 47 |
+
const db = getDatabase()
|
| 48 |
+
const body = await request.json()
|
| 49 |
+
|
| 50 |
+
// Evaluate all enabled rules
|
| 51 |
+
if (body.action === 'evaluate') {
|
| 52 |
+
return evaluateRules(db)
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Create new rule
|
| 56 |
+
const { name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes } = body
|
| 57 |
+
|
| 58 |
+
if (!name || !entity_type || !condition_field || !condition_operator || !condition_value) {
|
| 59 |
+
return NextResponse.json({ error: 'Missing required fields: name, entity_type, condition_field, condition_operator, condition_value' }, { status: 400 })
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const validEntities = ['agent', 'task', 'session', 'activity']
|
| 63 |
+
if (!validEntities.includes(entity_type)) {
|
| 64 |
+
return NextResponse.json({ error: `entity_type must be one of: ${validEntities.join(', ')}` }, { status: 400 })
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
const validOperators = ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'count_above', 'count_below', 'age_minutes_above']
|
| 68 |
+
if (!validOperators.includes(condition_operator)) {
|
| 69 |
+
return NextResponse.json({ error: `condition_operator must be one of: ${validOperators.join(', ')}` }, { status: 400 })
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const result = db.prepare(`
|
| 74 |
+
INSERT INTO alert_rules (name, description, entity_type, condition_field, condition_operator, condition_value, action_type, action_config, cooldown_minutes, created_by)
|
| 75 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 76 |
+
`).run(
|
| 77 |
+
name,
|
| 78 |
+
description || null,
|
| 79 |
+
entity_type,
|
| 80 |
+
condition_field,
|
| 81 |
+
condition_operator,
|
| 82 |
+
condition_value,
|
| 83 |
+
action_type || 'notification',
|
| 84 |
+
JSON.stringify(action_config || {}),
|
| 85 |
+
cooldown_minutes || 60,
|
| 86 |
+
auth.user?.username || 'system'
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
// Audit log
|
| 90 |
+
try {
|
| 91 |
+
db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
|
| 92 |
+
'alert_rule_created',
|
| 93 |
+
auth.user?.username || 'system',
|
| 94 |
+
`Created alert rule: ${name}`
|
| 95 |
+
)
|
| 96 |
+
} catch { /* audit table might not exist */ }
|
| 97 |
+
|
| 98 |
+
const rule = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(result.lastInsertRowid) as AlertRule
|
| 99 |
+
return NextResponse.json({ rule }, { status: 201 })
|
| 100 |
+
} catch (err: any) {
|
| 101 |
+
return NextResponse.json({ error: err.message || 'Failed to create rule' }, { status: 500 })
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* PUT /api/alerts - Update an alert rule
|
| 107 |
+
*/
|
| 108 |
+
export async function PUT(request: NextRequest) {
|
| 109 |
+
const auth = requireRole(request, 'operator')
|
| 110 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 111 |
+
|
| 112 |
+
const db = getDatabase()
|
| 113 |
+
const body = await request.json()
|
| 114 |
+
const { id, ...updates } = body
|
| 115 |
+
|
| 116 |
+
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
| 117 |
+
|
| 118 |
+
const existing = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule | undefined
|
| 119 |
+
if (!existing) return NextResponse.json({ error: 'Rule not found' }, { status: 404 })
|
| 120 |
+
|
| 121 |
+
const allowed = ['name', 'description', 'enabled', 'entity_type', 'condition_field', 'condition_operator', 'condition_value', 'action_type', 'action_config', 'cooldown_minutes']
|
| 122 |
+
const sets: string[] = []
|
| 123 |
+
const values: any[] = []
|
| 124 |
+
|
| 125 |
+
for (const key of allowed) {
|
| 126 |
+
if (key in updates) {
|
| 127 |
+
sets.push(`${key} = ?`)
|
| 128 |
+
values.push(key === 'action_config' ? JSON.stringify(updates[key]) : updates[key])
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if (sets.length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
| 133 |
+
|
| 134 |
+
sets.push('updated_at = (unixepoch())')
|
| 135 |
+
values.push(id)
|
| 136 |
+
|
| 137 |
+
db.prepare(`UPDATE alert_rules SET ${sets.join(', ')} WHERE id = ?`).run(...values)
|
| 138 |
+
|
| 139 |
+
const updated = db.prepare('SELECT * FROM alert_rules WHERE id = ?').get(id) as AlertRule
|
| 140 |
+
return NextResponse.json({ rule: updated })
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/**
|
| 144 |
+
* DELETE /api/alerts - Delete an alert rule
|
| 145 |
+
*/
|
| 146 |
+
export async function DELETE(request: NextRequest) {
|
| 147 |
+
const auth = requireRole(request, 'admin')
|
| 148 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 149 |
+
|
| 150 |
+
const db = getDatabase()
|
| 151 |
+
const body = await request.json()
|
| 152 |
+
const { id } = body
|
| 153 |
+
|
| 154 |
+
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
| 155 |
+
|
| 156 |
+
const result = db.prepare('DELETE FROM alert_rules WHERE id = ?').run(id)
|
| 157 |
+
|
| 158 |
+
try {
|
| 159 |
+
db.prepare('INSERT INTO audit_log (action, actor, detail) VALUES (?, ?, ?)').run(
|
| 160 |
+
'alert_rule_deleted',
|
| 161 |
+
auth.user?.username || 'system',
|
| 162 |
+
`Deleted alert rule #${id}`
|
| 163 |
+
)
|
| 164 |
+
} catch { /* audit table might not exist */ }
|
| 165 |
+
|
| 166 |
+
return NextResponse.json({ deleted: result.changes > 0 })
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/**
|
| 170 |
+
* Evaluate all enabled alert rules against current data
|
| 171 |
+
*/
|
| 172 |
+
function evaluateRules(db: ReturnType<typeof getDatabase>) {
|
| 173 |
+
let rules: AlertRule[]
|
| 174 |
+
try {
|
| 175 |
+
rules = db.prepare('SELECT * FROM alert_rules WHERE enabled = 1').all() as AlertRule[]
|
| 176 |
+
} catch {
|
| 177 |
+
return NextResponse.json({ evaluated: 0, triggered: 0, results: [] })
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
const now = Math.floor(Date.now() / 1000)
|
| 181 |
+
const results: { rule_id: number; rule_name: string; triggered: boolean; reason?: string }[] = []
|
| 182 |
+
|
| 183 |
+
for (const rule of rules) {
|
| 184 |
+
// Check cooldown
|
| 185 |
+
if (rule.last_triggered_at && (now - rule.last_triggered_at) < rule.cooldown_minutes * 60) {
|
| 186 |
+
results.push({ rule_id: rule.id, rule_name: rule.name, triggered: false, reason: 'In cooldown' })
|
| 187 |
+
continue
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const triggered = evaluateRule(db, rule, now)
|
| 191 |
+
results.push({ rule_id: rule.id, rule_name: rule.name, triggered, reason: triggered ? 'Condition met' : 'Condition not met' })
|
| 192 |
+
|
| 193 |
+
if (triggered) {
|
| 194 |
+
// Update trigger tracking
|
| 195 |
+
db.prepare('UPDATE alert_rules SET last_triggered_at = ?, trigger_count = trigger_count + 1 WHERE id = ?').run(now, rule.id)
|
| 196 |
+
|
| 197 |
+
// Create notification
|
| 198 |
+
try {
|
| 199 |
+
const config = JSON.parse(rule.action_config || '{}')
|
| 200 |
+
const recipient = config.recipient || 'system'
|
| 201 |
+
db.prepare(`
|
| 202 |
+
INSERT INTO notifications (recipient, type, title, message, source_type, source_id)
|
| 203 |
+
VALUES (?, 'alert', ?, ?, 'alert_rule', ?)
|
| 204 |
+
`).run(recipient, `Alert: ${rule.name}`, rule.description || `Rule "${rule.name}" triggered`, rule.id)
|
| 205 |
+
} catch { /* notification creation failed */ }
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const triggered = results.filter(r => r.triggered).length
|
| 210 |
+
return NextResponse.json({ evaluated: rules.length, triggered, results })
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function evaluateRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
|
| 214 |
+
try {
|
| 215 |
+
switch (rule.entity_type) {
|
| 216 |
+
case 'agent': return evaluateAgentRule(db, rule, now)
|
| 217 |
+
case 'task': return evaluateTaskRule(db, rule, now)
|
| 218 |
+
case 'session': return evaluateSessionRule(db, rule, now)
|
| 219 |
+
case 'activity': return evaluateActivityRule(db, rule, now)
|
| 220 |
+
default: return false
|
| 221 |
+
}
|
| 222 |
+
} catch {
|
| 223 |
+
return false
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function evaluateAgentRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
|
| 228 |
+
const { condition_field, condition_operator, condition_value } = rule
|
| 229 |
+
|
| 230 |
+
if (condition_operator === 'count_above' || condition_operator === 'count_below') {
|
| 231 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE ${safeColumn('agents', condition_field)} = ?`).get(condition_value) as any)?.c || 0
|
| 232 |
+
return condition_operator === 'count_above' ? count > parseInt(condition_value) : count < parseInt(condition_value)
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (condition_operator === 'age_minutes_above') {
|
| 236 |
+
// Check agents where field value is older than N minutes (e.g., last_seen)
|
| 237 |
+
const threshold = now - parseInt(condition_value) * 60
|
| 238 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status != 'offline' AND ${safeColumn('agents', condition_field)} < ?`).get(threshold) as any)?.c || 0
|
| 239 |
+
return count > 0
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const agents = db.prepare(`SELECT ${safeColumn('agents', condition_field)} as val FROM agents WHERE status != 'offline'`).all() as any[]
|
| 243 |
+
return agents.some(a => compareValue(a.val, condition_operator, condition_value))
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function evaluateTaskRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
|
| 247 |
+
const { condition_field, condition_operator, condition_value } = rule
|
| 248 |
+
|
| 249 |
+
if (condition_operator === 'count_above') {
|
| 250 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks WHERE ${safeColumn('tasks', condition_field)} = ?`).get(condition_value) as any)?.c || 0
|
| 251 |
+
return count > parseInt(condition_value)
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
if (condition_operator === 'count_below') {
|
| 255 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM tasks`).get() as any)?.c || 0
|
| 256 |
+
return count < parseInt(condition_value)
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const tasks = db.prepare(`SELECT ${safeColumn('tasks', condition_field)} as val FROM tasks`).all() as any[]
|
| 260 |
+
return tasks.some(t => compareValue(t.val, condition_operator, condition_value))
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function evaluateSessionRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, _now: number): boolean {
|
| 264 |
+
// Session data comes from the gateway, not the DB, so we check the agents table for session info
|
| 265 |
+
const { condition_operator, condition_value } = rule
|
| 266 |
+
|
| 267 |
+
if (condition_operator === 'count_above') {
|
| 268 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM agents WHERE status = 'busy'`).get() as any)?.c || 0
|
| 269 |
+
return count > parseInt(condition_value)
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
return false
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
function evaluateActivityRule(db: ReturnType<typeof getDatabase>, rule: AlertRule, now: number): boolean {
|
| 276 |
+
const { condition_field, condition_operator, condition_value } = rule
|
| 277 |
+
|
| 278 |
+
if (condition_operator === 'count_above') {
|
| 279 |
+
// Count activities in the last hour
|
| 280 |
+
const hourAgo = now - 3600
|
| 281 |
+
const count = (db.prepare(`SELECT COUNT(*) as c FROM activities WHERE created_at > ? AND ${safeColumn('activities', condition_field)} = ?`).get(hourAgo, condition_value) as any)?.c || 0
|
| 282 |
+
return count > parseInt(condition_value)
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
return false
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
function compareValue(actual: any, operator: string, expected: string): boolean {
|
| 289 |
+
if (actual == null) return false
|
| 290 |
+
const strActual = String(actual)
|
| 291 |
+
switch (operator) {
|
| 292 |
+
case 'equals': return strActual === expected
|
| 293 |
+
case 'not_equals': return strActual !== expected
|
| 294 |
+
case 'greater_than': return Number(actual) > Number(expected)
|
| 295 |
+
case 'less_than': return Number(actual) < Number(expected)
|
| 296 |
+
case 'contains': return strActual.toLowerCase().includes(expected.toLowerCase())
|
| 297 |
+
default: return false
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Whitelist of columns per table to prevent SQL injection
|
| 302 |
+
const SAFE_COLUMNS: Record<string, Set<string>> = {
|
| 303 |
+
agents: new Set(['status', 'role', 'name', 'last_seen', 'last_activity']),
|
| 304 |
+
tasks: new Set(['status', 'priority', 'assigned_to', 'title']),
|
| 305 |
+
activities: new Set(['type', 'actor', 'entity_type']),
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function safeColumn(table: string, column: string): string {
|
| 309 |
+
if (SAFE_COLUMNS[table]?.has(column)) return column
|
| 310 |
+
return 'id' // fallback to safe column
|
| 311 |
+
}
|
src/app/api/audit/route.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { getDatabase } from '@/lib/db'
|
| 4 |
+
|
| 5 |
+
function safeParseJson(str: string): any {
|
| 6 |
+
try { return JSON.parse(str) } catch { return str }
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* GET /api/audit - Query audit log (admin only)
|
| 11 |
+
* Query params: action, actor, limit, offset, since, until
|
| 12 |
+
*/
|
| 13 |
+
export async function GET(request: NextRequest) {
|
| 14 |
+
const auth = requireRole(request, 'admin')
|
| 15 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 16 |
+
|
| 17 |
+
const { searchParams } = new URL(request.url)
|
| 18 |
+
const action = searchParams.get('action')
|
| 19 |
+
const actor = searchParams.get('actor')
|
| 20 |
+
const limit = Math.min(parseInt(searchParams.get('limit') || '100'), 500)
|
| 21 |
+
const offset = parseInt(searchParams.get('offset') || '0')
|
| 22 |
+
const since = searchParams.get('since')
|
| 23 |
+
const until = searchParams.get('until')
|
| 24 |
+
|
| 25 |
+
const conditions: string[] = []
|
| 26 |
+
const params: any[] = []
|
| 27 |
+
|
| 28 |
+
if (action) {
|
| 29 |
+
conditions.push('action = ?')
|
| 30 |
+
params.push(action)
|
| 31 |
+
}
|
| 32 |
+
if (actor) {
|
| 33 |
+
conditions.push('actor = ?')
|
| 34 |
+
params.push(actor)
|
| 35 |
+
}
|
| 36 |
+
if (since) {
|
| 37 |
+
conditions.push('created_at >= ?')
|
| 38 |
+
params.push(parseInt(since))
|
| 39 |
+
}
|
| 40 |
+
if (until) {
|
| 41 |
+
conditions.push('created_at <= ?')
|
| 42 |
+
params.push(parseInt(until))
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
| 46 |
+
|
| 47 |
+
const db = getDatabase()
|
| 48 |
+
|
| 49 |
+
const total = (db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as any).count
|
| 50 |
+
|
| 51 |
+
const rows = db.prepare(`
|
| 52 |
+
SELECT * FROM audit_log ${where}
|
| 53 |
+
ORDER BY created_at DESC
|
| 54 |
+
LIMIT ? OFFSET ?
|
| 55 |
+
`).all(...params, limit, offset)
|
| 56 |
+
|
| 57 |
+
return NextResponse.json({
|
| 58 |
+
events: rows.map((row: any) => ({
|
| 59 |
+
...row,
|
| 60 |
+
detail: row.detail ? safeParseJson(row.detail) : null,
|
| 61 |
+
})),
|
| 62 |
+
total,
|
| 63 |
+
limit,
|
| 64 |
+
offset,
|
| 65 |
+
})
|
| 66 |
+
}
|
src/app/api/auth/access-requests/route.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomBytes } from 'crypto'
|
| 2 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 3 |
+
import { createUser, getUserFromRequest } from '@/lib/auth'
|
| 4 |
+
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 5 |
+
|
| 6 |
+
function makeUsernameFromEmail(email: string): string {
|
| 7 |
+
const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase() || 'user'
|
| 8 |
+
return base.slice(0, 28)
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function ensureUniqueUsername(base: string): string {
|
| 12 |
+
const db = getDatabase()
|
| 13 |
+
let candidate = base
|
| 14 |
+
let i = 0
|
| 15 |
+
while (db.prepare('SELECT 1 FROM users WHERE username = ?').get(candidate)) {
|
| 16 |
+
i += 1
|
| 17 |
+
candidate = `${base.slice(0, 24)}-${i}`
|
| 18 |
+
}
|
| 19 |
+
return candidate
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export async function GET(request: NextRequest) {
|
| 23 |
+
const user = getUserFromRequest(request)
|
| 24 |
+
if (!user || user.role !== 'admin') {
|
| 25 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const db = getDatabase()
|
| 29 |
+
db.exec(`
|
| 30 |
+
CREATE TABLE IF NOT EXISTS access_requests (
|
| 31 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 32 |
+
provider TEXT NOT NULL DEFAULT 'google',
|
| 33 |
+
email TEXT NOT NULL,
|
| 34 |
+
provider_user_id TEXT,
|
| 35 |
+
display_name TEXT,
|
| 36 |
+
avatar_url TEXT,
|
| 37 |
+
status TEXT NOT NULL DEFAULT 'pending',
|
| 38 |
+
requested_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
| 39 |
+
last_attempt_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
| 40 |
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
| 41 |
+
reviewed_by TEXT,
|
| 42 |
+
reviewed_at INTEGER,
|
| 43 |
+
review_note TEXT,
|
| 44 |
+
approved_user_id INTEGER
|
| 45 |
+
)
|
| 46 |
+
`)
|
| 47 |
+
|
| 48 |
+
const status = String(request.nextUrl.searchParams.get('status') || 'all')
|
| 49 |
+
const rows = status === 'all'
|
| 50 |
+
? db.prepare("SELECT * FROM access_requests ORDER BY status = 'pending' DESC, last_attempt_at DESC, id DESC").all()
|
| 51 |
+
: db.prepare('SELECT * FROM access_requests WHERE status = ? ORDER BY last_attempt_at DESC, id DESC').all(status)
|
| 52 |
+
|
| 53 |
+
return NextResponse.json({ requests: rows })
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export async function POST(request: NextRequest) {
|
| 57 |
+
const admin = getUserFromRequest(request)
|
| 58 |
+
if (!admin || admin.role !== 'admin') {
|
| 59 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
const db = getDatabase()
|
| 63 |
+
const body = await request.json().catch(() => ({}))
|
| 64 |
+
const requestId = Number(body?.request_id)
|
| 65 |
+
const action = String(body?.action || '')
|
| 66 |
+
const role = String(body?.role || 'viewer') as 'admin' | 'operator' | 'viewer'
|
| 67 |
+
const note = body?.note ? String(body.note) : null
|
| 68 |
+
|
| 69 |
+
if (!Number.isInteger(requestId) || requestId <= 0) {
|
| 70 |
+
return NextResponse.json({ error: 'request_id is required' }, { status: 400 })
|
| 71 |
+
}
|
| 72 |
+
if (!['approve', 'reject'].includes(action)) {
|
| 73 |
+
return NextResponse.json({ error: 'action must be approve or reject' }, { status: 400 })
|
| 74 |
+
}
|
| 75 |
+
if (!['admin', 'operator', 'viewer'].includes(role)) {
|
| 76 |
+
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const reqRow = db.prepare('SELECT * FROM access_requests WHERE id = ?').get(requestId) as any
|
| 80 |
+
if (!reqRow) return NextResponse.json({ error: 'Request not found' }, { status: 404 })
|
| 81 |
+
|
| 82 |
+
if (action === 'reject') {
|
| 83 |
+
db.prepare(`
|
| 84 |
+
UPDATE access_requests
|
| 85 |
+
SET status = 'rejected', reviewed_by = ?, reviewed_at = (unixepoch()), review_note = ?
|
| 86 |
+
WHERE id = ?
|
| 87 |
+
`).run(admin.username, note, requestId)
|
| 88 |
+
|
| 89 |
+
logAuditEvent({
|
| 90 |
+
action: 'access_request_rejected',
|
| 91 |
+
actor: admin.username,
|
| 92 |
+
actor_id: admin.id,
|
| 93 |
+
detail: { request_id: requestId, email: reqRow.email, note },
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
return NextResponse.json({ ok: true })
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const email = String(reqRow.email || '').toLowerCase()
|
| 100 |
+
const providerUserId = reqRow.provider_user_id ? String(reqRow.provider_user_id) : null
|
| 101 |
+
const displayName = String(reqRow.display_name || email.split('@')[0] || 'Google User')
|
| 102 |
+
const avatarUrl = reqRow.avatar_url ? String(reqRow.avatar_url) : null
|
| 103 |
+
|
| 104 |
+
const user = db.transaction(() => {
|
| 105 |
+
const existing = db.prepare('SELECT * FROM users WHERE lower(email) = ? OR (provider = ? AND provider_user_id = ?) ORDER BY id ASC LIMIT 1').get(email, 'google', providerUserId || '') as any
|
| 106 |
+
|
| 107 |
+
let userId: number
|
| 108 |
+
if (existing) {
|
| 109 |
+
db.prepare(`
|
| 110 |
+
UPDATE users
|
| 111 |
+
SET provider = 'google', provider_user_id = ?, email = ?, avatar_url = COALESCE(?, avatar_url), is_approved = 1, role = ?, approved_by = ?, approved_at = (unixepoch()), updated_at = (unixepoch())
|
| 112 |
+
WHERE id = ?
|
| 113 |
+
`).run(providerUserId, email, avatarUrl, role, admin.username, existing.id)
|
| 114 |
+
userId = Number(existing.id)
|
| 115 |
+
} else {
|
| 116 |
+
const username = ensureUniqueUsername(makeUsernameFromEmail(email))
|
| 117 |
+
const randomPwd = randomBytes(24).toString('hex')
|
| 118 |
+
const created = createUser(username, randomPwd, displayName, role, {
|
| 119 |
+
provider: 'google',
|
| 120 |
+
provider_user_id: providerUserId,
|
| 121 |
+
email,
|
| 122 |
+
avatar_url: avatarUrl,
|
| 123 |
+
is_approved: 1,
|
| 124 |
+
approved_by: admin.username,
|
| 125 |
+
approved_at: Math.floor(Date.now() / 1000),
|
| 126 |
+
})
|
| 127 |
+
userId = created.id
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
db.prepare(`
|
| 131 |
+
UPDATE access_requests
|
| 132 |
+
SET status = 'approved', reviewed_by = ?, reviewed_at = (unixepoch()), review_note = ?, approved_user_id = ?
|
| 133 |
+
WHERE id = ?
|
| 134 |
+
`).run(admin.username, note, userId, requestId)
|
| 135 |
+
|
| 136 |
+
return db.prepare('SELECT id, username, display_name, role, provider, email, avatar_url, is_approved FROM users WHERE id = ?').get(userId)
|
| 137 |
+
})() as any
|
| 138 |
+
|
| 139 |
+
logAuditEvent({
|
| 140 |
+
action: 'access_request_approved',
|
| 141 |
+
actor: admin.username,
|
| 142 |
+
actor_id: admin.id,
|
| 143 |
+
detail: { request_id: requestId, email, role, user_id: user?.id, note },
|
| 144 |
+
})
|
| 145 |
+
|
| 146 |
+
return NextResponse.json({ ok: true, user })
|
| 147 |
+
}
|
src/app/api/auth/google/route.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomBytes } from 'crypto'
|
| 2 |
+
import { NextResponse } from 'next/server'
|
| 3 |
+
import { createSession } from '@/lib/auth'
|
| 4 |
+
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 5 |
+
import { verifyGoogleIdToken } from '@/lib/google-auth'
|
| 6 |
+
import { getMcSessionCookieOptions } from '@/lib/session-cookie'
|
| 7 |
+
|
| 8 |
+
function upsertAccessRequest(input: {
|
| 9 |
+
email: string
|
| 10 |
+
providerUserId: string
|
| 11 |
+
displayName: string
|
| 12 |
+
avatarUrl?: string
|
| 13 |
+
}) {
|
| 14 |
+
const db = getDatabase()
|
| 15 |
+
db.prepare(`
|
| 16 |
+
INSERT INTO access_requests (provider, email, provider_user_id, display_name, avatar_url, status, attempt_count, requested_at, last_attempt_at)
|
| 17 |
+
VALUES ('google', ?, ?, ?, ?, 'pending', 1, (unixepoch()), (unixepoch()))
|
| 18 |
+
ON CONFLICT(email, provider) DO UPDATE SET
|
| 19 |
+
provider_user_id = excluded.provider_user_id,
|
| 20 |
+
display_name = excluded.display_name,
|
| 21 |
+
avatar_url = excluded.avatar_url,
|
| 22 |
+
status = 'pending',
|
| 23 |
+
attempt_count = access_requests.attempt_count + 1,
|
| 24 |
+
last_attempt_at = (unixepoch())
|
| 25 |
+
`).run(input.email.toLowerCase(), input.providerUserId, input.displayName, input.avatarUrl || null)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export async function POST(request: Request) {
|
| 29 |
+
try {
|
| 30 |
+
const body = await request.json().catch(() => ({}))
|
| 31 |
+
const credential = String(body?.credential || '')
|
| 32 |
+
const profile = await verifyGoogleIdToken(credential)
|
| 33 |
+
|
| 34 |
+
const db = getDatabase()
|
| 35 |
+
const email = String(profile.email || '').toLowerCase().trim()
|
| 36 |
+
const sub = String(profile.sub || '').trim()
|
| 37 |
+
const displayName = String(profile.name || email.split('@')[0] || 'Google User').trim()
|
| 38 |
+
const avatar = profile.picture ? String(profile.picture) : null
|
| 39 |
+
|
| 40 |
+
const row = db.prepare(`
|
| 41 |
+
SELECT id, username, display_name, role, provider, email, avatar_url, is_approved, created_at, updated_at, last_login_at
|
| 42 |
+
FROM users
|
| 43 |
+
WHERE (provider = 'google' AND provider_user_id = ?) OR lower(email) = ?
|
| 44 |
+
ORDER BY id ASC
|
| 45 |
+
LIMIT 1
|
| 46 |
+
`).get(sub, email) as any
|
| 47 |
+
|
| 48 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 49 |
+
const userAgent = request.headers.get('user-agent') || undefined
|
| 50 |
+
|
| 51 |
+
if (!row || Number(row.is_approved ?? 1) !== 1) {
|
| 52 |
+
upsertAccessRequest({
|
| 53 |
+
email,
|
| 54 |
+
providerUserId: sub,
|
| 55 |
+
displayName,
|
| 56 |
+
avatarUrl: avatar || undefined,
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
logAuditEvent({
|
| 60 |
+
action: 'google_login_pending_approval',
|
| 61 |
+
actor: email,
|
| 62 |
+
detail: { email, sub },
|
| 63 |
+
ip_address: ipAddress,
|
| 64 |
+
user_agent: userAgent,
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
return NextResponse.json(
|
| 68 |
+
{ error: 'Access request pending admin approval', code: 'PENDING_APPROVAL' },
|
| 69 |
+
{ status: 403 }
|
| 70 |
+
)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
db.prepare(`
|
| 74 |
+
UPDATE users
|
| 75 |
+
SET provider = 'google', provider_user_id = ?, email = ?, avatar_url = COALESCE(?, avatar_url), updated_at = (unixepoch())
|
| 76 |
+
WHERE id = ?
|
| 77 |
+
`).run(sub, email, avatar, row.id)
|
| 78 |
+
|
| 79 |
+
const { token, expiresAt } = createSession(row.id, ipAddress, userAgent)
|
| 80 |
+
|
| 81 |
+
logAuditEvent({ action: 'login_google', actor: row.username, actor_id: row.id, ip_address: ipAddress, user_agent: userAgent })
|
| 82 |
+
|
| 83 |
+
const response = NextResponse.json({
|
| 84 |
+
user: {
|
| 85 |
+
id: row.id,
|
| 86 |
+
username: row.username,
|
| 87 |
+
display_name: row.display_name,
|
| 88 |
+
role: row.role,
|
| 89 |
+
provider: 'google',
|
| 90 |
+
email,
|
| 91 |
+
avatar_url: avatar,
|
| 92 |
+
},
|
| 93 |
+
})
|
| 94 |
+
|
| 95 |
+
response.cookies.set('mc-session', token, {
|
| 96 |
+
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }),
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
return response
|
| 100 |
+
} catch (error: any) {
|
| 101 |
+
return NextResponse.json({ error: error?.message || 'Google login failed' }, { status: 400 })
|
| 102 |
+
}
|
| 103 |
+
}
|
src/app/api/auth/login/route.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server'
|
| 2 |
+
import { authenticateUser, createSession } from '@/lib/auth'
|
| 3 |
+
import { logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { getMcSessionCookieOptions } from '@/lib/session-cookie'
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
try {
|
| 8 |
+
const { username, password } = await request.json()
|
| 9 |
+
|
| 10 |
+
if (!username || !password) {
|
| 11 |
+
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 15 |
+
const userAgent = request.headers.get('user-agent') || undefined
|
| 16 |
+
|
| 17 |
+
const user = authenticateUser(username, password)
|
| 18 |
+
if (!user) {
|
| 19 |
+
logAuditEvent({ action: 'login_failed', actor: username, ip_address: ipAddress, user_agent: userAgent })
|
| 20 |
+
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 })
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const { token, expiresAt } = createSession(user.id, ipAddress, userAgent)
|
| 24 |
+
|
| 25 |
+
logAuditEvent({ action: 'login', actor: user.username, actor_id: user.id, ip_address: ipAddress, user_agent: userAgent })
|
| 26 |
+
|
| 27 |
+
const response = NextResponse.json({
|
| 28 |
+
user: {
|
| 29 |
+
id: user.id,
|
| 30 |
+
username: user.username,
|
| 31 |
+
display_name: user.display_name,
|
| 32 |
+
role: user.role,
|
| 33 |
+
provider: user.provider || 'local',
|
| 34 |
+
email: user.email || null,
|
| 35 |
+
avatar_url: user.avatar_url || null,
|
| 36 |
+
},
|
| 37 |
+
})
|
| 38 |
+
|
| 39 |
+
response.cookies.set('mc-session', token, {
|
| 40 |
+
...getMcSessionCookieOptions({ maxAgeSeconds: expiresAt - Math.floor(Date.now() / 1000) }),
|
| 41 |
+
})
|
| 42 |
+
|
| 43 |
+
return response
|
| 44 |
+
} catch (error) {
|
| 45 |
+
console.error('Login error:', error)
|
| 46 |
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
| 47 |
+
}
|
| 48 |
+
}
|
src/app/api/auth/logout/route.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server'
|
| 2 |
+
import { destroySession, getUserFromRequest } from '@/lib/auth'
|
| 3 |
+
import { logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { getMcSessionCookieOptions } from '@/lib/session-cookie'
|
| 5 |
+
|
| 6 |
+
export async function POST(request: Request) {
|
| 7 |
+
const user = getUserFromRequest(request)
|
| 8 |
+
const cookieHeader = request.headers.get('cookie') || ''
|
| 9 |
+
const match = cookieHeader.match(/(?:^|;\s*)mc-session=([^;]*)/)
|
| 10 |
+
const token = match ? decodeURIComponent(match[1]) : null
|
| 11 |
+
|
| 12 |
+
if (token) {
|
| 13 |
+
destroySession(token)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
if (user) {
|
| 17 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 18 |
+
logAuditEvent({ action: 'logout', actor: user.username, actor_id: user.id, ip_address: ipAddress })
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const response = NextResponse.json({ ok: true })
|
| 22 |
+
response.cookies.set('mc-session', '', {
|
| 23 |
+
...getMcSessionCookieOptions({ maxAgeSeconds: 0 }),
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
return response
|
| 27 |
+
}
|
src/app/api/auth/me/route.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getUserFromRequest, updateUser } from '@/lib/auth'
|
| 3 |
+
import { logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { verifyPassword } from '@/lib/password'
|
| 5 |
+
|
| 6 |
+
export async function GET(request: Request) {
|
| 7 |
+
const user = getUserFromRequest(request)
|
| 8 |
+
|
| 9 |
+
if (!user) {
|
| 10 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
return NextResponse.json({
|
| 14 |
+
user: {
|
| 15 |
+
id: user.id,
|
| 16 |
+
username: user.username,
|
| 17 |
+
display_name: user.display_name,
|
| 18 |
+
role: user.role,
|
| 19 |
+
provider: user.provider || 'local',
|
| 20 |
+
email: user.email || null,
|
| 21 |
+
avatar_url: user.avatar_url || null,
|
| 22 |
+
},
|
| 23 |
+
})
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* PATCH /api/auth/me - Self-service password change and display name update.
|
| 28 |
+
* Body: { current_password, new_password } and/or { display_name }
|
| 29 |
+
*/
|
| 30 |
+
export async function PATCH(request: NextRequest) {
|
| 31 |
+
const user = getUserFromRequest(request)
|
| 32 |
+
if (!user) {
|
| 33 |
+
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// API key users (id=0) cannot change passwords
|
| 37 |
+
if (user.id === 0) {
|
| 38 |
+
return NextResponse.json({ error: 'API key users cannot change passwords' }, { status: 403 })
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
try {
|
| 42 |
+
const { current_password, new_password, display_name } = await request.json()
|
| 43 |
+
|
| 44 |
+
const updates: { password?: string; display_name?: string } = {}
|
| 45 |
+
|
| 46 |
+
// Handle password change
|
| 47 |
+
if (new_password) {
|
| 48 |
+
if (!current_password) {
|
| 49 |
+
return NextResponse.json({ error: 'Current password is required' }, { status: 400 })
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (new_password.length < 8) {
|
| 53 |
+
return NextResponse.json({ error: 'New password must be at least 8 characters' }, { status: 400 })
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Verify current password by fetching stored hash
|
| 57 |
+
const { getDatabase } = await import('@/lib/db')
|
| 58 |
+
const db = getDatabase()
|
| 59 |
+
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(user.id) as any
|
| 60 |
+
if (!row || !verifyPassword(current_password, row.password_hash)) {
|
| 61 |
+
return NextResponse.json({ error: 'Current password is incorrect' }, { status: 403 })
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
updates.password = new_password
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Handle display name update
|
| 68 |
+
if (display_name !== undefined) {
|
| 69 |
+
if (!display_name.trim()) {
|
| 70 |
+
return NextResponse.json({ error: 'Display name cannot be empty' }, { status: 400 })
|
| 71 |
+
}
|
| 72 |
+
updates.display_name = display_name.trim()
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
if (Object.keys(updates).length === 0) {
|
| 76 |
+
return NextResponse.json({ error: 'No updates provided' }, { status: 400 })
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const updated = updateUser(user.id, updates)
|
| 80 |
+
if (!updated) {
|
| 81 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 85 |
+
if (updates.password) {
|
| 86 |
+
logAuditEvent({ action: 'password_change', actor: user.username, actor_id: user.id, ip_address: ipAddress })
|
| 87 |
+
}
|
| 88 |
+
if (updates.display_name) {
|
| 89 |
+
logAuditEvent({ action: 'profile_update', actor: user.username, actor_id: user.id, detail: { display_name: updates.display_name }, ip_address: ipAddress })
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return NextResponse.json({
|
| 93 |
+
success: true,
|
| 94 |
+
user: {
|
| 95 |
+
id: updated.id,
|
| 96 |
+
username: updated.username,
|
| 97 |
+
display_name: updated.display_name,
|
| 98 |
+
role: updated.role,
|
| 99 |
+
provider: updated.provider || 'local',
|
| 100 |
+
email: updated.email || null,
|
| 101 |
+
avatar_url: updated.avatar_url || null,
|
| 102 |
+
},
|
| 103 |
+
})
|
| 104 |
+
} catch (error) {
|
| 105 |
+
console.error('PATCH /api/auth/me error:', error)
|
| 106 |
+
return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 })
|
| 107 |
+
}
|
| 108 |
+
}
|
src/app/api/auth/users/route.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getUserFromRequest, getAllUsers, createUser, updateUser, deleteUser } from '@/lib/auth'
|
| 3 |
+
import { logAuditEvent } from '@/lib/db'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/auth/users - List all users (admin only)
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
const user = getUserFromRequest(request)
|
| 10 |
+
if (!user || user.role !== 'admin') {
|
| 11 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
const users = getAllUsers()
|
| 15 |
+
return NextResponse.json({ users })
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* POST /api/auth/users - Create a new user (admin only)
|
| 20 |
+
*/
|
| 21 |
+
export async function POST(request: NextRequest) {
|
| 22 |
+
const currentUser = getUserFromRequest(request)
|
| 23 |
+
if (!currentUser || currentUser.role !== 'admin') {
|
| 24 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const { username, password, display_name, role = 'operator', provider = 'local', email = null } = await request.json()
|
| 29 |
+
|
| 30 |
+
if (!username || !password) {
|
| 31 |
+
return NextResponse.json({ error: 'Username and password are required' }, { status: 400 })
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
if (!['admin', 'operator', 'viewer'].includes(role)) {
|
| 35 |
+
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const newUser = createUser(username, password, display_name || username, role, { provider, email })
|
| 39 |
+
|
| 40 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 41 |
+
logAuditEvent({
|
| 42 |
+
action: 'user_create', actor: currentUser.username, actor_id: currentUser.id,
|
| 43 |
+
target_type: 'user', target_id: newUser.id,
|
| 44 |
+
detail: { username, role, provider, email }, ip_address: ipAddress,
|
| 45 |
+
})
|
| 46 |
+
|
| 47 |
+
return NextResponse.json({
|
| 48 |
+
user: {
|
| 49 |
+
id: newUser.id,
|
| 50 |
+
username: newUser.username,
|
| 51 |
+
display_name: newUser.display_name,
|
| 52 |
+
role: newUser.role,
|
| 53 |
+
provider: newUser.provider || 'local',
|
| 54 |
+
email: newUser.email || null,
|
| 55 |
+
avatar_url: newUser.avatar_url || null,
|
| 56 |
+
is_approved: newUser.is_approved ?? 1,
|
| 57 |
+
}
|
| 58 |
+
}, { status: 201 })
|
| 59 |
+
} catch (error: any) {
|
| 60 |
+
if (error.message?.includes('UNIQUE constraint failed')) {
|
| 61 |
+
return NextResponse.json({ error: 'Username already exists' }, { status: 409 })
|
| 62 |
+
}
|
| 63 |
+
console.error('POST /api/auth/users error:', error)
|
| 64 |
+
return NextResponse.json({ error: 'Failed to create user' }, { status: 500 })
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* PUT /api/auth/users - Update a user (admin only)
|
| 70 |
+
*/
|
| 71 |
+
export async function PUT(request: NextRequest) {
|
| 72 |
+
const currentUser = getUserFromRequest(request)
|
| 73 |
+
if (!currentUser || currentUser.role !== 'admin') {
|
| 74 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
try {
|
| 78 |
+
const { id, display_name, role, password, is_approved, email, avatar_url } = await request.json()
|
| 79 |
+
|
| 80 |
+
if (!id) {
|
| 81 |
+
return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if (role && !['admin', 'operator', 'viewer'].includes(role)) {
|
| 85 |
+
return NextResponse.json({ error: 'Invalid role' }, { status: 400 })
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Prevent demoting yourself
|
| 89 |
+
if (id === currentUser.id && role && role !== currentUser.role) {
|
| 90 |
+
return NextResponse.json({ error: 'Cannot change your own role' }, { status: 400 })
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
const updated = updateUser(id, { display_name, role, password: password || undefined, is_approved, email, avatar_url })
|
| 94 |
+
if (!updated) {
|
| 95 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 99 |
+
logAuditEvent({
|
| 100 |
+
action: 'user_update', actor: currentUser.username, actor_id: currentUser.id,
|
| 101 |
+
target_type: 'user', target_id: id,
|
| 102 |
+
detail: { display_name, role, password_changed: !!password, is_approved }, ip_address: ipAddress,
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
return NextResponse.json({
|
| 106 |
+
user: {
|
| 107 |
+
id: updated.id,
|
| 108 |
+
username: updated.username,
|
| 109 |
+
display_name: updated.display_name,
|
| 110 |
+
role: updated.role,
|
| 111 |
+
provider: updated.provider || 'local',
|
| 112 |
+
email: updated.email || null,
|
| 113 |
+
avatar_url: updated.avatar_url || null,
|
| 114 |
+
is_approved: updated.is_approved ?? 1,
|
| 115 |
+
}
|
| 116 |
+
})
|
| 117 |
+
} catch (error) {
|
| 118 |
+
console.error('PUT /api/auth/users error:', error)
|
| 119 |
+
return NextResponse.json({ error: 'Failed to update user' }, { status: 500 })
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* DELETE /api/auth/users - Delete a user (admin only)
|
| 125 |
+
*/
|
| 126 |
+
export async function DELETE(request: NextRequest) {
|
| 127 |
+
const currentUser = getUserFromRequest(request)
|
| 128 |
+
if (!currentUser || currentUser.role !== 'admin') {
|
| 129 |
+
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const { searchParams } = new URL(request.url)
|
| 133 |
+
const id = searchParams.get('id')
|
| 134 |
+
|
| 135 |
+
if (!id) {
|
| 136 |
+
return NextResponse.json({ error: 'User ID is required' }, { status: 400 })
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
const userId = parseInt(id)
|
| 140 |
+
|
| 141 |
+
// Prevent deleting yourself
|
| 142 |
+
if (userId === currentUser.id) {
|
| 143 |
+
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 })
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const deleted = deleteUser(userId)
|
| 147 |
+
if (!deleted) {
|
| 148 |
+
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 152 |
+
logAuditEvent({
|
| 153 |
+
action: 'user_delete', actor: currentUser.username, actor_id: currentUser.id,
|
| 154 |
+
target_type: 'user', target_id: userId,
|
| 155 |
+
ip_address: ipAddress,
|
| 156 |
+
})
|
| 157 |
+
|
| 158 |
+
return NextResponse.json({ success: true })
|
| 159 |
+
}
|
src/app/api/backup/route.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { config, ensureDirExists } from '@/lib/config'
|
| 5 |
+
import { join, dirname } from 'path'
|
| 6 |
+
import { readdirSync, statSync, unlinkSync } from 'fs'
|
| 7 |
+
|
| 8 |
+
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
| 9 |
+
const MAX_BACKUPS = 10
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* GET /api/backup - List existing backups (admin only)
|
| 13 |
+
*/
|
| 14 |
+
export async function GET(request: NextRequest) {
|
| 15 |
+
const auth = requireRole(request, 'admin')
|
| 16 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 17 |
+
|
| 18 |
+
ensureDirExists(BACKUP_DIR)
|
| 19 |
+
|
| 20 |
+
try {
|
| 21 |
+
const files = readdirSync(BACKUP_DIR)
|
| 22 |
+
.filter(f => f.endsWith('.db'))
|
| 23 |
+
.map(f => {
|
| 24 |
+
const stat = statSync(join(BACKUP_DIR, f))
|
| 25 |
+
return {
|
| 26 |
+
name: f,
|
| 27 |
+
size: stat.size,
|
| 28 |
+
created_at: Math.floor(stat.mtimeMs / 1000),
|
| 29 |
+
}
|
| 30 |
+
})
|
| 31 |
+
.sort((a, b) => b.created_at - a.created_at)
|
| 32 |
+
|
| 33 |
+
return NextResponse.json({ backups: files, dir: BACKUP_DIR })
|
| 34 |
+
} catch {
|
| 35 |
+
return NextResponse.json({ backups: [], dir: BACKUP_DIR })
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* POST /api/backup - Create a new backup (admin only)
|
| 41 |
+
*/
|
| 42 |
+
export async function POST(request: NextRequest) {
|
| 43 |
+
const auth = requireRole(request, 'admin')
|
| 44 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 45 |
+
|
| 46 |
+
ensureDirExists(BACKUP_DIR)
|
| 47 |
+
|
| 48 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
|
| 49 |
+
const backupPath = join(BACKUP_DIR, `mc-backup-${timestamp}.db`)
|
| 50 |
+
|
| 51 |
+
try {
|
| 52 |
+
const db = getDatabase()
|
| 53 |
+
await db.backup(backupPath)
|
| 54 |
+
|
| 55 |
+
const stat = statSync(backupPath)
|
| 56 |
+
|
| 57 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 58 |
+
logAuditEvent({
|
| 59 |
+
action: 'backup_create',
|
| 60 |
+
actor: auth.user.username,
|
| 61 |
+
actor_id: auth.user.id,
|
| 62 |
+
detail: { path: backupPath, size: stat.size },
|
| 63 |
+
ip_address: ipAddress,
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
// Prune old backups beyond MAX_BACKUPS
|
| 67 |
+
pruneOldBackups()
|
| 68 |
+
|
| 69 |
+
return NextResponse.json({
|
| 70 |
+
success: true,
|
| 71 |
+
backup: {
|
| 72 |
+
name: `mc-backup-${timestamp}.db`,
|
| 73 |
+
size: stat.size,
|
| 74 |
+
created_at: Math.floor(stat.mtimeMs / 1000),
|
| 75 |
+
},
|
| 76 |
+
})
|
| 77 |
+
} catch (error: any) {
|
| 78 |
+
console.error('Backup failed:', error)
|
| 79 |
+
return NextResponse.json({ error: `Backup failed: ${error.message}` }, { status: 500 })
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* DELETE /api/backup?name=<filename> - Delete a specific backup (admin only)
|
| 85 |
+
*/
|
| 86 |
+
export async function DELETE(request: NextRequest) {
|
| 87 |
+
const auth = requireRole(request, 'admin')
|
| 88 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 89 |
+
|
| 90 |
+
const { searchParams } = new URL(request.url)
|
| 91 |
+
const name = searchParams.get('name')
|
| 92 |
+
|
| 93 |
+
if (!name || !name.endsWith('.db') || name.includes('/') || name.includes('..')) {
|
| 94 |
+
return NextResponse.json({ error: 'Invalid backup name' }, { status: 400 })
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
try {
|
| 98 |
+
const fullPath = join(BACKUP_DIR, name)
|
| 99 |
+
unlinkSync(fullPath)
|
| 100 |
+
|
| 101 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 102 |
+
logAuditEvent({
|
| 103 |
+
action: 'backup_delete',
|
| 104 |
+
actor: auth.user.username,
|
| 105 |
+
actor_id: auth.user.id,
|
| 106 |
+
detail: { name },
|
| 107 |
+
ip_address: ipAddress,
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
return NextResponse.json({ success: true })
|
| 111 |
+
} catch {
|
| 112 |
+
return NextResponse.json({ error: 'Backup not found' }, { status: 404 })
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function pruneOldBackups() {
|
| 117 |
+
try {
|
| 118 |
+
const files = readdirSync(BACKUP_DIR)
|
| 119 |
+
.filter(f => f.startsWith('mc-backup-') && f.endsWith('.db'))
|
| 120 |
+
.map(f => ({ name: f, mtime: statSync(join(BACKUP_DIR, f)).mtimeMs }))
|
| 121 |
+
.sort((a, b) => b.mtime - a.mtime)
|
| 122 |
+
|
| 123 |
+
for (const file of files.slice(MAX_BACKUPS)) {
|
| 124 |
+
unlinkSync(join(BACKUP_DIR, file.name))
|
| 125 |
+
}
|
| 126 |
+
} catch {
|
| 127 |
+
// Best-effort pruning
|
| 128 |
+
}
|
| 129 |
+
}
|
src/app/api/chat/conversations/route.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase } from '@/lib/db'
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* GET /api/chat/conversations - List conversations derived from messages
|
| 6 |
+
* Query params: agent (filter by participant), limit, offset
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(request: NextRequest) {
|
| 9 |
+
try {
|
| 10 |
+
const db = getDatabase()
|
| 11 |
+
const { searchParams } = new URL(request.url)
|
| 12 |
+
|
| 13 |
+
const agent = searchParams.get('agent')
|
| 14 |
+
const limit = parseInt(searchParams.get('limit') || '50')
|
| 15 |
+
const offset = parseInt(searchParams.get('offset') || '0')
|
| 16 |
+
|
| 17 |
+
let query: string
|
| 18 |
+
const params: any[] = []
|
| 19 |
+
|
| 20 |
+
if (agent) {
|
| 21 |
+
// Get conversations where this agent is a participant
|
| 22 |
+
query = `
|
| 23 |
+
SELECT
|
| 24 |
+
m.conversation_id,
|
| 25 |
+
MAX(m.created_at) as last_message_at,
|
| 26 |
+
COUNT(*) as message_count,
|
| 27 |
+
COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
|
| 28 |
+
SUM(CASE WHEN m.to_agent = ? AND m.read_at IS NULL THEN 1 ELSE 0 END) as unread_count
|
| 29 |
+
FROM messages m
|
| 30 |
+
WHERE m.from_agent = ? OR m.to_agent = ? OR m.to_agent IS NULL
|
| 31 |
+
GROUP BY m.conversation_id
|
| 32 |
+
ORDER BY last_message_at DESC
|
| 33 |
+
LIMIT ? OFFSET ?
|
| 34 |
+
`
|
| 35 |
+
params.push(agent, agent, agent, limit, offset)
|
| 36 |
+
} else {
|
| 37 |
+
query = `
|
| 38 |
+
SELECT
|
| 39 |
+
m.conversation_id,
|
| 40 |
+
MAX(m.created_at) as last_message_at,
|
| 41 |
+
COUNT(*) as message_count,
|
| 42 |
+
COUNT(DISTINCT m.from_agent) + COUNT(DISTINCT CASE WHEN m.to_agent IS NOT NULL THEN m.to_agent END) as participant_count,
|
| 43 |
+
0 as unread_count
|
| 44 |
+
FROM messages m
|
| 45 |
+
GROUP BY m.conversation_id
|
| 46 |
+
ORDER BY last_message_at DESC
|
| 47 |
+
LIMIT ? OFFSET ?
|
| 48 |
+
`
|
| 49 |
+
params.push(limit, offset)
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const conversations = db.prepare(query).all(...params) as any[]
|
| 53 |
+
|
| 54 |
+
// Fetch the last message for each conversation
|
| 55 |
+
const withLastMessage = conversations.map((conv) => {
|
| 56 |
+
const lastMsg = db.prepare(`
|
| 57 |
+
SELECT * FROM messages
|
| 58 |
+
WHERE conversation_id = ?
|
| 59 |
+
ORDER BY created_at DESC
|
| 60 |
+
LIMIT 1
|
| 61 |
+
`).get(conv.conversation_id) as any
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
...conv,
|
| 65 |
+
last_message: lastMsg
|
| 66 |
+
? {
|
| 67 |
+
...lastMsg,
|
| 68 |
+
metadata: lastMsg.metadata ? JSON.parse(lastMsg.metadata) : null
|
| 69 |
+
}
|
| 70 |
+
: null
|
| 71 |
+
}
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
return NextResponse.json({ conversations: withLastMessage, total: withLastMessage.length })
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error('GET /api/chat/conversations error:', error)
|
| 77 |
+
return NextResponse.json({ error: 'Failed to fetch conversations' }, { status: 500 })
|
| 78 |
+
}
|
| 79 |
+
}
|
src/app/api/chat/messages/[id]/route.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase, Message } from '@/lib/db'
|
| 3 |
+
import { requireRole } from '@/lib/auth'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/chat/messages/[id] - Get a single message
|
| 7 |
+
*/
|
| 8 |
+
export async function GET(
|
| 9 |
+
request: NextRequest,
|
| 10 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 11 |
+
) {
|
| 12 |
+
try {
|
| 13 |
+
const db = getDatabase()
|
| 14 |
+
const { id } = await params
|
| 15 |
+
|
| 16 |
+
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
|
| 17 |
+
|
| 18 |
+
if (!message) {
|
| 19 |
+
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
return NextResponse.json({
|
| 23 |
+
message: {
|
| 24 |
+
...message,
|
| 25 |
+
metadata: message.metadata ? JSON.parse(message.metadata) : null
|
| 26 |
+
}
|
| 27 |
+
})
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.error('GET /api/chat/messages/[id] error:', error)
|
| 30 |
+
return NextResponse.json({ error: 'Failed to fetch message' }, { status: 500 })
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* PATCH /api/chat/messages/[id] - Mark message as read
|
| 36 |
+
*/
|
| 37 |
+
export async function PATCH(
|
| 38 |
+
request: NextRequest,
|
| 39 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 40 |
+
) {
|
| 41 |
+
const auth = requireRole(request, 'operator')
|
| 42 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
const db = getDatabase()
|
| 46 |
+
const { id } = await params
|
| 47 |
+
const body = await request.json()
|
| 48 |
+
|
| 49 |
+
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message | undefined
|
| 50 |
+
|
| 51 |
+
if (!message) {
|
| 52 |
+
return NextResponse.json({ error: 'Message not found' }, { status: 404 })
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (body.read) {
|
| 56 |
+
const now = Math.floor(Date.now() / 1000)
|
| 57 |
+
db.prepare('UPDATE messages SET read_at = ? WHERE id = ?').run(now, parseInt(id))
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const updated = db.prepare('SELECT * FROM messages WHERE id = ?').get(parseInt(id)) as Message
|
| 61 |
+
|
| 62 |
+
return NextResponse.json({
|
| 63 |
+
message: {
|
| 64 |
+
...updated,
|
| 65 |
+
metadata: updated.metadata ? JSON.parse(updated.metadata) : null
|
| 66 |
+
}
|
| 67 |
+
})
|
| 68 |
+
} catch (error) {
|
| 69 |
+
console.error('PATCH /api/chat/messages/[id] error:', error)
|
| 70 |
+
return NextResponse.json({ error: 'Failed to update message' }, { status: 500 })
|
| 71 |
+
}
|
| 72 |
+
}
|
src/app/api/chat/messages/route.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { getDatabase, db_helpers, Message } from '@/lib/db'
|
| 3 |
+
import { runOpenClaw } from '@/lib/command'
|
| 4 |
+
import { getAllGatewaySessions } from '@/lib/sessions'
|
| 5 |
+
import { eventBus } from '@/lib/event-bus'
|
| 6 |
+
import { requireRole } from '@/lib/auth'
|
| 7 |
+
|
| 8 |
+
type ForwardInfo = {
|
| 9 |
+
attempted: boolean
|
| 10 |
+
delivered: boolean
|
| 11 |
+
reason?: string
|
| 12 |
+
session?: string
|
| 13 |
+
runId?: string
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const COORDINATOR_AGENT =
|
| 17 |
+
String(process.env.MC_COORDINATOR_AGENT || process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').trim() ||
|
| 18 |
+
'coordinator'
|
| 19 |
+
|
| 20 |
+
function parseGatewayJson(raw: string): any | null {
|
| 21 |
+
const trimmed = String(raw || '').trim()
|
| 22 |
+
if (!trimmed) return null
|
| 23 |
+
const start = trimmed.indexOf('{')
|
| 24 |
+
const end = trimmed.lastIndexOf('}')
|
| 25 |
+
if (start < 0 || end < start) return null
|
| 26 |
+
try {
|
| 27 |
+
return JSON.parse(trimmed.slice(start, end + 1))
|
| 28 |
+
} catch {
|
| 29 |
+
return null
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function createChatReply(
|
| 34 |
+
db: ReturnType<typeof getDatabase>,
|
| 35 |
+
conversationId: string,
|
| 36 |
+
fromAgent: string,
|
| 37 |
+
toAgent: string,
|
| 38 |
+
content: string,
|
| 39 |
+
messageType: 'text' | 'status' = 'status',
|
| 40 |
+
metadata: Record<string, any> | null = null
|
| 41 |
+
) {
|
| 42 |
+
const replyInsert = db
|
| 43 |
+
.prepare(`
|
| 44 |
+
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
|
| 45 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 46 |
+
`)
|
| 47 |
+
.run(
|
| 48 |
+
conversationId,
|
| 49 |
+
fromAgent,
|
| 50 |
+
toAgent,
|
| 51 |
+
content,
|
| 52 |
+
messageType,
|
| 53 |
+
metadata ? JSON.stringify(metadata) : null
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
const row = db
|
| 57 |
+
.prepare('SELECT * FROM messages WHERE id = ?')
|
| 58 |
+
.get(replyInsert.lastInsertRowid) as Message
|
| 59 |
+
|
| 60 |
+
eventBus.broadcast('chat.message', {
|
| 61 |
+
...row,
|
| 62 |
+
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
| 63 |
+
})
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function extractReplyText(waitPayload: any): string | null {
|
| 67 |
+
if (!waitPayload || typeof waitPayload !== 'object') return null
|
| 68 |
+
|
| 69 |
+
const directCandidates = [
|
| 70 |
+
waitPayload.text,
|
| 71 |
+
waitPayload.message,
|
| 72 |
+
waitPayload.response,
|
| 73 |
+
waitPayload.output,
|
| 74 |
+
waitPayload.result,
|
| 75 |
+
]
|
| 76 |
+
for (const value of directCandidates) {
|
| 77 |
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if (typeof waitPayload.output === 'object' && waitPayload.output) {
|
| 81 |
+
const nested = [
|
| 82 |
+
waitPayload.output.text,
|
| 83 |
+
waitPayload.output.message,
|
| 84 |
+
waitPayload.output.content,
|
| 85 |
+
]
|
| 86 |
+
for (const value of nested) {
|
| 87 |
+
if (typeof value === 'string' && value.trim()) return value.trim()
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
return null
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* GET /api/chat/messages - List messages with filters
|
| 96 |
+
* Query params: conversation_id, from_agent, to_agent, limit, offset, since
|
| 97 |
+
*/
|
| 98 |
+
export async function GET(request: NextRequest) {
|
| 99 |
+
try {
|
| 100 |
+
const db = getDatabase()
|
| 101 |
+
const { searchParams } = new URL(request.url)
|
| 102 |
+
|
| 103 |
+
const conversation_id = searchParams.get('conversation_id')
|
| 104 |
+
const from_agent = searchParams.get('from_agent')
|
| 105 |
+
const to_agent = searchParams.get('to_agent')
|
| 106 |
+
const limit = parseInt(searchParams.get('limit') || '50')
|
| 107 |
+
const offset = parseInt(searchParams.get('offset') || '0')
|
| 108 |
+
const since = searchParams.get('since')
|
| 109 |
+
|
| 110 |
+
let query = 'SELECT * FROM messages WHERE 1=1'
|
| 111 |
+
const params: any[] = []
|
| 112 |
+
|
| 113 |
+
if (conversation_id) {
|
| 114 |
+
query += ' AND conversation_id = ?'
|
| 115 |
+
params.push(conversation_id)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
if (from_agent) {
|
| 119 |
+
query += ' AND from_agent = ?'
|
| 120 |
+
params.push(from_agent)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (to_agent) {
|
| 124 |
+
query += ' AND to_agent = ?'
|
| 125 |
+
params.push(to_agent)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
if (since) {
|
| 129 |
+
query += ' AND created_at > ?'
|
| 130 |
+
params.push(parseInt(since))
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
query += ' ORDER BY created_at ASC LIMIT ? OFFSET ?'
|
| 134 |
+
params.push(limit, offset)
|
| 135 |
+
|
| 136 |
+
const messages = db.prepare(query).all(...params) as Message[]
|
| 137 |
+
|
| 138 |
+
const parsed = messages.map((msg) => ({
|
| 139 |
+
...msg,
|
| 140 |
+
metadata: msg.metadata ? JSON.parse(msg.metadata) : null
|
| 141 |
+
}))
|
| 142 |
+
|
| 143 |
+
return NextResponse.json({ messages: parsed, total: parsed.length })
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.error('GET /api/chat/messages error:', error)
|
| 146 |
+
return NextResponse.json({ error: 'Failed to fetch messages' }, { status: 500 })
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* POST /api/chat/messages - Send a new message
|
| 152 |
+
* Body: { from, to, content, message_type, conversation_id, metadata }
|
| 153 |
+
*/
|
| 154 |
+
export async function POST(request: NextRequest) {
|
| 155 |
+
const auth = requireRole(request, 'operator')
|
| 156 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 157 |
+
|
| 158 |
+
try {
|
| 159 |
+
const db = getDatabase()
|
| 160 |
+
const body = await request.json()
|
| 161 |
+
|
| 162 |
+
const from = (body.from || '').trim()
|
| 163 |
+
const to = body.to ? (body.to as string).trim() : null
|
| 164 |
+
const content = (body.content || '').trim()
|
| 165 |
+
const message_type = body.message_type || 'text'
|
| 166 |
+
const conversation_id = body.conversation_id || `conv_${Date.now()}`
|
| 167 |
+
const metadata = body.metadata || null
|
| 168 |
+
|
| 169 |
+
if (!from || !content) {
|
| 170 |
+
return NextResponse.json(
|
| 171 |
+
{ error: '"from" and "content" are required' },
|
| 172 |
+
{ status: 400 }
|
| 173 |
+
)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const stmt = db.prepare(`
|
| 177 |
+
INSERT INTO messages (conversation_id, from_agent, to_agent, content, message_type, metadata)
|
| 178 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 179 |
+
`)
|
| 180 |
+
|
| 181 |
+
const result = stmt.run(
|
| 182 |
+
conversation_id,
|
| 183 |
+
from,
|
| 184 |
+
to,
|
| 185 |
+
content,
|
| 186 |
+
message_type,
|
| 187 |
+
metadata ? JSON.stringify(metadata) : null
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
const messageId = result.lastInsertRowid as number
|
| 191 |
+
|
| 192 |
+
let forwardInfo: ForwardInfo | null = null
|
| 193 |
+
|
| 194 |
+
// Log activity
|
| 195 |
+
db_helpers.logActivity(
|
| 196 |
+
'chat_message',
|
| 197 |
+
'message',
|
| 198 |
+
messageId,
|
| 199 |
+
from,
|
| 200 |
+
`Sent ${message_type} message${to ? ` to ${to}` : ' (broadcast)'}`,
|
| 201 |
+
{ conversation_id, to, message_type }
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
// Create notification for recipient if specified
|
| 205 |
+
if (to) {
|
| 206 |
+
db_helpers.createNotification(
|
| 207 |
+
to,
|
| 208 |
+
'chat_message',
|
| 209 |
+
`Message from ${from}`,
|
| 210 |
+
content.substring(0, 200) + (content.length > 200 ? '...' : ''),
|
| 211 |
+
'message',
|
| 212 |
+
messageId
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
// Optionally forward to agent via gateway
|
| 216 |
+
if (body.forward) {
|
| 217 |
+
forwardInfo = { attempted: true, delivered: false }
|
| 218 |
+
|
| 219 |
+
const agent = db
|
| 220 |
+
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?)')
|
| 221 |
+
.get(to) as any
|
| 222 |
+
|
| 223 |
+
let sessionKey: string | null = agent?.session_key || null
|
| 224 |
+
|
| 225 |
+
// Fallback: derive session from on-disk gateway session stores
|
| 226 |
+
if (!sessionKey) {
|
| 227 |
+
const sessions = getAllGatewaySessions()
|
| 228 |
+
const match = sessions.find(
|
| 229 |
+
(s) => s.agent.toLowerCase() === String(to).toLowerCase()
|
| 230 |
+
)
|
| 231 |
+
sessionKey = match?.key || match?.sessionId || null
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// Prefer configured openclawId when present, fallback to normalized name
|
| 235 |
+
let openclawAgentId: string | null = null
|
| 236 |
+
if (agent?.config) {
|
| 237 |
+
try {
|
| 238 |
+
const cfg = JSON.parse(agent.config)
|
| 239 |
+
if (cfg?.openclawId && typeof cfg.openclawId === 'string') {
|
| 240 |
+
openclawAgentId = cfg.openclawId
|
| 241 |
+
}
|
| 242 |
+
} catch {
|
| 243 |
+
// ignore parse issues
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
if (!openclawAgentId && typeof to === 'string') {
|
| 247 |
+
openclawAgentId = to.toLowerCase().replace(/\s+/g, '-')
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
if (!sessionKey && !openclawAgentId) {
|
| 251 |
+
forwardInfo.reason = 'no_active_session'
|
| 252 |
+
|
| 253 |
+
// For coordinator messages, emit an immediate visible status reply
|
| 254 |
+
if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
|
| 255 |
+
try {
|
| 256 |
+
createChatReply(
|
| 257 |
+
db,
|
| 258 |
+
conversation_id,
|
| 259 |
+
COORDINATOR_AGENT,
|
| 260 |
+
from,
|
| 261 |
+
'I received your message, but my live coordinator session is offline right now. Start/restore the coordinator session and retry.',
|
| 262 |
+
'status',
|
| 263 |
+
{ status: 'offline', reason: 'no_active_session' }
|
| 264 |
+
)
|
| 265 |
+
} catch (e) {
|
| 266 |
+
console.error('Failed to create offline status reply:', e)
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
} else {
|
| 270 |
+
try {
|
| 271 |
+
const invokeParams: any = {
|
| 272 |
+
message: `Message from ${from}: ${content}`,
|
| 273 |
+
idempotencyKey: `mc-${messageId}-${Date.now()}`,
|
| 274 |
+
deliver: false,
|
| 275 |
+
}
|
| 276 |
+
if (sessionKey) invokeParams.sessionKey = sessionKey
|
| 277 |
+
else invokeParams.agentId = openclawAgentId
|
| 278 |
+
|
| 279 |
+
const invokeResult = await runOpenClaw(
|
| 280 |
+
[
|
| 281 |
+
'gateway',
|
| 282 |
+
'call',
|
| 283 |
+
'agent',
|
| 284 |
+
'--timeout',
|
| 285 |
+
'10000',
|
| 286 |
+
'--params',
|
| 287 |
+
JSON.stringify(invokeParams),
|
| 288 |
+
'--json',
|
| 289 |
+
],
|
| 290 |
+
{ timeoutMs: 12000 }
|
| 291 |
+
)
|
| 292 |
+
const acceptedPayload = parseGatewayJson(invokeResult.stdout)
|
| 293 |
+
forwardInfo.delivered = true
|
| 294 |
+
forwardInfo.session = sessionKey || openclawAgentId || undefined
|
| 295 |
+
if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) {
|
| 296 |
+
forwardInfo.runId = acceptedPayload.runId
|
| 297 |
+
}
|
| 298 |
+
} catch (err) {
|
| 299 |
+
// OpenClaw may return accepted JSON on stdout but still emit a late stderr warning.
|
| 300 |
+
// Treat accepted runs as successful delivery.
|
| 301 |
+
const maybeStdout = String((err as any)?.stdout || '')
|
| 302 |
+
const acceptedPayload = parseGatewayJson(maybeStdout)
|
| 303 |
+
if (maybeStdout.includes('"status": "accepted"') || maybeStdout.includes('"status":"accepted"')) {
|
| 304 |
+
forwardInfo.delivered = true
|
| 305 |
+
forwardInfo.session = sessionKey || openclawAgentId || undefined
|
| 306 |
+
if (typeof acceptedPayload?.runId === 'string' && acceptedPayload.runId) {
|
| 307 |
+
forwardInfo.runId = acceptedPayload.runId
|
| 308 |
+
}
|
| 309 |
+
} else {
|
| 310 |
+
forwardInfo.reason = 'gateway_send_failed'
|
| 311 |
+
console.error('Failed to forward message via gateway:', err)
|
| 312 |
+
|
| 313 |
+
// For coordinator messages, emit visible status when send fails
|
| 314 |
+
if (typeof conversation_id === 'string' && conversation_id.startsWith('coord:')) {
|
| 315 |
+
try {
|
| 316 |
+
createChatReply(
|
| 317 |
+
db,
|
| 318 |
+
conversation_id,
|
| 319 |
+
COORDINATOR_AGENT,
|
| 320 |
+
from,
|
| 321 |
+
'I received your message, but delivery to the live coordinator runtime failed. Please restart the coordinator/gateway session and retry.',
|
| 322 |
+
'status',
|
| 323 |
+
{ status: 'delivery_failed', reason: 'gateway_send_failed' }
|
| 324 |
+
)
|
| 325 |
+
} catch (e) {
|
| 326 |
+
console.error('Failed to create gateway failure status reply:', e)
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
// Coordinator mode should always show visible coordinator feedback in thread.
|
| 333 |
+
if (
|
| 334 |
+
typeof conversation_id === 'string' &&
|
| 335 |
+
conversation_id.startsWith('coord:') &&
|
| 336 |
+
forwardInfo.delivered
|
| 337 |
+
) {
|
| 338 |
+
try {
|
| 339 |
+
createChatReply(
|
| 340 |
+
db,
|
| 341 |
+
conversation_id,
|
| 342 |
+
COORDINATOR_AGENT,
|
| 343 |
+
from,
|
| 344 |
+
'Received. I am coordinating downstream agents now.',
|
| 345 |
+
'status',
|
| 346 |
+
{ status: 'accepted', runId: forwardInfo.runId || null }
|
| 347 |
+
)
|
| 348 |
+
} catch (e) {
|
| 349 |
+
console.error('Failed to create accepted status reply:', e)
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// Best effort: wait briefly and surface completion/error feedback.
|
| 353 |
+
if (forwardInfo.runId) {
|
| 354 |
+
try {
|
| 355 |
+
const waitResult = await runOpenClaw(
|
| 356 |
+
[
|
| 357 |
+
'gateway',
|
| 358 |
+
'call',
|
| 359 |
+
'agent.wait',
|
| 360 |
+
'--timeout',
|
| 361 |
+
'8000',
|
| 362 |
+
'--params',
|
| 363 |
+
JSON.stringify({ runId: forwardInfo.runId, timeoutMs: 6000 }),
|
| 364 |
+
'--json',
|
| 365 |
+
],
|
| 366 |
+
{ timeoutMs: 9000 }
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
const waitPayload = parseGatewayJson(waitResult.stdout)
|
| 370 |
+
const waitStatus = String(waitPayload?.status || '').toLowerCase()
|
| 371 |
+
|
| 372 |
+
if (waitStatus === 'error') {
|
| 373 |
+
const reason =
|
| 374 |
+
typeof waitPayload?.error === 'string'
|
| 375 |
+
? waitPayload.error
|
| 376 |
+
: 'Unknown runtime error'
|
| 377 |
+
createChatReply(
|
| 378 |
+
db,
|
| 379 |
+
conversation_id,
|
| 380 |
+
COORDINATOR_AGENT,
|
| 381 |
+
from,
|
| 382 |
+
`I received your message, but execution failed: ${reason}`,
|
| 383 |
+
'status',
|
| 384 |
+
{ status: 'error', runId: forwardInfo.runId }
|
| 385 |
+
)
|
| 386 |
+
} else if (waitStatus === 'timeout') {
|
| 387 |
+
createChatReply(
|
| 388 |
+
db,
|
| 389 |
+
conversation_id,
|
| 390 |
+
COORDINATOR_AGENT,
|
| 391 |
+
from,
|
| 392 |
+
'I received your message and I am still processing it. I will post results as soon as execution completes.',
|
| 393 |
+
'status',
|
| 394 |
+
{ status: 'processing', runId: forwardInfo.runId }
|
| 395 |
+
)
|
| 396 |
+
} else {
|
| 397 |
+
const replyText = extractReplyText(waitPayload)
|
| 398 |
+
if (replyText) {
|
| 399 |
+
createChatReply(
|
| 400 |
+
db,
|
| 401 |
+
conversation_id,
|
| 402 |
+
COORDINATOR_AGENT,
|
| 403 |
+
from,
|
| 404 |
+
replyText,
|
| 405 |
+
'text',
|
| 406 |
+
{ status: waitStatus || 'completed', runId: forwardInfo.runId }
|
| 407 |
+
)
|
| 408 |
+
} else {
|
| 409 |
+
createChatReply(
|
| 410 |
+
db,
|
| 411 |
+
conversation_id,
|
| 412 |
+
COORDINATOR_AGENT,
|
| 413 |
+
from,
|
| 414 |
+
'Execution accepted and completed. No textual response payload was returned by the runtime.',
|
| 415 |
+
'status',
|
| 416 |
+
{ status: waitStatus || 'completed', runId: forwardInfo.runId }
|
| 417 |
+
)
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
} catch (waitErr) {
|
| 421 |
+
const maybeWaitStdout = String((waitErr as any)?.stdout || '')
|
| 422 |
+
const maybeWaitStderr = String((waitErr as any)?.stderr || '')
|
| 423 |
+
const waitPayload = parseGatewayJson(maybeWaitStdout)
|
| 424 |
+
const reason =
|
| 425 |
+
typeof waitPayload?.error === 'string'
|
| 426 |
+
? waitPayload.error
|
| 427 |
+
: (maybeWaitStderr || maybeWaitStdout || 'Unable to read completion status from coordinator runtime.').trim()
|
| 428 |
+
|
| 429 |
+
createChatReply(
|
| 430 |
+
db,
|
| 431 |
+
conversation_id,
|
| 432 |
+
COORDINATOR_AGENT,
|
| 433 |
+
from,
|
| 434 |
+
`I received your message, but I could not retrieve completion output yet: ${reason}`,
|
| 435 |
+
'status',
|
| 436 |
+
{ status: 'unknown', runId: forwardInfo.runId }
|
| 437 |
+
)
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
const created = db.prepare('SELECT * FROM messages WHERE id = ?').get(messageId) as Message
|
| 446 |
+
const parsedMessage = {
|
| 447 |
+
...created,
|
| 448 |
+
metadata: created.metadata ? JSON.parse(created.metadata) : null
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// Broadcast to SSE clients
|
| 452 |
+
eventBus.broadcast('chat.message', parsedMessage)
|
| 453 |
+
|
| 454 |
+
return NextResponse.json({ message: parsedMessage, forward: forwardInfo }, { status: 201 })
|
| 455 |
+
} catch (error) {
|
| 456 |
+
console.error('POST /api/chat/messages error:', error)
|
| 457 |
+
return NextResponse.json({ error: 'Failed to send message' }, { status: 500 })
|
| 458 |
+
}
|
| 459 |
+
}
|
src/app/api/cleanup/route.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { config } from '@/lib/config'
|
| 5 |
+
|
| 6 |
+
interface CleanupResult {
|
| 7 |
+
table: string
|
| 8 |
+
deleted: number
|
| 9 |
+
cutoff_date: string
|
| 10 |
+
retention_days: number
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* GET /api/cleanup - Show retention policy and what would be cleaned
|
| 15 |
+
*/
|
| 16 |
+
export async function GET(request: NextRequest) {
|
| 17 |
+
const auth = requireRole(request, 'admin')
|
| 18 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 19 |
+
|
| 20 |
+
const db = getDatabase()
|
| 21 |
+
const now = Math.floor(Date.now() / 1000)
|
| 22 |
+
const ret = config.retention
|
| 23 |
+
|
| 24 |
+
const preview = []
|
| 25 |
+
|
| 26 |
+
for (const { table, column, days, label } of getRetentionTargets()) {
|
| 27 |
+
if (days <= 0) {
|
| 28 |
+
preview.push({ table: label, retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
|
| 29 |
+
continue
|
| 30 |
+
}
|
| 31 |
+
const cutoff = now - days * 86400
|
| 32 |
+
try {
|
| 33 |
+
const row = db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE ${column} < ?`).get(cutoff) as any
|
| 34 |
+
preview.push({
|
| 35 |
+
table: label,
|
| 36 |
+
retention_days: days,
|
| 37 |
+
cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0],
|
| 38 |
+
stale_count: row.c,
|
| 39 |
+
})
|
| 40 |
+
} catch {
|
| 41 |
+
preview.push({ table: label, retention_days: days, stale_count: 0, note: 'Table not found' })
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Token usage file stats
|
| 46 |
+
try {
|
| 47 |
+
const { readFile } = require('fs/promises')
|
| 48 |
+
const data = JSON.parse(await readFile(config.tokensPath, 'utf-8'))
|
| 49 |
+
const cutoffMs = Date.now() - ret.tokenUsage * 86400000
|
| 50 |
+
const stale = data.filter((r: any) => r.timestamp < cutoffMs).length
|
| 51 |
+
preview.push({
|
| 52 |
+
table: 'Token Usage (file)',
|
| 53 |
+
retention_days: ret.tokenUsage,
|
| 54 |
+
cutoff_date: new Date(cutoffMs).toISOString().split('T')[0],
|
| 55 |
+
stale_count: stale,
|
| 56 |
+
})
|
| 57 |
+
} catch {
|
| 58 |
+
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return NextResponse.json({ retention: config.retention, preview })
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* POST /api/cleanup - Run cleanup (admin only)
|
| 66 |
+
* Body: { dry_run?: boolean }
|
| 67 |
+
*/
|
| 68 |
+
export async function POST(request: NextRequest) {
|
| 69 |
+
const auth = requireRole(request, 'admin')
|
| 70 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 71 |
+
|
| 72 |
+
const body = await request.json().catch(() => ({}))
|
| 73 |
+
const dryRun = body.dry_run === true
|
| 74 |
+
|
| 75 |
+
const db = getDatabase()
|
| 76 |
+
const now = Math.floor(Date.now() / 1000)
|
| 77 |
+
const results: CleanupResult[] = []
|
| 78 |
+
let totalDeleted = 0
|
| 79 |
+
|
| 80 |
+
for (const { table, column, days, label } of getRetentionTargets()) {
|
| 81 |
+
if (days <= 0) continue
|
| 82 |
+
const cutoff = now - days * 86400
|
| 83 |
+
|
| 84 |
+
try {
|
| 85 |
+
if (dryRun) {
|
| 86 |
+
const row = db.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE ${column} < ?`).get(cutoff) as any
|
| 87 |
+
results.push({
|
| 88 |
+
table: label,
|
| 89 |
+
deleted: row.c,
|
| 90 |
+
cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0],
|
| 91 |
+
retention_days: days,
|
| 92 |
+
})
|
| 93 |
+
totalDeleted += row.c
|
| 94 |
+
} else {
|
| 95 |
+
const res = db.prepare(`DELETE FROM ${table} WHERE ${column} < ?`).run(cutoff)
|
| 96 |
+
results.push({
|
| 97 |
+
table: label,
|
| 98 |
+
deleted: res.changes,
|
| 99 |
+
cutoff_date: new Date(cutoff * 1000).toISOString().split('T')[0],
|
| 100 |
+
retention_days: days,
|
| 101 |
+
})
|
| 102 |
+
totalDeleted += res.changes
|
| 103 |
+
}
|
| 104 |
+
} catch {
|
| 105 |
+
results.push({ table: label, deleted: 0, cutoff_date: '', retention_days: days })
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Clean token usage file
|
| 110 |
+
const ret = config.retention
|
| 111 |
+
if (ret.tokenUsage > 0) {
|
| 112 |
+
try {
|
| 113 |
+
const { readFile, writeFile } = require('fs/promises')
|
| 114 |
+
const raw = await readFile(config.tokensPath, 'utf-8')
|
| 115 |
+
const data = JSON.parse(raw)
|
| 116 |
+
const cutoffMs = Date.now() - ret.tokenUsage * 86400000
|
| 117 |
+
const kept = data.filter((r: any) => r.timestamp >= cutoffMs)
|
| 118 |
+
const removed = data.length - kept.length
|
| 119 |
+
|
| 120 |
+
if (!dryRun && removed > 0) {
|
| 121 |
+
await writeFile(config.tokensPath, JSON.stringify(kept, null, 2))
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
results.push({
|
| 125 |
+
table: 'Token Usage (file)',
|
| 126 |
+
deleted: removed,
|
| 127 |
+
cutoff_date: new Date(cutoffMs).toISOString().split('T')[0],
|
| 128 |
+
retention_days: ret.tokenUsage,
|
| 129 |
+
})
|
| 130 |
+
totalDeleted += removed
|
| 131 |
+
} catch {
|
| 132 |
+
// No token file or parse error
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
if (!dryRun && totalDeleted > 0) {
|
| 137 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 138 |
+
logAuditEvent({
|
| 139 |
+
action: 'data_cleanup',
|
| 140 |
+
actor: auth.user.username,
|
| 141 |
+
actor_id: auth.user.id,
|
| 142 |
+
detail: { total_deleted: totalDeleted, results },
|
| 143 |
+
ip_address: ipAddress,
|
| 144 |
+
})
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
return NextResponse.json({
|
| 148 |
+
dry_run: dryRun,
|
| 149 |
+
total_deleted: totalDeleted,
|
| 150 |
+
results,
|
| 151 |
+
})
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function getRetentionTargets() {
|
| 155 |
+
const ret = config.retention
|
| 156 |
+
return [
|
| 157 |
+
{ table: 'activities', column: 'created_at', days: ret.activities, label: 'Activities' },
|
| 158 |
+
{ table: 'audit_log', column: 'created_at', days: ret.auditLog, label: 'Audit Log' },
|
| 159 |
+
{ table: 'notifications', column: 'created_at', days: ret.notifications, label: 'Notifications' },
|
| 160 |
+
{ table: 'pipeline_runs', column: 'created_at', days: ret.pipelineRuns, label: 'Pipeline Runs' },
|
| 161 |
+
]
|
| 162 |
+
}
|
src/app/api/cron/route.ts
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { config } from '@/lib/config'
|
| 4 |
+
import fs from 'node:fs'
|
| 5 |
+
import path from 'node:path'
|
| 6 |
+
|
| 7 |
+
interface CronJob {
|
| 8 |
+
name: string
|
| 9 |
+
schedule: string
|
| 10 |
+
command: string
|
| 11 |
+
enabled: boolean
|
| 12 |
+
lastRun?: number
|
| 13 |
+
nextRun?: number
|
| 14 |
+
lastStatus?: 'success' | 'error' | 'running'
|
| 15 |
+
lastError?: string
|
| 16 |
+
// Extended fields from OpenClaw format
|
| 17 |
+
id?: string
|
| 18 |
+
agentId?: string
|
| 19 |
+
timezone?: string
|
| 20 |
+
model?: string
|
| 21 |
+
delivery?: string
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* OpenClaw cron jobs live in ~/.openclaw/cron/jobs.json
|
| 26 |
+
* Format: { version: 1, jobs: [ { id, agentId, name, enabled, schedule: { kind, expr, tz }, payload, delivery, state } ] }
|
| 27 |
+
*/
|
| 28 |
+
interface OpenClawCronJob {
|
| 29 |
+
id: string
|
| 30 |
+
agentId: string
|
| 31 |
+
name: string
|
| 32 |
+
enabled: boolean
|
| 33 |
+
createdAtMs?: number
|
| 34 |
+
updatedAtMs?: number
|
| 35 |
+
schedule: {
|
| 36 |
+
kind: string
|
| 37 |
+
expr: string
|
| 38 |
+
tz?: string
|
| 39 |
+
}
|
| 40 |
+
sessionTarget?: string
|
| 41 |
+
wakeMode?: string
|
| 42 |
+
payload: {
|
| 43 |
+
kind: string
|
| 44 |
+
message?: string
|
| 45 |
+
model?: string
|
| 46 |
+
thinking?: string
|
| 47 |
+
timeoutSeconds?: number
|
| 48 |
+
}
|
| 49 |
+
delivery?: {
|
| 50 |
+
mode: string
|
| 51 |
+
channel?: string
|
| 52 |
+
to?: string
|
| 53 |
+
}
|
| 54 |
+
state?: {
|
| 55 |
+
nextRunAtMs?: number
|
| 56 |
+
lastRunAtMs?: number
|
| 57 |
+
lastStatus?: string
|
| 58 |
+
lastDurationMs?: number
|
| 59 |
+
lastError?: string
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
interface OpenClawCronFile {
|
| 64 |
+
version: number
|
| 65 |
+
jobs: OpenClawCronJob[]
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function getCronFilePath(): string {
|
| 69 |
+
const openclawHome = config.openclawHome
|
| 70 |
+
if (!openclawHome) return ''
|
| 71 |
+
return path.join(openclawHome, 'cron', 'jobs.json')
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function loadCronFile(): OpenClawCronFile | null {
|
| 75 |
+
const filePath = getCronFilePath()
|
| 76 |
+
if (!filePath) return null
|
| 77 |
+
try {
|
| 78 |
+
const raw = fs.readFileSync(filePath, 'utf-8')
|
| 79 |
+
return JSON.parse(raw)
|
| 80 |
+
} catch {
|
| 81 |
+
return null
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function saveCronFile(data: OpenClawCronFile): boolean {
|
| 86 |
+
const filePath = getCronFilePath()
|
| 87 |
+
if (!filePath) return false
|
| 88 |
+
try {
|
| 89 |
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
|
| 90 |
+
return true
|
| 91 |
+
} catch (err) {
|
| 92 |
+
console.error('Failed to write cron file:', err)
|
| 93 |
+
return false
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
function mapLastStatus(status?: string): 'success' | 'error' | 'running' | undefined {
|
| 98 |
+
if (!status) return undefined
|
| 99 |
+
const s = status.toLowerCase()
|
| 100 |
+
if (s === 'success' || s === 'completed' || s === 'updated') return 'success'
|
| 101 |
+
if (s === 'error' || s === 'failed') return 'error'
|
| 102 |
+
if (s === 'running' || s === 'pending') return 'running'
|
| 103 |
+
return 'success' // default for unknown non-error statuses
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function mapOpenClawJob(job: OpenClawCronJob): CronJob {
|
| 107 |
+
// Build a human-readable command description from the payload
|
| 108 |
+
const payloadSummary = job.payload.message
|
| 109 |
+
? job.payload.message.slice(0, 200) + (job.payload.message.length > 200 ? '...' : '')
|
| 110 |
+
: `${job.payload.kind} (${job.agentId})`
|
| 111 |
+
|
| 112 |
+
const scheduleStr = job.schedule.tz
|
| 113 |
+
? `${job.schedule.expr} (${job.schedule.tz})`
|
| 114 |
+
: job.schedule.expr
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
id: job.id,
|
| 118 |
+
name: job.name,
|
| 119 |
+
schedule: scheduleStr,
|
| 120 |
+
command: payloadSummary,
|
| 121 |
+
enabled: job.enabled,
|
| 122 |
+
lastRun: job.state?.lastRunAtMs,
|
| 123 |
+
nextRun: job.state?.nextRunAtMs,
|
| 124 |
+
lastStatus: mapLastStatus(job.state?.lastStatus),
|
| 125 |
+
lastError: job.state?.lastError,
|
| 126 |
+
agentId: job.agentId,
|
| 127 |
+
timezone: job.schedule.tz,
|
| 128 |
+
model: job.payload.model,
|
| 129 |
+
delivery: job.delivery?.mode === 'none' ? undefined : job.delivery?.channel,
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export async function GET(request: NextRequest) {
|
| 134 |
+
try {
|
| 135 |
+
const { searchParams } = new URL(request.url)
|
| 136 |
+
const action = searchParams.get('action')
|
| 137 |
+
|
| 138 |
+
if (action === 'list') {
|
| 139 |
+
const cronFile = loadCronFile()
|
| 140 |
+
if (!cronFile || !cronFile.jobs) {
|
| 141 |
+
return NextResponse.json({ jobs: [] })
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const jobs = cronFile.jobs.map(mapOpenClawJob)
|
| 145 |
+
return NextResponse.json({ jobs })
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
if (action === 'logs') {
|
| 149 |
+
const jobId = searchParams.get('job')
|
| 150 |
+
if (!jobId) {
|
| 151 |
+
return NextResponse.json({ error: 'Job ID required' }, { status: 400 })
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Find the job to get its state info
|
| 155 |
+
const cronFile = loadCronFile()
|
| 156 |
+
const job = cronFile?.jobs.find(j => j.id === jobId || j.name === jobId)
|
| 157 |
+
|
| 158 |
+
const logs: Array<{ timestamp: number; message: string; level: string }> = []
|
| 159 |
+
|
| 160 |
+
if (job?.state) {
|
| 161 |
+
if (job.state.lastRunAtMs) {
|
| 162 |
+
logs.push({
|
| 163 |
+
timestamp: job.state.lastRunAtMs,
|
| 164 |
+
message: `Job executed — status: ${job.state.lastStatus || 'unknown'}${job.state.lastDurationMs ? ` (${job.state.lastDurationMs}ms)` : ''}`,
|
| 165 |
+
level: job.state.lastStatus === 'error' || job.state.lastStatus === 'failed' ? 'error' : 'info',
|
| 166 |
+
})
|
| 167 |
+
}
|
| 168 |
+
if (job.state.lastError) {
|
| 169 |
+
logs.push({
|
| 170 |
+
timestamp: job.state.lastRunAtMs || Date.now(),
|
| 171 |
+
message: `Error: ${job.state.lastError}`,
|
| 172 |
+
level: 'error',
|
| 173 |
+
})
|
| 174 |
+
}
|
| 175 |
+
if (job.state.nextRunAtMs) {
|
| 176 |
+
logs.push({
|
| 177 |
+
timestamp: Date.now(),
|
| 178 |
+
message: `Next scheduled run: ${new Date(job.state.nextRunAtMs).toLocaleString()}`,
|
| 179 |
+
level: 'info',
|
| 180 |
+
})
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
return NextResponse.json({ logs })
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
| 188 |
+
} catch (error) {
|
| 189 |
+
console.error('Cron API error:', error)
|
| 190 |
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
export async function POST(request: NextRequest) {
|
| 195 |
+
const auth = requireRole(request, 'admin')
|
| 196 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
const body = await request.json()
|
| 200 |
+
const { action, jobName, jobId } = body
|
| 201 |
+
|
| 202 |
+
if (action === 'toggle') {
|
| 203 |
+
const id = jobId || jobName
|
| 204 |
+
if (!id) {
|
| 205 |
+
return NextResponse.json({ error: 'Job ID or name required' }, { status: 400 })
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
const cronFile = loadCronFile()
|
| 209 |
+
if (!cronFile) {
|
| 210 |
+
return NextResponse.json({ error: 'Cron file not found' }, { status: 404 })
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const job = cronFile.jobs.find(j => j.id === id || j.name === id)
|
| 214 |
+
if (!job) {
|
| 215 |
+
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
job.enabled = !job.enabled
|
| 219 |
+
job.updatedAtMs = Date.now()
|
| 220 |
+
|
| 221 |
+
if (!saveCronFile(cronFile)) {
|
| 222 |
+
return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 })
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
return NextResponse.json({ success: true, enabled: job.enabled })
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
if (action === 'trigger') {
|
| 229 |
+
const id = jobId || jobName
|
| 230 |
+
if (!id) {
|
| 231 |
+
return NextResponse.json({ error: 'Job ID required' }, { status: 400 })
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if (process.env.MISSION_CONTROL_ALLOW_COMMAND_TRIGGER !== '1') {
|
| 235 |
+
return NextResponse.json(
|
| 236 |
+
{ error: 'Manual triggers disabled. Set MISSION_CONTROL_ALLOW_COMMAND_TRIGGER=1 to enable.' },
|
| 237 |
+
{ status: 403 }
|
| 238 |
+
)
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const cronFile = loadCronFile()
|
| 242 |
+
const job = cronFile?.jobs.find(j => j.id === id || j.name === id)
|
| 243 |
+
if (!job) {
|
| 244 |
+
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// For OpenClaw cron jobs, trigger via the openclaw CLI
|
| 248 |
+
const { runCommand } = await import('@/lib/command')
|
| 249 |
+
try {
|
| 250 |
+
const { stdout, stderr } = await runCommand(config.openclawBin, [
|
| 251 |
+
'cron', 'trigger', job.id
|
| 252 |
+
], { timeoutMs: 30000 })
|
| 253 |
+
|
| 254 |
+
return NextResponse.json({
|
| 255 |
+
success: true,
|
| 256 |
+
stdout: stdout.trim(),
|
| 257 |
+
stderr: stderr.trim()
|
| 258 |
+
})
|
| 259 |
+
} catch (execError: any) {
|
| 260 |
+
return NextResponse.json({
|
| 261 |
+
success: false,
|
| 262 |
+
error: execError.message,
|
| 263 |
+
stdout: execError.stdout?.trim() || '',
|
| 264 |
+
stderr: execError.stderr?.trim() || ''
|
| 265 |
+
}, { status: 500 })
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
if (action === 'remove') {
|
| 270 |
+
const id = jobId || jobName
|
| 271 |
+
if (!id) {
|
| 272 |
+
return NextResponse.json({ error: 'Job ID or name required' }, { status: 400 })
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const cronFile = loadCronFile()
|
| 276 |
+
if (!cronFile) {
|
| 277 |
+
return NextResponse.json({ error: 'Cron file not found' }, { status: 404 })
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const idx = cronFile.jobs.findIndex(j => j.id === id || j.name === id)
|
| 281 |
+
if (idx === -1) {
|
| 282 |
+
return NextResponse.json({ error: 'Job not found' }, { status: 404 })
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
cronFile.jobs.splice(idx, 1)
|
| 286 |
+
|
| 287 |
+
if (!saveCronFile(cronFile)) {
|
| 288 |
+
return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 })
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
return NextResponse.json({ success: true })
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
if (action === 'add') {
|
| 295 |
+
const { schedule, command, description } = body
|
| 296 |
+
const name = jobName || body.name
|
| 297 |
+
if (!schedule || !command || !name) {
|
| 298 |
+
return NextResponse.json(
|
| 299 |
+
{ error: 'Schedule, command, and name required' },
|
| 300 |
+
{ status: 400 }
|
| 301 |
+
)
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
const cronFile = loadCronFile() || { version: 1, jobs: [] }
|
| 305 |
+
|
| 306 |
+
const newJob: OpenClawCronJob = {
|
| 307 |
+
id: `mc-${Date.now().toString(36)}`,
|
| 308 |
+
agentId: String(process.env.MC_CRON_AGENT_ID || process.env.MC_COORDINATOR_AGENT || 'system'),
|
| 309 |
+
name,
|
| 310 |
+
enabled: true,
|
| 311 |
+
createdAtMs: Date.now(),
|
| 312 |
+
updatedAtMs: Date.now(),
|
| 313 |
+
schedule: {
|
| 314 |
+
kind: 'cron',
|
| 315 |
+
expr: schedule,
|
| 316 |
+
},
|
| 317 |
+
payload: {
|
| 318 |
+
kind: 'agentTurn',
|
| 319 |
+
message: command,
|
| 320 |
+
},
|
| 321 |
+
delivery: {
|
| 322 |
+
mode: 'none',
|
| 323 |
+
},
|
| 324 |
+
state: {},
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
cronFile.jobs.push(newJob)
|
| 328 |
+
|
| 329 |
+
if (!saveCronFile(cronFile)) {
|
| 330 |
+
return NextResponse.json({ error: 'Failed to save cron file' }, { status: 500 })
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
return NextResponse.json({ success: true })
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
return NextResponse.json({ error: 'Invalid action' }, { status: 400 })
|
| 337 |
+
} catch (error) {
|
| 338 |
+
console.error('Cron management error:', error)
|
| 339 |
+
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
| 340 |
+
}
|
| 341 |
+
}
|
src/app/api/events/route.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { eventBus, ServerEvent } from '@/lib/event-bus'
|
| 2 |
+
|
| 3 |
+
export const dynamic = 'force-dynamic'
|
| 4 |
+
export const runtime = 'nodejs'
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* GET /api/events - Server-Sent Events stream for real-time DB mutations.
|
| 8 |
+
* Clients connect via EventSource and receive JSON-encoded events.
|
| 9 |
+
*/
|
| 10 |
+
export async function GET() {
|
| 11 |
+
const encoder = new TextEncoder()
|
| 12 |
+
|
| 13 |
+
// Cleanup function, set in start(), called in cancel()
|
| 14 |
+
let cleanup: (() => void) | null = null
|
| 15 |
+
|
| 16 |
+
const stream = new ReadableStream({
|
| 17 |
+
start(controller) {
|
| 18 |
+
// Send initial connection event
|
| 19 |
+
controller.enqueue(
|
| 20 |
+
encoder.encode(`data: ${JSON.stringify({ type: 'connected', data: null, timestamp: Date.now() })}\n\n`)
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
// Forward all server events to this SSE client
|
| 24 |
+
const handler = (event: ServerEvent) => {
|
| 25 |
+
try {
|
| 26 |
+
controller.enqueue(
|
| 27 |
+
encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
| 28 |
+
)
|
| 29 |
+
} catch {
|
| 30 |
+
// Client disconnected, cleanup will happen in cancel()
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
eventBus.on('server-event', handler)
|
| 35 |
+
|
| 36 |
+
// Heartbeat every 30s to keep connection alive through proxies
|
| 37 |
+
const heartbeat = setInterval(() => {
|
| 38 |
+
try {
|
| 39 |
+
controller.enqueue(encoder.encode(': heartbeat\n\n'))
|
| 40 |
+
} catch {
|
| 41 |
+
clearInterval(heartbeat)
|
| 42 |
+
}
|
| 43 |
+
}, 30_000)
|
| 44 |
+
|
| 45 |
+
cleanup = () => {
|
| 46 |
+
eventBus.off('server-event', handler)
|
| 47 |
+
clearInterval(heartbeat)
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
|
| 51 |
+
cancel() {
|
| 52 |
+
// Client disconnected
|
| 53 |
+
if (cleanup) cleanup()
|
| 54 |
+
},
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
return new Response(stream, {
|
| 58 |
+
headers: {
|
| 59 |
+
'Content-Type': 'text/event-stream',
|
| 60 |
+
'Cache-Control': 'no-cache, no-transform',
|
| 61 |
+
Connection: 'keep-alive',
|
| 62 |
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
| 63 |
+
},
|
| 64 |
+
})
|
| 65 |
+
}
|
src/app/api/export/route.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* GET /api/export?type=audit|tasks|activities|pipelines&format=csv|json&since=UNIX&until=UNIX
|
| 7 |
+
* Admin-only data export endpoint.
|
| 8 |
+
*/
|
| 9 |
+
export async function GET(request: NextRequest) {
|
| 10 |
+
const auth = requireRole(request, 'admin')
|
| 11 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 12 |
+
|
| 13 |
+
const { searchParams } = new URL(request.url)
|
| 14 |
+
const type = searchParams.get('type')
|
| 15 |
+
const format = searchParams.get('format') || 'csv'
|
| 16 |
+
const since = searchParams.get('since')
|
| 17 |
+
const until = searchParams.get('until')
|
| 18 |
+
|
| 19 |
+
if (!type || !['audit', 'tasks', 'activities', 'pipelines'].includes(type)) {
|
| 20 |
+
return NextResponse.json(
|
| 21 |
+
{ error: 'type required: audit, tasks, activities, pipelines' },
|
| 22 |
+
{ status: 400 }
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const db = getDatabase()
|
| 27 |
+
const conditions: string[] = []
|
| 28 |
+
const params: any[] = []
|
| 29 |
+
|
| 30 |
+
if (since) {
|
| 31 |
+
conditions.push('created_at >= ?')
|
| 32 |
+
params.push(parseInt(since))
|
| 33 |
+
}
|
| 34 |
+
if (until) {
|
| 35 |
+
conditions.push('created_at <= ?')
|
| 36 |
+
params.push(parseInt(until))
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
| 40 |
+
|
| 41 |
+
let rows: any[] = []
|
| 42 |
+
let headers: string[] = []
|
| 43 |
+
let filename = ''
|
| 44 |
+
|
| 45 |
+
switch (type) {
|
| 46 |
+
case 'audit': {
|
| 47 |
+
rows = db.prepare(`SELECT * FROM audit_log ${where} ORDER BY created_at DESC`).all(...params)
|
| 48 |
+
headers = ['id', 'action', 'actor', 'actor_id', 'target_type', 'target_id', 'detail', 'ip_address', 'user_agent', 'created_at']
|
| 49 |
+
filename = 'audit-log'
|
| 50 |
+
break
|
| 51 |
+
}
|
| 52 |
+
case 'tasks': {
|
| 53 |
+
rows = db.prepare(`SELECT * FROM tasks ${where} ORDER BY created_at DESC`).all(...params)
|
| 54 |
+
headers = ['id', 'title', 'description', 'status', 'priority', 'assigned_to', 'created_by', 'created_at', 'updated_at', 'due_date', 'estimated_hours', 'actual_hours', 'tags']
|
| 55 |
+
filename = 'tasks'
|
| 56 |
+
break
|
| 57 |
+
}
|
| 58 |
+
case 'activities': {
|
| 59 |
+
rows = db.prepare(`SELECT * FROM activities ${where} ORDER BY created_at DESC`).all(...params)
|
| 60 |
+
headers = ['id', 'type', 'entity_type', 'entity_id', 'actor', 'description', 'data', 'created_at']
|
| 61 |
+
filename = 'activities'
|
| 62 |
+
break
|
| 63 |
+
}
|
| 64 |
+
case 'pipelines': {
|
| 65 |
+
rows = db.prepare(`SELECT pr.*, wp.name as pipeline_name FROM pipeline_runs pr LEFT JOIN workflow_pipelines wp ON pr.pipeline_id = wp.id ${where ? where.replace('created_at', 'pr.created_at') : ''} ORDER BY pr.created_at DESC`).all(...params)
|
| 66 |
+
headers = ['id', 'pipeline_id', 'pipeline_name', 'status', 'current_step', 'steps_snapshot', 'started_at', 'completed_at', 'triggered_by', 'created_at']
|
| 67 |
+
filename = 'pipeline-runs'
|
| 68 |
+
break
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// Log the export
|
| 73 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 74 |
+
logAuditEvent({
|
| 75 |
+
action: 'data_export',
|
| 76 |
+
actor: auth.user.username,
|
| 77 |
+
actor_id: auth.user.id,
|
| 78 |
+
detail: { type, format, row_count: rows.length },
|
| 79 |
+
ip_address: ipAddress,
|
| 80 |
+
})
|
| 81 |
+
|
| 82 |
+
const dateStr = new Date().toISOString().split('T')[0]
|
| 83 |
+
|
| 84 |
+
if (format === 'csv') {
|
| 85 |
+
const csvRows = [headers.join(',')]
|
| 86 |
+
for (const row of rows) {
|
| 87 |
+
const values = headers.map(h => {
|
| 88 |
+
const val = row[h]
|
| 89 |
+
if (val == null) return ''
|
| 90 |
+
const str = String(val)
|
| 91 |
+
// Escape CSV: wrap in quotes if contains comma, newline, or quote
|
| 92 |
+
if (str.includes(',') || str.includes('\n') || str.includes('"')) {
|
| 93 |
+
return `"${str.replace(/"/g, '""')}"`
|
| 94 |
+
}
|
| 95 |
+
return str
|
| 96 |
+
})
|
| 97 |
+
csvRows.push(values.join(','))
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return new NextResponse(csvRows.join('\n'), {
|
| 101 |
+
headers: {
|
| 102 |
+
'Content-Type': 'text/csv; charset=utf-8',
|
| 103 |
+
'Content-Disposition': `attachment; filename=${filename}-${dateStr}.csv`,
|
| 104 |
+
},
|
| 105 |
+
})
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// JSON format
|
| 109 |
+
return NextResponse.json(
|
| 110 |
+
{ type, exported_at: new Date().toISOString(), count: rows.length, data: rows },
|
| 111 |
+
{
|
| 112 |
+
headers: {
|
| 113 |
+
'Content-Disposition': `attachment; filename=${filename}-${dateStr}.json`,
|
| 114 |
+
},
|
| 115 |
+
}
|
| 116 |
+
)
|
| 117 |
+
}
|
src/app/api/gateway-config/route.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
+
import { requireRole } from '@/lib/auth'
|
| 3 |
+
import { logAuditEvent } from '@/lib/db'
|
| 4 |
+
import { config } from '@/lib/config'
|
| 5 |
+
import { join } from 'path'
|
| 6 |
+
|
| 7 |
+
function getConfigPath(): string | null {
|
| 8 |
+
if (!config.openclawHome) return null
|
| 9 |
+
return join(config.openclawHome, 'openclaw.json')
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* GET /api/gateway-config - Read the gateway configuration
|
| 14 |
+
*/
|
| 15 |
+
export async function GET(request: NextRequest) {
|
| 16 |
+
const auth = requireRole(request, 'admin')
|
| 17 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 18 |
+
|
| 19 |
+
const configPath = getConfigPath()
|
| 20 |
+
if (!configPath) {
|
| 21 |
+
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
const { readFile } = require('fs/promises')
|
| 26 |
+
const raw = await readFile(configPath, 'utf-8')
|
| 27 |
+
const parsed = JSON.parse(raw)
|
| 28 |
+
|
| 29 |
+
// Redact sensitive fields for display
|
| 30 |
+
const redacted = redactSensitive(JSON.parse(JSON.stringify(parsed)))
|
| 31 |
+
|
| 32 |
+
return NextResponse.json({
|
| 33 |
+
path: configPath,
|
| 34 |
+
config: redacted,
|
| 35 |
+
raw_size: raw.length,
|
| 36 |
+
})
|
| 37 |
+
} catch (err: any) {
|
| 38 |
+
if (err.code === 'ENOENT') {
|
| 39 |
+
return NextResponse.json({ error: 'Config file not found', path: configPath }, { status: 404 })
|
| 40 |
+
}
|
| 41 |
+
return NextResponse.json({ error: `Failed to read config: ${err.message}` }, { status: 500 })
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* PUT /api/gateway-config - Update specific config fields
|
| 47 |
+
* Body: { updates: { "path.to.key": value, ... } }
|
| 48 |
+
*
|
| 49 |
+
* Uses dot-notation paths to set nested values.
|
| 50 |
+
* CRITICAL: Preserves gateway.auth.password and other sensitive fields.
|
| 51 |
+
*/
|
| 52 |
+
export async function PUT(request: NextRequest) {
|
| 53 |
+
const auth = requireRole(request, 'admin')
|
| 54 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 55 |
+
|
| 56 |
+
const configPath = getConfigPath()
|
| 57 |
+
if (!configPath) {
|
| 58 |
+
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const body = await request.json().catch(() => null)
|
| 62 |
+
if (!body?.updates || typeof body.updates !== 'object') {
|
| 63 |
+
return NextResponse.json({ error: 'updates object required (dot-notation paths)' }, { status: 400 })
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Block writes to sensitive paths
|
| 67 |
+
const blockedPaths = ['gateway.auth.password', 'gateway.auth.secret']
|
| 68 |
+
for (const key of Object.keys(body.updates)) {
|
| 69 |
+
if (blockedPaths.some(bp => key.startsWith(bp))) {
|
| 70 |
+
return NextResponse.json({ error: `Cannot modify protected field: ${key}` }, { status: 403 })
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
const { readFile, writeFile } = require('fs/promises')
|
| 76 |
+
const raw = await readFile(configPath, 'utf-8')
|
| 77 |
+
const parsed = JSON.parse(raw)
|
| 78 |
+
|
| 79 |
+
// Apply updates via dot-notation
|
| 80 |
+
const appliedKeys: string[] = []
|
| 81 |
+
for (const [dotPath, value] of Object.entries(body.updates)) {
|
| 82 |
+
setNestedValue(parsed, dotPath, value)
|
| 83 |
+
appliedKeys.push(dotPath)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// Write back with pretty formatting
|
| 87 |
+
await writeFile(configPath, JSON.stringify(parsed, null, 2) + '\n')
|
| 88 |
+
|
| 89 |
+
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 90 |
+
logAuditEvent({
|
| 91 |
+
action: 'gateway_config_update',
|
| 92 |
+
actor: auth.user.username,
|
| 93 |
+
actor_id: auth.user.id,
|
| 94 |
+
detail: { updated_keys: appliedKeys },
|
| 95 |
+
ip_address: ipAddress,
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
return NextResponse.json({ updated: appliedKeys, count: appliedKeys.length })
|
| 99 |
+
} catch (err: any) {
|
| 100 |
+
return NextResponse.json({ error: `Failed to update config: ${err.message}` }, { status: 500 })
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/** Set a value in a nested object using dot-notation path */
|
| 105 |
+
function setNestedValue(obj: any, path: string, value: any) {
|
| 106 |
+
const keys = path.split('.')
|
| 107 |
+
let current = obj
|
| 108 |
+
for (let i = 0; i < keys.length - 1; i++) {
|
| 109 |
+
if (current[keys[i]] === undefined) current[keys[i]] = {}
|
| 110 |
+
current = current[keys[i]]
|
| 111 |
+
}
|
| 112 |
+
current[keys[keys.length - 1]] = value
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/** Redact sensitive values for display */
|
| 116 |
+
function redactSensitive(obj: any, parentKey = ''): any {
|
| 117 |
+
if (typeof obj !== 'object' || obj === null) return obj
|
| 118 |
+
|
| 119 |
+
const sensitiveKeys = ['password', 'secret', 'token', 'api_key', 'apiKey']
|
| 120 |
+
|
| 121 |
+
for (const key of Object.keys(obj)) {
|
| 122 |
+
if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
|
| 123 |
+
if (typeof obj[key] === 'string' && obj[key].length > 0) {
|
| 124 |
+
obj[key] = '••••••••'
|
| 125 |
+
}
|
| 126 |
+
} else if (typeof obj[key] === 'object' && obj[key] !== null) {
|
| 127 |
+
redactSensitive(obj[key], key)
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
return obj
|
| 132 |
+
}
|
src/app/api/gateways/health/route.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server"
|
| 2 |
+
import { requireRole } from "@/lib/auth"
|
| 3 |
+
import { getDatabase } from "@/lib/db"
|
| 4 |
+
|
| 5 |
+
interface GatewayEntry {
|
| 6 |
+
id: number
|
| 7 |
+
name: string
|
| 8 |
+
host: string
|
| 9 |
+
port: number
|
| 10 |
+
token: string
|
| 11 |
+
is_primary: number
|
| 12 |
+
status: string
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface HealthResult {
|
| 16 |
+
id: number
|
| 17 |
+
name: string
|
| 18 |
+
status: "online" | "offline" | "error"
|
| 19 |
+
latency: number | null
|
| 20 |
+
agents: string[]
|
| 21 |
+
sessions_count: number
|
| 22 |
+
error?: string
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* POST /api/gateways/health - Server-side health probe for all gateways
|
| 27 |
+
* Probes gateways from the server where loopback addresses are reachable.
|
| 28 |
+
*/
|
| 29 |
+
export async function POST(request: NextRequest) {
|
| 30 |
+
const auth = requireRole(request, "viewer")
|
| 31 |
+
if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
|
| 32 |
+
|
| 33 |
+
const db = getDatabase()
|
| 34 |
+
const gateways = db.prepare("SELECT * FROM gateways ORDER BY is_primary DESC, name ASC").all() as GatewayEntry[]
|
| 35 |
+
|
| 36 |
+
const results: HealthResult[] = await Promise.all(
|
| 37 |
+
gateways.map(async (gw) => {
|
| 38 |
+
const start = Date.now()
|
| 39 |
+
try {
|
| 40 |
+
const controller = new AbortController()
|
| 41 |
+
const timeout = setTimeout(() => controller.abort(), 5000)
|
| 42 |
+
|
| 43 |
+
const probeUrl = "http://" + gw.host + ":" + gw.port + "/"
|
| 44 |
+
const res = await fetch(probeUrl, {
|
| 45 |
+
signal: controller.signal,
|
| 46 |
+
})
|
| 47 |
+
clearTimeout(timeout)
|
| 48 |
+
|
| 49 |
+
const latency = Date.now() - start
|
| 50 |
+
const status = res.ok ? "online" : "error"
|
| 51 |
+
|
| 52 |
+
db.prepare(
|
| 53 |
+
"UPDATE gateways SET status = ?, latency = ?, last_seen = (unixepoch()), updated_at = (unixepoch()) WHERE id = ?"
|
| 54 |
+
).run(status, latency, gw.id)
|
| 55 |
+
|
| 56 |
+
return {
|
| 57 |
+
id: gw.id,
|
| 58 |
+
name: gw.name,
|
| 59 |
+
status: status as "online" | "error",
|
| 60 |
+
latency,
|
| 61 |
+
agents: [],
|
| 62 |
+
sessions_count: 0,
|
| 63 |
+
}
|
| 64 |
+
} catch (err: any) {
|
| 65 |
+
const latency = Date.now() - start
|
| 66 |
+
|
| 67 |
+
db.prepare(
|
| 68 |
+
"UPDATE gateways SET status = ?, latency = NULL, updated_at = (unixepoch()) WHERE id = ?"
|
| 69 |
+
).run("offline", gw.id)
|
| 70 |
+
|
| 71 |
+
return {
|
| 72 |
+
id: gw.id,
|
| 73 |
+
name: gw.name,
|
| 74 |
+
status: "offline" as const,
|
| 75 |
+
latency: null,
|
| 76 |
+
agents: [],
|
| 77 |
+
sessions_count: 0,
|
| 78 |
+
error: err.name === "AbortError" ? "timeout" : (err.message || "connection failed"),
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
})
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return NextResponse.json({ results, probed_at: Date.now() })
|
| 85 |
+
}
|