Nyk commited on
Commit
bbbc03f
·
0 Parent(s):

feat: initial open-source release

Browse files

OpenClaw 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
Files changed (50) hide show
  1. .env.example +89 -0
  2. .gitattributes +2 -0
  3. .github/pull_request_template.md +17 -0
  4. .github/workflows/quality-gate.yml +49 -0
  5. .gitignore +34 -0
  6. .npmrc +2 -0
  7. CONTRIBUTING.md +64 -0
  8. LICENSE +21 -0
  9. README.md +293 -0
  10. SECURITY.md +31 -0
  11. eslint.config.mjs +23 -0
  12. middleware.ts +100 -0
  13. next.config.js +46 -0
  14. ops/mc-provisioner-daemon.js +302 -0
  15. ops/templates/openclaw-gateway@.service +23 -0
  16. package-lock.json +0 -0
  17. package.json +66 -0
  18. playwright.config.ts +18 -0
  19. pnpm-lock.yaml +0 -0
  20. postcss.config.js +6 -0
  21. scripts/agent-heartbeat.sh +240 -0
  22. scripts/notification-daemon.sh +347 -0
  23. src/app/api/activities/route.ts +217 -0
  24. src/app/api/agents/[id]/heartbeat/route.ts +179 -0
  25. src/app/api/agents/[id]/memory/route.ts +213 -0
  26. src/app/api/agents/[id]/route.ts +206 -0
  27. src/app/api/agents/[id]/soul/route.ts +229 -0
  28. src/app/api/agents/[id]/wake/route.ts +63 -0
  29. src/app/api/agents/comms/route.ts +155 -0
  30. src/app/api/agents/message/route.ts +70 -0
  31. src/app/api/agents/route.ts +331 -0
  32. src/app/api/agents/sync/route.ts +42 -0
  33. src/app/api/alerts/route.ts +311 -0
  34. src/app/api/audit/route.ts +66 -0
  35. src/app/api/auth/access-requests/route.ts +147 -0
  36. src/app/api/auth/google/route.ts +103 -0
  37. src/app/api/auth/login/route.ts +48 -0
  38. src/app/api/auth/logout/route.ts +27 -0
  39. src/app/api/auth/me/route.ts +108 -0
  40. src/app/api/auth/users/route.ts +159 -0
  41. src/app/api/backup/route.ts +129 -0
  42. src/app/api/chat/conversations/route.ts +79 -0
  43. src/app/api/chat/messages/[id]/route.ts +72 -0
  44. src/app/api/chat/messages/route.ts +459 -0
  45. src/app/api/cleanup/route.ts +162 -0
  46. src/app/api/cron/route.ts +341 -0
  47. src/app/api/events/route.ts +65 -0
  48. src/app/api/export/route.ts +117 -0
  49. src/app/api/gateway-config/route.ts +132 -0
  50. 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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
10
+ [![Next.js 16](https://img.shields.io/badge/Next.js-16-black?logo=next.js)](https://nextjs.org/)
11
+ [![React 19](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=white)](https://react.dev/)
12
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)](https://typescriptlang.org/)
13
+ [![SQLite](https://img.shields.io/badge/SQLite-WAL-003B57?logo=sqlite&logoColor=white)](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) &copy; 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
+ }