Spaces:
Sleeping
Sleeping
chore: resolve merge conflict with main for PR #180
Browse files- .env.example +5 -1
- Dockerfile +2 -2
- README.md +40 -0
- docker-compose.yml +3 -1
- openapi.json +0 -0
- package.json +2 -2
- playwright.config.ts +11 -2
- src/app/[[...panel]]/page.tsx +10 -3
- src/app/api/agents/[id]/diagnostics/route.ts +343 -0
- src/app/api/agents/route.ts +39 -2
- src/app/api/cleanup/route.ts +25 -0
- src/app/api/memory/route.ts +52 -1
- src/app/api/settings/route.ts +1 -0
- src/app/api/status/route.ts +58 -29
- src/app/api/tasks/[id]/route.ts +15 -8
- src/app/api/tasks/route.ts +5 -3
- src/components/dashboard/dashboard.tsx +1 -1
- src/components/layout/live-feed.tsx +3 -2
- src/components/panels/agent-detail-tabs.tsx +22 -1
- src/components/panels/agent-squad-panel-phase3.tsx +18 -1
- src/components/panels/memory-browser-panel.tsx +11 -10
- src/components/panels/office-panel.tsx +92 -3
- src/components/panels/settings-panel.tsx +23 -1
- src/components/panels/super-admin-panel.tsx +28 -16
- src/components/panels/task-board-panel.tsx +16 -13
- src/lib/__tests__/task-status.test.ts +47 -0
- src/lib/config.ts +23 -4
- src/lib/scheduler.ts +6 -0
- src/lib/sessions.ts +83 -14
- src/lib/task-status.ts +40 -0
- src/lib/validation.ts +3 -0
- src/lib/websocket.ts +11 -1
- src/live-feed.tsx +0 -161
- src/page.tsx +0 -166
- tests/agent-diagnostics.spec.ts +76 -0
.env.example
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# === Authentication ===
|
| 2 |
# Admin user seeded on first run (only if no users exist in DB)
|
| 3 |
AUTH_USER=admin
|
|
@@ -65,7 +69,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL=
|
|
| 65 |
NEXT_PUBLIC_GATEWAY_URL=
|
| 66 |
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
|
| 67 |
# Gateway client id used in websocket handshake (role=operator UI client).
|
| 68 |
-
NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui
|
| 69 |
|
| 70 |
# === Data Paths (all optional, defaults to .data/ in project root) ===
|
| 71 |
# MISSION_CONTROL_DATA_DIR=.data
|
|
|
|
| 1 |
+
# === Server Port ===
|
| 2 |
+
# Port the Next.js server listens on (dev and production)
|
| 3 |
+
# PORT=3000
|
| 4 |
+
|
| 5 |
# === Authentication ===
|
| 6 |
# Admin user seeded on first run (only if no users exist in DB)
|
| 7 |
AUTH_USER=admin
|
|
|
|
| 69 |
NEXT_PUBLIC_GATEWAY_URL=
|
| 70 |
# NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
|
| 71 |
# Gateway client id used in websocket handshake (role=operator UI client).
|
| 72 |
+
NEXT_PUBLIC_GATEWAY_CLIENT_ID=openclaw-control-ui
|
| 73 |
|
| 74 |
# === Data Paths (all optional, defaults to .data/ in project root) ===
|
| 75 |
# MISSION_CONTROL_DATA_DIR=.data
|
Dockerfile
CHANGED
|
@@ -32,9 +32,9 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
|
|
| 32 |
RUN mkdir -p .data && chown nextjs:nodejs .data
|
| 33 |
RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
| 34 |
USER nextjs
|
| 35 |
-
EXPOSE 3000
|
| 36 |
ENV PORT=3000
|
|
|
|
| 37 |
ENV HOSTNAME=0.0.0.0
|
| 38 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 39 |
-
CMD curl -f http://localhost:3000/login || exit 1
|
| 40 |
CMD ["node", "server.js"]
|
|
|
|
| 32 |
RUN mkdir -p .data && chown nextjs:nodejs .data
|
| 33 |
RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
|
| 34 |
USER nextjs
|
|
|
|
| 35 |
ENV PORT=3000
|
| 36 |
+
EXPOSE 3000
|
| 37 |
ENV HOSTNAME=0.0.0.0
|
| 38 |
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 39 |
+
CMD curl -f http://localhost:${PORT:-3000}/login || exit 1
|
| 40 |
CMD ["node", "server.js"]
|
README.md
CHANGED
|
@@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot
|
|
| 113 |
### Integrations
|
| 114 |
Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
### Update Checker
|
| 117 |
Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
|
| 118 |
|
|
@@ -286,6 +294,20 @@ All endpoints require authentication unless noted. Full reference below.
|
|
| 286 |
|
| 287 |
</details>
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
<details>
|
| 290 |
<summary><strong>Direct CLI</strong></summary>
|
| 291 |
|
|
@@ -427,6 +449,24 @@ pnpm test:e2e # Playwright E2E
|
|
| 427 |
pnpm quality:gate # All checks
|
| 428 |
```
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
## Roadmap
|
| 431 |
|
| 432 |
See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped.
|
|
|
|
| 113 |
### Integrations
|
| 114 |
Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
|
| 115 |
|
| 116 |
+
### Workspace Management
|
| 117 |
+
Workspaces (tenant instances) are created and managed through the **Super Admin** panel, accessible from the sidebar under **Admin > Super Admin**. From there, admins can:
|
| 118 |
+
- **Create** new client instances (slug, display name, Linux user, gateway port, plan tier)
|
| 119 |
+
- **Monitor** provisioning jobs and their step-by-step progress
|
| 120 |
+
- **Decommission** tenants with optional cleanup of state directories and Linux users
|
| 121 |
+
|
| 122 |
+
Each workspace gets its own isolated environment with a dedicated OpenClaw gateway, state directory, and workspace root. See the [Super Admin API](#api-overview) endpoints under `/api/super/*` for programmatic access.
|
| 123 |
+
|
| 124 |
### Update Checker
|
| 125 |
Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
|
| 126 |
|
|
|
|
| 294 |
|
| 295 |
</details>
|
| 296 |
|
| 297 |
+
<details>
|
| 298 |
+
<summary><strong>Super Admin (Workspace/Tenant Management)</strong></summary>
|
| 299 |
+
|
| 300 |
+
| Method | Path | Role | Description |
|
| 301 |
+
|--------|------|------|-------------|
|
| 302 |
+
| `GET` | `/api/super/tenants` | admin | List all tenants with latest provisioning status |
|
| 303 |
+
| `POST` | `/api/super/tenants` | admin | Create tenant and queue bootstrap job |
|
| 304 |
+
| `POST` | `/api/super/tenants/[id]/decommission` | admin | Queue tenant decommission job |
|
| 305 |
+
| `GET` | `/api/super/provision-jobs` | admin | List provisioning jobs (filter: `?tenant_id=`, `?status=`) |
|
| 306 |
+
| `POST` | `/api/super/provision-jobs` | admin | Queue additional job for existing tenant |
|
| 307 |
+
| `POST` | `/api/super/provision-jobs/[id]/action` | admin | Approve, reject, or cancel a provisioning job |
|
| 308 |
+
|
| 309 |
+
</details>
|
| 310 |
+
|
| 311 |
<details>
|
| 312 |
<summary><strong>Direct CLI</strong></summary>
|
| 313 |
|
|
|
|
| 449 |
pnpm quality:gate # All checks
|
| 450 |
```
|
| 451 |
|
| 452 |
+
## Agent Diagnostics Contract
|
| 453 |
+
|
| 454 |
+
`GET /api/agents/{id}/diagnostics` is self-scoped by default.
|
| 455 |
+
|
| 456 |
+
- Self access:
|
| 457 |
+
- Session user where `username === agent.name`, or
|
| 458 |
+
- API-key request with `x-agent-name` matching `{id}` agent name
|
| 459 |
+
- Cross-agent access:
|
| 460 |
+
- Allowed only with explicit `?privileged=1` and admin auth
|
| 461 |
+
- Query validation:
|
| 462 |
+
- `hours` must be an integer between `1` and `720`
|
| 463 |
+
- `section` must be a comma-separated subset of `summary,tasks,errors,activity,trends,tokens`
|
| 464 |
+
|
| 465 |
+
Trend alerts in the `trends.alerts` response are derived from current-vs-previous window comparisons:
|
| 466 |
+
|
| 467 |
+
- `warning`: error spikes or severe activity drop
|
| 468 |
+
- `info`: throughput drops or potential stall patterns
|
| 469 |
+
|
| 470 |
## Roadmap
|
| 471 |
|
| 472 |
See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped.
|
docker-compose.yml
CHANGED
|
@@ -3,7 +3,9 @@ services:
|
|
| 3 |
build: .
|
| 4 |
container_name: mission-control
|
| 5 |
ports:
|
| 6 |
-
- "${MC_PORT:-3000}:3000"
|
|
|
|
|
|
|
| 7 |
env_file:
|
| 8 |
- path: .env
|
| 9 |
required: false
|
|
|
|
| 3 |
build: .
|
| 4 |
container_name: mission-control
|
| 5 |
ports:
|
| 6 |
+
- "${MC_PORT:-3000}:${PORT:-3000}"
|
| 7 |
+
environment:
|
| 8 |
+
- PORT=${PORT:-3000}
|
| 9 |
env_file:
|
| 10 |
- path: .env
|
| 11 |
required: false
|
openapi.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
| 3 |
"version": "1.3.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
|
| 9 |
"lint": "eslint .",
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"test": "vitest run",
|
|
|
|
| 3 |
"version": "1.3.0",
|
| 4 |
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
|
| 5 |
"scripts": {
|
| 6 |
+
"dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
|
| 7 |
"build": "next build",
|
| 8 |
+
"start": "next start --hostname 0.0.0.0 --port ${PORT:-3000}",
|
| 9 |
"lint": "eslint .",
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"test": "vitest run",
|
playwright.config.ts
CHANGED
|
@@ -18,9 +18,18 @@ export default defineConfig({
|
|
| 18 |
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
| 19 |
],
|
| 20 |
webServer: {
|
| 21 |
-
command: '
|
| 22 |
url: 'http://127.0.0.1:3005',
|
| 23 |
reuseExistingServer: true,
|
| 24 |
-
timeout:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
})
|
|
|
|
| 18 |
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }
|
| 19 |
],
|
| 20 |
webServer: {
|
| 21 |
+
command: 'node .next/standalone/server.js',
|
| 22 |
url: 'http://127.0.0.1:3005',
|
| 23 |
reuseExistingServer: true,
|
| 24 |
+
timeout: 120_000,
|
| 25 |
+
env: {
|
| 26 |
+
...process.env,
|
| 27 |
+
HOSTNAME: process.env.HOSTNAME || '127.0.0.1',
|
| 28 |
+
PORT: process.env.PORT || '3005',
|
| 29 |
+
MC_DISABLE_RATE_LIMIT: process.env.MC_DISABLE_RATE_LIMIT || '1',
|
| 30 |
+
API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
|
| 31 |
+
AUTH_USER: process.env.AUTH_USER || 'testadmin',
|
| 32 |
+
AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
|
| 33 |
+
},
|
| 34 |
}
|
| 35 |
})
|
src/app/[[...panel]]/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
-
import { usePathname } from 'next/navigation'
|
| 5 |
import { NavRail } from '@/components/layout/nav-rail'
|
| 6 |
import { HeaderBar } from '@/components/layout/header-bar'
|
| 7 |
import { LiveFeed } from '@/components/layout/live-feed'
|
|
@@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events'
|
|
| 42 |
import { useMissionControl } from '@/store'
|
| 43 |
|
| 44 |
export default function Home() {
|
|
|
|
| 45 |
const { connect } = useWebSocket()
|
| 46 |
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
|
| 47 |
|
|
@@ -62,7 +63,13 @@ export default function Home() {
|
|
| 62 |
|
| 63 |
// Fetch current user
|
| 64 |
fetch('/api/auth/me')
|
| 65 |
-
.then(res =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
.then(data => { if (data?.user) setCurrentUser(data.user) })
|
| 67 |
.catch(() => {})
|
| 68 |
|
|
@@ -120,7 +127,7 @@ export default function Home() {
|
|
| 120 |
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
|
| 121 |
connect(wsUrl, wsToken)
|
| 122 |
})
|
| 123 |
-
}, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
|
| 124 |
|
| 125 |
if (!isClient) {
|
| 126 |
return (
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useEffect, useState } from 'react'
|
| 4 |
+
import { usePathname, useRouter } from 'next/navigation'
|
| 5 |
import { NavRail } from '@/components/layout/nav-rail'
|
| 6 |
import { HeaderBar } from '@/components/layout/header-bar'
|
| 7 |
import { LiveFeed } from '@/components/layout/live-feed'
|
|
|
|
| 42 |
import { useMissionControl } from '@/store'
|
| 43 |
|
| 44 |
export default function Home() {
|
| 45 |
+
const router = useRouter()
|
| 46 |
const { connect } = useWebSocket()
|
| 47 |
const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
|
| 48 |
|
|
|
|
| 63 |
|
| 64 |
// Fetch current user
|
| 65 |
fetch('/api/auth/me')
|
| 66 |
+
.then(async (res) => {
|
| 67 |
+
if (res.ok) return res.json()
|
| 68 |
+
if (res.status === 401) {
|
| 69 |
+
router.replace(`/login?next=${encodeURIComponent(pathname)}`)
|
| 70 |
+
}
|
| 71 |
+
return null
|
| 72 |
+
})
|
| 73 |
.then(data => { if (data?.user) setCurrentUser(data.user) })
|
| 74 |
.catch(() => {})
|
| 75 |
|
|
|
|
| 127 |
const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
|
| 128 |
connect(wsUrl, wsToken)
|
| 129 |
})
|
| 130 |
+
}, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
|
| 131 |
|
| 132 |
if (!isClient) {
|
| 133 |
return (
|
src/app/api/agents/[id]/diagnostics/route.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 2 |
+
import { getDatabase } from '@/lib/db';
|
| 3 |
+
import { requireRole } from '@/lib/auth';
|
| 4 |
+
import { logger } from '@/lib/logger';
|
| 5 |
+
|
| 6 |
+
const ALLOWED_SECTIONS = ['summary', 'tasks', 'errors', 'activity', 'trends', 'tokens'] as const;
|
| 7 |
+
type DiagnosticsSection = (typeof ALLOWED_SECTIONS)[number];
|
| 8 |
+
|
| 9 |
+
function parseHoursParam(raw: string | null): { value?: number; error?: string } {
|
| 10 |
+
if (raw === null) return { value: 24 };
|
| 11 |
+
const parsed = Number(raw);
|
| 12 |
+
if (!Number.isInteger(parsed)) {
|
| 13 |
+
return { error: 'hours must be an integer between 1 and 720' };
|
| 14 |
+
}
|
| 15 |
+
if (parsed < 1 || parsed > 720) {
|
| 16 |
+
return { error: 'hours must be between 1 and 720' };
|
| 17 |
+
}
|
| 18 |
+
return { value: parsed };
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
function parseSectionsParam(raw: string | null): { value?: Set<DiagnosticsSection>; error?: string } {
|
| 22 |
+
if (!raw || raw.trim().length === 0) {
|
| 23 |
+
return { value: new Set(ALLOWED_SECTIONS) };
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const requested = raw
|
| 27 |
+
.split(',')
|
| 28 |
+
.map((section) => section.trim())
|
| 29 |
+
.filter(Boolean);
|
| 30 |
+
|
| 31 |
+
if (requested.length === 0) {
|
| 32 |
+
return { error: 'section must include at least one valid value' };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const invalid = requested.filter((section) => !ALLOWED_SECTIONS.includes(section as DiagnosticsSection));
|
| 36 |
+
if (invalid.length > 0) {
|
| 37 |
+
return { error: `Invalid section value(s): ${invalid.join(', ')}` };
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return { value: new Set(requested as DiagnosticsSection[]) };
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API
|
| 45 |
+
*
|
| 46 |
+
* Provides an agent with its own performance metrics, error analysis,
|
| 47 |
+
* and trend data so it can self-optimize.
|
| 48 |
+
*
|
| 49 |
+
* Query params:
|
| 50 |
+
* hours - Time window in hours (default: 24, max: 720 = 30 days)
|
| 51 |
+
* section - Comma-separated sections to include (default: all)
|
| 52 |
+
* Options: summary, tasks, errors, activity, trends, tokens
|
| 53 |
+
*
|
| 54 |
+
* Response includes:
|
| 55 |
+
* summary - High-level KPIs (throughput, error rate, activity count)
|
| 56 |
+
* tasks - Task completion breakdown by status and priority
|
| 57 |
+
* errors - Error frequency, types, and recent error details
|
| 58 |
+
* activity - Activity breakdown by type with hourly timeline
|
| 59 |
+
* trends - Multi-period comparison for trend detection
|
| 60 |
+
* tokens - Token usage by model with cost estimates
|
| 61 |
+
*/
|
| 62 |
+
export async function GET(
|
| 63 |
+
request: NextRequest,
|
| 64 |
+
{ params }: { params: Promise<{ id: string }> }
|
| 65 |
+
) {
|
| 66 |
+
const auth = requireRole(request, 'viewer');
|
| 67 |
+
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
const db = getDatabase();
|
| 71 |
+
const resolvedParams = await params;
|
| 72 |
+
const agentId = resolvedParams.id;
|
| 73 |
+
const workspaceId = auth.user.workspace_id ?? 1;
|
| 74 |
+
|
| 75 |
+
// Resolve agent by ID or name
|
| 76 |
+
let agent: any;
|
| 77 |
+
if (/^\d+$/.test(agentId)) {
|
| 78 |
+
agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
|
| 79 |
+
} else {
|
| 80 |
+
agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (!agent) {
|
| 84 |
+
return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const { searchParams } = new URL(request.url);
|
| 88 |
+
const requesterAgentName = (request.headers.get('x-agent-name') || '').trim();
|
| 89 |
+
const privileged = searchParams.get('privileged') === '1';
|
| 90 |
+
const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;
|
| 91 |
+
|
| 92 |
+
// Self-only by default. Cross-agent access requires explicit privileged override.
|
| 93 |
+
if (!isSelfRequest && !(privileged && auth.user.role === 'admin')) {
|
| 94 |
+
return NextResponse.json(
|
| 95 |
+
{ error: 'Diagnostics are self-scoped. Use privileged=1 with admin role for cross-agent access.' },
|
| 96 |
+
{ status: 403 }
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
const parsedHours = parseHoursParam(searchParams.get('hours'));
|
| 101 |
+
if (parsedHours.error) {
|
| 102 |
+
return NextResponse.json({ error: parsedHours.error }, { status: 400 });
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const parsedSections = parseSectionsParam(searchParams.get('section'));
|
| 106 |
+
if (parsedSections.error) {
|
| 107 |
+
return NextResponse.json({ error: parsedSections.error }, { status: 400 });
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
const hours = parsedHours.value as number;
|
| 111 |
+
const sections = parsedSections.value as Set<DiagnosticsSection>;
|
| 112 |
+
|
| 113 |
+
const now = Math.floor(Date.now() / 1000);
|
| 114 |
+
const since = now - hours * 3600;
|
| 115 |
+
|
| 116 |
+
const result: Record<string, any> = {
|
| 117 |
+
agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
|
| 118 |
+
timeframe: { hours, since, until: now },
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
if (sections.has('summary')) {
|
| 122 |
+
result.summary = buildSummary(db, agent.name, workspaceId, since);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
if (sections.has('tasks')) {
|
| 126 |
+
result.tasks = buildTaskMetrics(db, agent.name, workspaceId, since);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
if (sections.has('errors')) {
|
| 130 |
+
result.errors = buildErrorAnalysis(db, agent.name, workspaceId, since);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
if (sections.has('activity')) {
|
| 134 |
+
result.activity = buildActivityBreakdown(db, agent.name, workspaceId, since);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (sections.has('trends')) {
|
| 138 |
+
result.trends = buildTrends(db, agent.name, workspaceId, hours);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
if (sections.has('tokens')) {
|
| 142 |
+
result.tokens = buildTokenMetrics(db, agent.name, workspaceId, since);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
return NextResponse.json(result);
|
| 146 |
+
} catch (error) {
|
| 147 |
+
logger.error({ err: error }, 'GET /api/agents/[id]/diagnostics error');
|
| 148 |
+
return NextResponse.json({ error: 'Failed to fetch diagnostics' }, { status: 500 });
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/** High-level KPIs */
|
| 153 |
+
function buildSummary(db: any, agentName: string, workspaceId: number, since: number) {
|
| 154 |
+
const tasksDone = (db.prepare(
|
| 155 |
+
`SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ?`
|
| 156 |
+
).get(agentName, workspaceId, since) as any).c;
|
| 157 |
+
|
| 158 |
+
const tasksTotal = (db.prepare(
|
| 159 |
+
`SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ?`
|
| 160 |
+
).get(agentName, workspaceId) as any).c;
|
| 161 |
+
|
| 162 |
+
const activityCount = (db.prepare(
|
| 163 |
+
`SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ?`
|
| 164 |
+
).get(agentName, workspaceId, since) as any).c;
|
| 165 |
+
|
| 166 |
+
const errorCount = (db.prepare(
|
| 167 |
+
`SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND type LIKE '%error%'`
|
| 168 |
+
).get(agentName, workspaceId, since) as any).c;
|
| 169 |
+
|
| 170 |
+
const errorRate = activityCount > 0 ? Math.round((errorCount / activityCount) * 10000) / 100 : 0;
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
tasks_completed: tasksDone,
|
| 174 |
+
tasks_total: tasksTotal,
|
| 175 |
+
activity_count: activityCount,
|
| 176 |
+
error_count: errorCount,
|
| 177 |
+
error_rate_percent: errorRate,
|
| 178 |
+
};
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/** Task completion breakdown */
|
| 182 |
+
function buildTaskMetrics(db: any, agentName: string, workspaceId: number, since: number) {
|
| 183 |
+
const byStatus = db.prepare(
|
| 184 |
+
`SELECT status, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY status`
|
| 185 |
+
).all(agentName, workspaceId) as Array<{ status: string; count: number }>;
|
| 186 |
+
|
| 187 |
+
const byPriority = db.prepare(
|
| 188 |
+
`SELECT priority, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY priority`
|
| 189 |
+
).all(agentName, workspaceId) as Array<{ priority: string; count: number }>;
|
| 190 |
+
|
| 191 |
+
const recentCompleted = db.prepare(
|
| 192 |
+
`SELECT id, title, priority, updated_at FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 10`
|
| 193 |
+
).all(agentName, workspaceId, since) as any[];
|
| 194 |
+
|
| 195 |
+
// Estimate throughput: tasks completed per day in the window
|
| 196 |
+
const windowDays = Math.max((Math.floor(Date.now() / 1000) - since) / 86400, 1);
|
| 197 |
+
const completedInWindow = recentCompleted.length;
|
| 198 |
+
const throughputPerDay = Math.round((completedInWindow / windowDays) * 100) / 100;
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
by_status: Object.fromEntries(byStatus.map(r => [r.status, r.count])),
|
| 202 |
+
by_priority: Object.fromEntries(byPriority.map(r => [r.priority, r.count])),
|
| 203 |
+
recent_completed: recentCompleted,
|
| 204 |
+
throughput_per_day: throughputPerDay,
|
| 205 |
+
};
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/** Error frequency and analysis */
|
| 209 |
+
function buildErrorAnalysis(db: any, agentName: string, workspaceId: number, since: number) {
|
| 210 |
+
const errorActivities = db.prepare(
|
| 211 |
+
`SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') GROUP BY type ORDER BY count DESC`
|
| 212 |
+
).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
|
| 213 |
+
|
| 214 |
+
const recentErrors = db.prepare(
|
| 215 |
+
`SELECT id, type, description, data, created_at FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') ORDER BY created_at DESC LIMIT 20`
|
| 216 |
+
).all(agentName, workspaceId, since) as any[];
|
| 217 |
+
|
| 218 |
+
return {
|
| 219 |
+
by_type: errorActivities,
|
| 220 |
+
total: errorActivities.reduce((sum, e) => sum + e.count, 0),
|
| 221 |
+
recent: recentErrors.map(e => ({
|
| 222 |
+
...e,
|
| 223 |
+
data: e.data ? JSON.parse(e.data) : null,
|
| 224 |
+
})),
|
| 225 |
+
};
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
/** Activity breakdown with hourly timeline */
|
| 229 |
+
function buildActivityBreakdown(db: any, agentName: string, workspaceId: number, since: number) {
|
| 230 |
+
const byType = db.prepare(
|
| 231 |
+
`SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY type ORDER BY count DESC`
|
| 232 |
+
).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
|
| 233 |
+
|
| 234 |
+
const timeline = db.prepare(
|
| 235 |
+
`SELECT (created_at / 3600) * 3600 as hour_bucket, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY hour_bucket ORDER BY hour_bucket ASC`
|
| 236 |
+
).all(agentName, workspaceId, since) as Array<{ hour_bucket: number; count: number }>;
|
| 237 |
+
|
| 238 |
+
return {
|
| 239 |
+
by_type: byType,
|
| 240 |
+
timeline: timeline.map(t => ({
|
| 241 |
+
timestamp: t.hour_bucket,
|
| 242 |
+
hour: new Date(t.hour_bucket * 1000).toISOString(),
|
| 243 |
+
count: t.count,
|
| 244 |
+
})),
|
| 245 |
+
};
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/** Multi-period trend comparison for anomaly/trend detection */
|
| 249 |
+
function buildTrends(db: any, agentName: string, workspaceId: number, hours: number) {
|
| 250 |
+
const now = Math.floor(Date.now() / 1000);
|
| 251 |
+
|
| 252 |
+
// Compare current period vs previous period of same length
|
| 253 |
+
const currentSince = now - hours * 3600;
|
| 254 |
+
const previousSince = currentSince - hours * 3600;
|
| 255 |
+
|
| 256 |
+
const periodMetrics = (since: number, until: number) => {
|
| 257 |
+
const activities = (db.prepare(
|
| 258 |
+
`SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ?`
|
| 259 |
+
).get(agentName, workspaceId, since, until) as any).c;
|
| 260 |
+
|
| 261 |
+
const errors = (db.prepare(
|
| 262 |
+
`SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ? AND (type LIKE '%error%' OR type LIKE '%fail%')`
|
| 263 |
+
).get(agentName, workspaceId, since, until) as any).c;
|
| 264 |
+
|
| 265 |
+
const tasksCompleted = (db.prepare(
|
| 266 |
+
`SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? AND updated_at < ?`
|
| 267 |
+
).get(agentName, workspaceId, since, until) as any).c;
|
| 268 |
+
|
| 269 |
+
return { activities, errors, tasks_completed: tasksCompleted };
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
const current = periodMetrics(currentSince, now);
|
| 273 |
+
const previous = periodMetrics(previousSince, currentSince);
|
| 274 |
+
|
| 275 |
+
const pctChange = (cur: number, prev: number) => {
|
| 276 |
+
if (prev === 0) return cur > 0 ? 100 : 0;
|
| 277 |
+
return Math.round(((cur - prev) / prev) * 10000) / 100;
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
return {
|
| 281 |
+
current_period: { since: currentSince, until: now, ...current },
|
| 282 |
+
previous_period: { since: previousSince, until: currentSince, ...previous },
|
| 283 |
+
change: {
|
| 284 |
+
activities_pct: pctChange(current.activities, previous.activities),
|
| 285 |
+
errors_pct: pctChange(current.errors, previous.errors),
|
| 286 |
+
tasks_completed_pct: pctChange(current.tasks_completed, previous.tasks_completed),
|
| 287 |
+
},
|
| 288 |
+
alerts: buildTrendAlerts(current, previous),
|
| 289 |
+
};
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/** Generate automatic alerts from trend data */
|
| 293 |
+
function buildTrendAlerts(current: { activities: number; errors: number; tasks_completed: number }, previous: { activities: number; errors: number; tasks_completed: number }) {
|
| 294 |
+
const alerts: Array<{ level: string; message: string }> = [];
|
| 295 |
+
|
| 296 |
+
// Error rate spike
|
| 297 |
+
if (current.errors > 0 && previous.errors > 0) {
|
| 298 |
+
const errorIncrease = (current.errors - previous.errors) / previous.errors;
|
| 299 |
+
if (errorIncrease > 0.5) {
|
| 300 |
+
alerts.push({ level: 'warning', message: `Error count increased ${Math.round(errorIncrease * 100)}% vs previous period` });
|
| 301 |
+
}
|
| 302 |
+
} else if (current.errors > 3 && previous.errors === 0) {
|
| 303 |
+
alerts.push({ level: 'warning', message: `New error pattern: ${current.errors} errors (none in previous period)` });
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// Throughput drop
|
| 307 |
+
if (previous.tasks_completed > 0 && current.tasks_completed === 0) {
|
| 308 |
+
alerts.push({ level: 'info', message: 'No tasks completed in current period (possible stall)' });
|
| 309 |
+
} else if (previous.tasks_completed > 2 && current.tasks_completed < previous.tasks_completed * 0.5) {
|
| 310 |
+
alerts.push({ level: 'info', message: `Task throughput dropped ${Math.round((1 - current.tasks_completed / previous.tasks_completed) * 100)}%` });
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// Activity drop (possible offline)
|
| 314 |
+
if (previous.activities > 5 && current.activities < previous.activities * 0.25) {
|
| 315 |
+
alerts.push({ level: 'warning', message: `Activity dropped ${Math.round((1 - current.activities / previous.activities) * 100)}% — agent may be stalled` });
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
return alerts;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/** Token usage by model */
|
| 322 |
+
function buildTokenMetrics(db: any, agentName: string, workspaceId: number, since: number) {
|
| 323 |
+
try {
|
| 324 |
+
// session_id on token_usage may store agent name or session key
|
| 325 |
+
const byModel = db.prepare(
|
| 326 |
+
`SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COUNT(*) as request_count FROM token_usage WHERE session_id = ? AND workspace_id = ? AND created_at >= ? GROUP BY model ORDER BY (input_tokens + output_tokens) DESC`
|
| 327 |
+
).all(agentName, workspaceId, since) as Array<{ model: string; input_tokens: number; output_tokens: number; request_count: number }>;
|
| 328 |
+
|
| 329 |
+
const total = byModel.reduce((acc, r) => ({
|
| 330 |
+
input_tokens: acc.input_tokens + r.input_tokens,
|
| 331 |
+
output_tokens: acc.output_tokens + r.output_tokens,
|
| 332 |
+
requests: acc.requests + r.request_count,
|
| 333 |
+
}), { input_tokens: 0, output_tokens: 0, requests: 0 });
|
| 334 |
+
|
| 335 |
+
return {
|
| 336 |
+
by_model: byModel,
|
| 337 |
+
total,
|
| 338 |
+
};
|
| 339 |
+
} catch {
|
| 340 |
+
// token_usage table may not exist
|
| 341 |
+
return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 } };
|
| 342 |
+
}
|
| 343 |
+
}
|
src/app/api/agents/route.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth';
|
|
| 8 |
import { mutationLimiter } from '@/lib/rate-limit';
|
| 9 |
import { logger } from '@/lib/logger';
|
| 10 |
import { validateBody, createAgentSchema } from '@/lib/validation';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
/**
|
| 13 |
* GET /api/agents - List all agents with optional filtering
|
|
@@ -123,6 +127,7 @@ export async function POST(request: NextRequest) {
|
|
| 123 |
|
| 124 |
const {
|
| 125 |
name,
|
|
|
|
| 126 |
role,
|
| 127 |
session_key,
|
| 128 |
soul_content,
|
|
@@ -130,9 +135,16 @@ export async function POST(request: NextRequest) {
|
|
| 130 |
config = {},
|
| 131 |
template,
|
| 132 |
gateway_config,
|
| 133 |
-
write_to_gateway
|
|
|
|
|
|
|
| 134 |
} = body;
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
// Resolve template if specified
|
| 137 |
let finalRole = role;
|
| 138 |
let finalConfig: Record<string, any> = { ...config };
|
|
@@ -158,6 +170,32 @@ export async function POST(request: NextRequest) {
|
|
| 158 |
if (existingAgent) {
|
| 159 |
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
|
| 160 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
const now = Math.floor(Date.now() / 1000);
|
| 163 |
|
|
@@ -215,7 +253,6 @@ export async function POST(request: NextRequest) {
|
|
| 215 |
// Write to gateway config if requested
|
| 216 |
if (write_to_gateway && finalConfig) {
|
| 217 |
try {
|
| 218 |
-
const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-');
|
| 219 |
await writeAgentToConfig({
|
| 220 |
id: openclawId,
|
| 221 |
name,
|
|
|
|
| 8 |
import { mutationLimiter } from '@/lib/rate-limit';
|
| 9 |
import { logger } from '@/lib/logger';
|
| 10 |
import { validateBody, createAgentSchema } from '@/lib/validation';
|
| 11 |
+
import { runOpenClaw } from '@/lib/command';
|
| 12 |
+
import { config as appConfig } from '@/lib/config';
|
| 13 |
+
import { resolveWithin } from '@/lib/paths';
|
| 14 |
+
import path from 'node:path';
|
| 15 |
|
| 16 |
/**
|
| 17 |
* GET /api/agents - List all agents with optional filtering
|
|
|
|
| 127 |
|
| 128 |
const {
|
| 129 |
name,
|
| 130 |
+
openclaw_id,
|
| 131 |
role,
|
| 132 |
session_key,
|
| 133 |
soul_content,
|
|
|
|
| 135 |
config = {},
|
| 136 |
template,
|
| 137 |
gateway_config,
|
| 138 |
+
write_to_gateway,
|
| 139 |
+
provision_openclaw_workspace,
|
| 140 |
+
openclaw_workspace_path
|
| 141 |
} = body;
|
| 142 |
|
| 143 |
+
const openclawId = (openclaw_id || name || 'agent')
|
| 144 |
+
.toLowerCase()
|
| 145 |
+
.replace(/[^a-z0-9]+/g, '-')
|
| 146 |
+
.replace(/^-|-$/g, '');
|
| 147 |
+
|
| 148 |
// Resolve template if specified
|
| 149 |
let finalRole = role;
|
| 150 |
let finalConfig: Record<string, any> = { ...config };
|
|
|
|
| 170 |
if (existingAgent) {
|
| 171 |
return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
|
| 172 |
}
|
| 173 |
+
|
| 174 |
+
if (provision_openclaw_workspace) {
|
| 175 |
+
if (!appConfig.openclawStateDir) {
|
| 176 |
+
return NextResponse.json(
|
| 177 |
+
{ error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' },
|
| 178 |
+
{ status: 500 }
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const workspacePath = openclaw_workspace_path
|
| 183 |
+
? path.resolve(openclaw_workspace_path)
|
| 184 |
+
: resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId));
|
| 185 |
+
|
| 186 |
+
try {
|
| 187 |
+
await runOpenClaw(
|
| 188 |
+
['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'],
|
| 189 |
+
{ timeoutMs: 20000 }
|
| 190 |
+
);
|
| 191 |
+
} catch (provisionError: any) {
|
| 192 |
+
logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed');
|
| 193 |
+
return NextResponse.json(
|
| 194 |
+
{ error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' },
|
| 195 |
+
{ status: 502 }
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
|
| 200 |
const now = Math.floor(Date.now() / 1000);
|
| 201 |
|
|
|
|
| 253 |
// Write to gateway config if requested
|
| 254 |
if (write_to_gateway && finalConfig) {
|
| 255 |
try {
|
|
|
|
| 256 |
await writeAgentToConfig({
|
| 257 |
id: openclawId,
|
| 258 |
name,
|
src/app/api/cleanup/route.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
|
|
| 3 |
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { heavyLimiter } from '@/lib/rate-limit'
|
|
|
|
| 6 |
|
| 7 |
interface CleanupResult {
|
| 8 |
table: string
|
|
@@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
|
|
| 59 |
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
return NextResponse.json({ retention: config.retention, preview })
|
| 63 |
}
|
| 64 |
|
|
@@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
|
|
| 137 |
}
|
| 138 |
}
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
if (!dryRun && totalDeleted > 0) {
|
| 141 |
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 142 |
logAuditEvent({
|
|
|
|
| 3 |
import { getDatabase, logAuditEvent } from '@/lib/db'
|
| 4 |
import { config } from '@/lib/config'
|
| 5 |
import { heavyLimiter } from '@/lib/rate-limit'
|
| 6 |
+
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
|
| 7 |
|
| 8 |
interface CleanupResult {
|
| 9 |
table: string
|
|
|
|
| 60 |
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
|
| 61 |
}
|
| 62 |
|
| 63 |
+
if (ret.gatewaySessions > 0) {
|
| 64 |
+
preview.push({
|
| 65 |
+
table: 'Gateway Session Store',
|
| 66 |
+
retention_days: ret.gatewaySessions,
|
| 67 |
+
stale_count: countStaleGatewaySessions(ret.gatewaySessions),
|
| 68 |
+
note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
|
| 69 |
+
})
|
| 70 |
+
} else {
|
| 71 |
+
preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
return NextResponse.json({ retention: config.retention, preview })
|
| 75 |
}
|
| 76 |
|
|
|
|
| 149 |
}
|
| 150 |
}
|
| 151 |
|
| 152 |
+
if (ret.gatewaySessions > 0) {
|
| 153 |
+
const sessionPrune = dryRun
|
| 154 |
+
? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
|
| 155 |
+
: pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
| 156 |
+
results.push({
|
| 157 |
+
table: 'Gateway Session Store',
|
| 158 |
+
deleted: sessionPrune.deleted,
|
| 159 |
+
cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
|
| 160 |
+
retention_days: ret.gatewaySessions,
|
| 161 |
+
})
|
| 162 |
+
totalDeleted += sessionPrune.deleted
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
if (!dryRun && totalDeleted > 0) {
|
| 166 |
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
|
| 167 |
logAuditEvent({
|
src/app/api/memory/route.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
|
|
| 9 |
import { logger } from '@/lib/logger'
|
| 10 |
|
| 11 |
const MEMORY_PATH = config.memoryDir
|
|
|
|
| 12 |
|
| 13 |
// Ensure memory directory exists on startup
|
| 14 |
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
|
@@ -24,6 +25,16 @@ interface MemoryFile {
|
|
| 24 |
children?: MemoryFile[]
|
| 25 |
}
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
function isWithinBase(base: string, candidate: string): boolean {
|
| 28 |
if (candidate === base) return true
|
| 29 |
return candidate.startsWith(base + sep)
|
|
@@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
|
|
| 137 |
if (!MEMORY_PATH) {
|
| 138 |
return NextResponse.json({ tree: [] })
|
| 139 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
const tree = await buildFileTree(MEMORY_PATH)
|
| 141 |
return NextResponse.json({ tree })
|
| 142 |
}
|
| 143 |
|
| 144 |
if (action === 'content' && path) {
|
| 145 |
// Return file content
|
|
|
|
|
|
|
|
|
|
| 146 |
if (!MEMORY_PATH) {
|
| 147 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
| 148 |
}
|
|
@@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
|
|
| 227 |
}
|
| 228 |
}
|
| 229 |
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
return NextResponse.json({
|
| 233 |
query,
|
|
@@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
|
|
| 256 |
if (!path) {
|
| 257 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 258 |
}
|
|
|
|
|
|
|
|
|
|
| 259 |
|
| 260 |
if (!MEMORY_PATH) {
|
| 261 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
@@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
|
|
| 316 |
if (!path) {
|
| 317 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 318 |
}
|
|
|
|
|
|
|
|
|
|
| 319 |
|
| 320 |
if (!MEMORY_PATH) {
|
| 321 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
|
|
| 9 |
import { logger } from '@/lib/logger'
|
| 10 |
|
| 11 |
const MEMORY_PATH = config.memoryDir
|
| 12 |
+
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
|
| 13 |
|
| 14 |
// Ensure memory directory exists on startup
|
| 15 |
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
|
|
|
|
| 25 |
children?: MemoryFile[]
|
| 26 |
}
|
| 27 |
|
| 28 |
+
function normalizeRelativePath(value: string): string {
|
| 29 |
+
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function isPathAllowed(relativePath: string): boolean {
|
| 33 |
+
if (!MEMORY_ALLOWED_PREFIXES.length) return true
|
| 34 |
+
const normalized = normalizeRelativePath(relativePath)
|
| 35 |
+
return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
function isWithinBase(base: string, candidate: string): boolean {
|
| 39 |
if (candidate === base) return true
|
| 40 |
return candidate.startsWith(base + sep)
|
|
|
|
| 148 |
if (!MEMORY_PATH) {
|
| 149 |
return NextResponse.json({ tree: [] })
|
| 150 |
}
|
| 151 |
+
if (MEMORY_ALLOWED_PREFIXES.length) {
|
| 152 |
+
const tree: MemoryFile[] = []
|
| 153 |
+
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
| 154 |
+
const folder = prefix.replace(/\/$/, '')
|
| 155 |
+
const fullPath = join(MEMORY_PATH, folder)
|
| 156 |
+
if (!existsSync(fullPath)) continue
|
| 157 |
+
try {
|
| 158 |
+
const stats = await stat(fullPath)
|
| 159 |
+
if (!stats.isDirectory()) continue
|
| 160 |
+
tree.push({
|
| 161 |
+
path: folder,
|
| 162 |
+
name: folder,
|
| 163 |
+
type: 'directory',
|
| 164 |
+
modified: stats.mtime.getTime(),
|
| 165 |
+
children: await buildFileTree(fullPath, folder),
|
| 166 |
+
})
|
| 167 |
+
} catch {
|
| 168 |
+
// Skip unreadable roots
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
return NextResponse.json({ tree })
|
| 172 |
+
}
|
| 173 |
const tree = await buildFileTree(MEMORY_PATH)
|
| 174 |
return NextResponse.json({ tree })
|
| 175 |
}
|
| 176 |
|
| 177 |
if (action === 'content' && path) {
|
| 178 |
// Return file content
|
| 179 |
+
if (!isPathAllowed(path)) {
|
| 180 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 181 |
+
}
|
| 182 |
if (!MEMORY_PATH) {
|
| 183 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
| 184 |
}
|
|
|
|
| 263 |
}
|
| 264 |
}
|
| 265 |
|
| 266 |
+
if (MEMORY_ALLOWED_PREFIXES.length) {
|
| 267 |
+
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
|
| 268 |
+
const folder = prefix.replace(/\/$/, '')
|
| 269 |
+
const fullPath = join(MEMORY_PATH, folder)
|
| 270 |
+
if (!existsSync(fullPath)) continue
|
| 271 |
+
await searchDirectory(fullPath, folder)
|
| 272 |
+
}
|
| 273 |
+
} else {
|
| 274 |
+
await searchDirectory(MEMORY_PATH)
|
| 275 |
+
}
|
| 276 |
|
| 277 |
return NextResponse.json({
|
| 278 |
query,
|
|
|
|
| 301 |
if (!path) {
|
| 302 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 303 |
}
|
| 304 |
+
if (!isPathAllowed(path)) {
|
| 305 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 306 |
+
}
|
| 307 |
|
| 308 |
if (!MEMORY_PATH) {
|
| 309 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
|
|
|
| 364 |
if (!path) {
|
| 365 |
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
|
| 366 |
}
|
| 367 |
+
if (!isPathAllowed(path)) {
|
| 368 |
+
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
|
| 369 |
+
}
|
| 370 |
|
| 371 |
if (!MEMORY_PATH) {
|
| 372 |
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
|
src/app/api/settings/route.ts
CHANGED
|
@@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
|
|
| 23 |
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
| 24 |
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
| 25 |
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
|
|
|
| 26 |
|
| 27 |
// Gateway
|
| 28 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
|
|
|
| 23 |
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
|
| 24 |
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
|
| 25 |
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
|
| 26 |
+
'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
|
| 27 |
|
| 28 |
// Gateway
|
| 29 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
src/app/api/status/route.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
import net from 'node:net'
|
|
|
|
| 3 |
import { existsSync, statSync } from 'node:fs'
|
| 4 |
import path from 'node:path'
|
| 5 |
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
|
|
@@ -195,29 +196,48 @@ async function getSystemStatus(workspaceId: number) {
|
|
| 195 |
}
|
| 196 |
|
| 197 |
try {
|
| 198 |
-
// System uptime
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
} catch (error) {
|
| 205 |
logger.error({ err: error }, 'Error getting uptime')
|
| 206 |
}
|
| 207 |
|
| 208 |
try {
|
| 209 |
-
// Memory info
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
}
|
| 222 |
}
|
| 223 |
} catch (error) {
|
|
@@ -414,14 +434,17 @@ async function performHealthCheck() {
|
|
| 414 |
})
|
| 415 |
}
|
| 416 |
|
| 417 |
-
// Check disk space
|
| 418 |
try {
|
| 419 |
-
const { stdout } = await runCommand('df', ['
|
| 420 |
timeoutMs: 3000
|
| 421 |
})
|
| 422 |
const lines = stdout.trim().split('\n')
|
| 423 |
const last = lines[lines.length - 1] || ''
|
| 424 |
-
const
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
health.checks.push({
|
| 427 |
name: 'Disk Space',
|
|
@@ -436,15 +459,21 @@ async function performHealthCheck() {
|
|
| 436 |
})
|
| 437 |
}
|
| 438 |
|
| 439 |
-
// Check memory usage
|
| 440 |
try {
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
|
| 449 |
health.checks.push({
|
| 450 |
name: 'Memory Usage',
|
|
|
|
| 1 |
import { NextRequest, NextResponse } from 'next/server'
|
| 2 |
import net from 'node:net'
|
| 3 |
+
import os from 'node:os'
|
| 4 |
import { existsSync, statSync } from 'node:fs'
|
| 5 |
import path from 'node:path'
|
| 6 |
import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
try {
|
| 199 |
+
// System uptime (cross-platform)
|
| 200 |
+
if (process.platform === 'darwin') {
|
| 201 |
+
const { stdout } = await runCommand('sysctl', ['-n', 'kern.boottime'], {
|
| 202 |
+
timeoutMs: 3000
|
| 203 |
+
})
|
| 204 |
+
// Output format: { sec = 1234567890, usec = 0 } ...
|
| 205 |
+
const match = stdout.match(/sec\s*=\s*(\d+)/)
|
| 206 |
+
if (match) {
|
| 207 |
+
status.uptime = Date.now() - parseInt(match[1]) * 1000
|
| 208 |
+
}
|
| 209 |
+
} else {
|
| 210 |
+
const { stdout } = await runCommand('uptime', ['-s'], {
|
| 211 |
+
timeoutMs: 3000
|
| 212 |
+
})
|
| 213 |
+
const bootTime = new Date(stdout.trim())
|
| 214 |
+
status.uptime = Date.now() - bootTime.getTime()
|
| 215 |
+
}
|
| 216 |
} catch (error) {
|
| 217 |
logger.error({ err: error }, 'Error getting uptime')
|
| 218 |
}
|
| 219 |
|
| 220 |
try {
|
| 221 |
+
// Memory info (cross-platform)
|
| 222 |
+
if (process.platform === 'darwin') {
|
| 223 |
+
const totalBytes = os.totalmem()
|
| 224 |
+
const freeBytes = os.freemem()
|
| 225 |
+
const totalMB = Math.round(totalBytes / (1024 * 1024))
|
| 226 |
+
const usedMB = Math.round((totalBytes - freeBytes) / (1024 * 1024))
|
| 227 |
+
const availableMB = Math.round(freeBytes / (1024 * 1024))
|
| 228 |
+
status.memory = { total: totalMB, used: usedMB, available: availableMB }
|
| 229 |
+
} else {
|
| 230 |
+
const { stdout: memOutput } = await runCommand('free', ['-m'], {
|
| 231 |
+
timeoutMs: 3000
|
| 232 |
+
})
|
| 233 |
+
const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:'))
|
| 234 |
+
if (memLine) {
|
| 235 |
+
const parts = memLine.split(/\s+/)
|
| 236 |
+
status.memory = {
|
| 237 |
+
total: parseInt(parts[1]) || 0,
|
| 238 |
+
used: parseInt(parts[2]) || 0,
|
| 239 |
+
available: parseInt(parts[6]) || 0
|
| 240 |
+
}
|
| 241 |
}
|
| 242 |
}
|
| 243 |
} catch (error) {
|
|
|
|
| 434 |
})
|
| 435 |
}
|
| 436 |
|
| 437 |
+
// Check disk space (cross-platform: use df -h / and parse capacity column)
|
| 438 |
try {
|
| 439 |
+
const { stdout } = await runCommand('df', ['-h', '/'], {
|
| 440 |
timeoutMs: 3000
|
| 441 |
})
|
| 442 |
const lines = stdout.trim().split('\n')
|
| 443 |
const last = lines[lines.length - 1] || ''
|
| 444 |
+
const parts = last.split(/\s+/)
|
| 445 |
+
// On macOS capacity is col 4 ("85%"), on Linux use% is col 4 as well
|
| 446 |
+
const pctField = parts.find(p => p.endsWith('%')) || '0%'
|
| 447 |
+
const usagePercent = parseInt(pctField.replace('%', '') || '0')
|
| 448 |
|
| 449 |
health.checks.push({
|
| 450 |
name: 'Disk Space',
|
|
|
|
| 459 |
})
|
| 460 |
}
|
| 461 |
|
| 462 |
+
// Check memory usage (cross-platform)
|
| 463 |
try {
|
| 464 |
+
let usagePercent: number
|
| 465 |
+
if (process.platform === 'darwin') {
|
| 466 |
+
const totalBytes = os.totalmem()
|
| 467 |
+
const freeBytes = os.freemem()
|
| 468 |
+
usagePercent = Math.round(((totalBytes - freeBytes) / totalBytes) * 100)
|
| 469 |
+
} else {
|
| 470 |
+
const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
|
| 471 |
+
const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:'))
|
| 472 |
+
const parts = (memLine || '').split(/\s+/)
|
| 473 |
+
const total = parseInt(parts[1] || '0')
|
| 474 |
+
const available = parseInt(parts[6] || '0')
|
| 475 |
+
usagePercent = Math.round(((total - available) / total) * 100)
|
| 476 |
+
}
|
| 477 |
|
| 478 |
health.checks.push({
|
| 479 |
name: 'Memory Usage',
|
src/app/api/tasks/[id]/route.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
|
|
| 6 |
import { logger } from '@/lib/logger';
|
| 7 |
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
| 8 |
import { resolveMentionRecipients } from '@/lib/mentions';
|
|
|
|
| 9 |
|
| 10 |
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
| 11 |
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
|
@@ -115,7 +116,7 @@ export async function PUT(
|
|
| 115 |
const {
|
| 116 |
title,
|
| 117 |
description,
|
| 118 |
-
status,
|
| 119 |
priority,
|
| 120 |
project_id,
|
| 121 |
assigned_to,
|
|
@@ -125,6 +126,12 @@ export async function PUT(
|
|
| 125 |
tags,
|
| 126 |
metadata
|
| 127 |
} = body;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
const now = Math.floor(Date.now() / 1000);
|
| 130 |
const descriptionMentionResolution = description !== undefined
|
|
@@ -152,15 +159,15 @@ export async function PUT(
|
|
| 152 |
fieldsToUpdate.push('description = ?');
|
| 153 |
updateParams.push(description);
|
| 154 |
}
|
| 155 |
-
if (
|
| 156 |
-
if (
|
| 157 |
return NextResponse.json(
|
| 158 |
{ error: 'Aegis approval is required to move task to done.' },
|
| 159 |
{ status: 403 }
|
| 160 |
)
|
| 161 |
}
|
| 162 |
fieldsToUpdate.push('status = ?');
|
| 163 |
-
updateParams.push(
|
| 164 |
}
|
| 165 |
if (priority !== undefined) {
|
| 166 |
fieldsToUpdate.push('priority = ?');
|
|
@@ -240,8 +247,8 @@ export async function PUT(
|
|
| 240 |
// Track changes and log activities
|
| 241 |
const changes: string[] = [];
|
| 242 |
|
| 243 |
-
if (
|
| 244 |
-
changes.push(`status: ${currentTask.status} → ${
|
| 245 |
|
| 246 |
// Create notification for status change if assigned
|
| 247 |
if (currentTask.assigned_to) {
|
|
@@ -249,7 +256,7 @@ export async function PUT(
|
|
| 249 |
currentTask.assigned_to,
|
| 250 |
'status_change',
|
| 251 |
'Task Status Updated',
|
| 252 |
-
`Task "${currentTask.title}" status changed to ${
|
| 253 |
'task',
|
| 254 |
taskId,
|
| 255 |
workspaceId
|
|
@@ -322,7 +329,7 @@ export async function PUT(
|
|
| 322 |
priority: currentTask.priority,
|
| 323 |
assigned_to: currentTask.assigned_to
|
| 324 |
},
|
| 325 |
-
newValues: { title, status, priority, assigned_to }
|
| 326 |
},
|
| 327 |
workspaceId
|
| 328 |
);
|
|
|
|
| 6 |
import { logger } from '@/lib/logger';
|
| 7 |
import { validateBody, updateTaskSchema } from '@/lib/validation';
|
| 8 |
import { resolveMentionRecipients } from '@/lib/mentions';
|
| 9 |
+
import { normalizeTaskUpdateStatus } from '@/lib/task-status';
|
| 10 |
|
| 11 |
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
| 12 |
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
|
|
|
| 116 |
const {
|
| 117 |
title,
|
| 118 |
description,
|
| 119 |
+
status: requestedStatus,
|
| 120 |
priority,
|
| 121 |
project_id,
|
| 122 |
assigned_to,
|
|
|
|
| 126 |
tags,
|
| 127 |
metadata
|
| 128 |
} = body;
|
| 129 |
+
const normalizedStatus = normalizeTaskUpdateStatus({
|
| 130 |
+
currentStatus: currentTask.status,
|
| 131 |
+
requestedStatus,
|
| 132 |
+
assignedTo: assigned_to,
|
| 133 |
+
assignedToProvided: assigned_to !== undefined,
|
| 134 |
+
})
|
| 135 |
|
| 136 |
const now = Math.floor(Date.now() / 1000);
|
| 137 |
const descriptionMentionResolution = description !== undefined
|
|
|
|
| 159 |
fieldsToUpdate.push('description = ?');
|
| 160 |
updateParams.push(description);
|
| 161 |
}
|
| 162 |
+
if (normalizedStatus !== undefined) {
|
| 163 |
+
if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
|
| 164 |
return NextResponse.json(
|
| 165 |
{ error: 'Aegis approval is required to move task to done.' },
|
| 166 |
{ status: 403 }
|
| 167 |
)
|
| 168 |
}
|
| 169 |
fieldsToUpdate.push('status = ?');
|
| 170 |
+
updateParams.push(normalizedStatus);
|
| 171 |
}
|
| 172 |
if (priority !== undefined) {
|
| 173 |
fieldsToUpdate.push('priority = ?');
|
|
|
|
| 247 |
// Track changes and log activities
|
| 248 |
const changes: string[] = [];
|
| 249 |
|
| 250 |
+
if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) {
|
| 251 |
+
changes.push(`status: ${currentTask.status} → ${normalizedStatus}`);
|
| 252 |
|
| 253 |
// Create notification for status change if assigned
|
| 254 |
if (currentTask.assigned_to) {
|
|
|
|
| 256 |
currentTask.assigned_to,
|
| 257 |
'status_change',
|
| 258 |
'Task Status Updated',
|
| 259 |
+
`Task "${currentTask.title}" status changed to ${normalizedStatus}`,
|
| 260 |
'task',
|
| 261 |
taskId,
|
| 262 |
workspaceId
|
|
|
|
| 329 |
priority: currentTask.priority,
|
| 330 |
assigned_to: currentTask.assigned_to
|
| 331 |
},
|
| 332 |
+
newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to }
|
| 333 |
},
|
| 334 |
workspaceId
|
| 335 |
);
|
src/app/api/tasks/route.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
|
|
| 6 |
import { logger } from '@/lib/logger';
|
| 7 |
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
| 8 |
import { resolveMentionRecipients } from '@/lib/mentions';
|
|
|
|
| 9 |
|
| 10 |
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
| 11 |
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
|
@@ -163,7 +164,7 @@ export async function POST(request: NextRequest) {
|
|
| 163 |
const {
|
| 164 |
title,
|
| 165 |
description,
|
| 166 |
-
status
|
| 167 |
priority = 'medium',
|
| 168 |
project_id,
|
| 169 |
assigned_to,
|
|
@@ -173,6 +174,7 @@ export async function POST(request: NextRequest) {
|
|
| 173 |
tags = [],
|
| 174 |
metadata = {}
|
| 175 |
} = body;
|
|
|
|
| 176 |
|
| 177 |
// Check for duplicate title
|
| 178 |
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
|
|
@@ -212,7 +214,7 @@ export async function POST(request: NextRequest) {
|
|
| 212 |
const dbResult = insertStmt.run(
|
| 213 |
title,
|
| 214 |
description,
|
| 215 |
-
|
| 216 |
priority,
|
| 217 |
resolvedProjectId,
|
| 218 |
row.ticket_counter,
|
|
@@ -234,7 +236,7 @@ export async function POST(request: NextRequest) {
|
|
| 234 |
// Log activity
|
| 235 |
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
|
| 236 |
title,
|
| 237 |
-
status,
|
| 238 |
priority,
|
| 239 |
assigned_to
|
| 240 |
}, workspaceId);
|
|
|
|
| 6 |
import { logger } from '@/lib/logger';
|
| 7 |
import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
|
| 8 |
import { resolveMentionRecipients } from '@/lib/mentions';
|
| 9 |
+
import { normalizeTaskCreateStatus } from '@/lib/task-status';
|
| 10 |
|
| 11 |
function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
|
| 12 |
if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
|
|
|
|
| 164 |
const {
|
| 165 |
title,
|
| 166 |
description,
|
| 167 |
+
status,
|
| 168 |
priority = 'medium',
|
| 169 |
project_id,
|
| 170 |
assigned_to,
|
|
|
|
| 174 |
tags = [],
|
| 175 |
metadata = {}
|
| 176 |
} = body;
|
| 177 |
+
const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
|
| 178 |
|
| 179 |
// Check for duplicate title
|
| 180 |
const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
|
|
|
|
| 214 |
const dbResult = insertStmt.run(
|
| 215 |
title,
|
| 216 |
description,
|
| 217 |
+
normalizedStatus,
|
| 218 |
priority,
|
| 219 |
resolvedProjectId,
|
| 220 |
row.ticket_counter,
|
|
|
|
| 236 |
// Log activity
|
| 237 |
db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
|
| 238 |
title,
|
| 239 |
+
status: normalizedStatus,
|
| 240 |
priority,
|
| 241 |
assigned_to
|
| 242 |
}, workspaceId);
|
src/components/dashboard/dashboard.tsx
CHANGED
|
@@ -522,7 +522,7 @@ export function Dashboard() {
|
|
| 522 |
{isLocal ? (
|
| 523 |
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
| 524 |
) : (
|
| 525 |
-
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="
|
| 526 |
)}
|
| 527 |
</div>
|
| 528 |
</div>
|
|
|
|
| 522 |
{isLocal ? (
|
| 523 |
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
|
| 524 |
) : (
|
| 525 |
+
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
|
| 526 |
)}
|
| 527 |
</div>
|
| 528 |
</div>
|
src/components/layout/live-feed.tsx
CHANGED
|
@@ -7,6 +7,7 @@ export function LiveFeed() {
|
|
| 7 |
const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
|
| 8 |
const isLocal = dashboardMode === 'local'
|
| 9 |
const [expanded, setExpanded] = useState(true)
|
|
|
|
| 10 |
|
| 11 |
// Combine logs, activities, and (in local mode) session events into a unified feed
|
| 12 |
const sessionItems = isLocal
|
|
@@ -70,7 +71,7 @@ export function LiveFeed() {
|
|
| 70 |
}
|
| 71 |
|
| 72 |
return (
|
| 73 |
-
<div className=
|
| 74 |
{/* Header */}
|
| 75 |
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
|
| 76 |
<div className="flex items-center gap-2">
|
|
@@ -80,7 +81,7 @@ export function LiveFeed() {
|
|
| 80 |
</div>
|
| 81 |
<div className="flex items-center gap-0.5">
|
| 82 |
<button
|
| 83 |
-
onClick={() => setExpanded(false)}
|
| 84 |
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
| 85 |
title="Collapse feed"
|
| 86 |
>
|
|
|
|
| 7 |
const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
|
| 8 |
const isLocal = dashboardMode === 'local'
|
| 9 |
const [expanded, setExpanded] = useState(true)
|
| 10 |
+
const [hasCollapsed, setHasCollapsed] = useState(false)
|
| 11 |
|
| 12 |
// Combine logs, activities, and (in local mode) session events into a unified feed
|
| 13 |
const sessionItems = isLocal
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
return (
|
| 74 |
+
<div className={`w-72 h-full bg-card border-l border-border flex flex-col shrink-0${hasCollapsed ? ' slide-in-right' : ''}`}>
|
| 75 |
{/* Header */}
|
| 76 |
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
|
| 77 |
<div className="flex items-center gap-2">
|
|
|
|
| 81 |
</div>
|
| 82 |
<div className="flex items-center gap-0.5">
|
| 83 |
<button
|
| 84 |
+
onClick={() => { setExpanded(false); setHasCollapsed(true) }}
|
| 85 |
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
| 86 |
title="Collapse feed"
|
| 87 |
>
|
src/components/panels/agent-detail-tabs.tsx
CHANGED
|
@@ -517,7 +517,7 @@ export function MemoryTab({
|
|
| 517 |
<div>
|
| 518 |
<h4 className="text-lg font-medium text-foreground">Working Memory</h4>
|
| 519 |
<p className="text-xs text-muted-foreground mt-1">
|
| 520 |
-
|
| 521 |
</p>
|
| 522 |
</div>
|
| 523 |
<div className="flex gap-2">
|
|
@@ -543,6 +543,14 @@ export function MemoryTab({
|
|
| 543 |
</div>
|
| 544 |
</div>
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
{/* Memory Content */}
|
| 547 |
<div>
|
| 548 |
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
@@ -852,6 +860,7 @@ export function CreateAgentModal({
|
|
| 852 |
dockerNetwork: 'none' as 'none' | 'bridge',
|
| 853 |
session_key: '',
|
| 854 |
write_to_gateway: true,
|
|
|
|
| 855 |
})
|
| 856 |
const [isCreating, setIsCreating] = useState(false)
|
| 857 |
const [error, setError] = useState<string | null>(null)
|
|
@@ -916,10 +925,12 @@ export function CreateAgentModal({
|
|
| 916 |
headers: { 'Content-Type': 'application/json' },
|
| 917 |
body: JSON.stringify({
|
| 918 |
name: formData.name,
|
|
|
|
| 919 |
role: formData.role,
|
| 920 |
session_key: formData.session_key || undefined,
|
| 921 |
template: selectedTemplate || undefined,
|
| 922 |
write_to_gateway: formData.write_to_gateway,
|
|
|
|
| 923 |
gateway_config: {
|
| 924 |
model: { primary: primaryModel },
|
| 925 |
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
|
|
@@ -1199,6 +1210,16 @@ export function CreateAgentModal({
|
|
| 1199 |
/>
|
| 1200 |
<span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
|
| 1201 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1202 |
</div>
|
| 1203 |
)}
|
| 1204 |
</div>
|
|
|
|
| 517 |
<div>
|
| 518 |
<h4 className="text-lg font-medium text-foreground">Working Memory</h4>
|
| 519 |
<p className="text-xs text-muted-foreground mt-1">
|
| 520 |
+
This is <strong className="text-foreground">agent-level</strong> scratchpad memory (stored as WORKING.md in the database), not the workspace memory folder.
|
| 521 |
</p>
|
| 522 |
</div>
|
| 523 |
<div className="flex gap-2">
|
|
|
|
| 543 |
</div>
|
| 544 |
</div>
|
| 545 |
|
| 546 |
+
{/* Info Banner */}
|
| 547 |
+
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
|
| 548 |
+
<strong className="text-blue-200">Agent Memory vs Workspace Memory:</strong>{' '}
|
| 549 |
+
This tab edits only this agent's private working memory (a scratchpad stored in the database).
|
| 550 |
+
To browse or edit all workspace memory files (daily logs, knowledge base, MEMORY.md, etc.), visit the{' '}
|
| 551 |
+
<Link href="/memory" className="text-blue-400 underline hover:text-blue-300">Memory Browser</Link> page.
|
| 552 |
+
</div>
|
| 553 |
+
|
| 554 |
{/* Memory Content */}
|
| 555 |
<div>
|
| 556 |
<label className="block text-sm font-medium text-muted-foreground mb-1">
|
|
|
|
| 860 |
dockerNetwork: 'none' as 'none' | 'bridge',
|
| 861 |
session_key: '',
|
| 862 |
write_to_gateway: true,
|
| 863 |
+
provision_openclaw_workspace: true,
|
| 864 |
})
|
| 865 |
const [isCreating, setIsCreating] = useState(false)
|
| 866 |
const [error, setError] = useState<string | null>(null)
|
|
|
|
| 925 |
headers: { 'Content-Type': 'application/json' },
|
| 926 |
body: JSON.stringify({
|
| 927 |
name: formData.name,
|
| 928 |
+
openclaw_id: formData.id || undefined,
|
| 929 |
role: formData.role,
|
| 930 |
session_key: formData.session_key || undefined,
|
| 931 |
template: selectedTemplate || undefined,
|
| 932 |
write_to_gateway: formData.write_to_gateway,
|
| 933 |
+
provision_openclaw_workspace: formData.provision_openclaw_workspace,
|
| 934 |
gateway_config: {
|
| 935 |
model: { primary: primaryModel },
|
| 936 |
identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
|
|
|
|
| 1210 |
/>
|
| 1211 |
<span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
|
| 1212 |
</label>
|
| 1213 |
+
|
| 1214 |
+
<label className="flex items-center gap-2 cursor-pointer">
|
| 1215 |
+
<input
|
| 1216 |
+
type="checkbox"
|
| 1217 |
+
checked={formData.provision_openclaw_workspace}
|
| 1218 |
+
onChange={(e) => setFormData(prev => ({ ...prev, provision_openclaw_workspace: e.target.checked }))}
|
| 1219 |
+
className="w-4 h-4 rounded border-border"
|
| 1220 |
+
/>
|
| 1221 |
+
<span className="text-sm text-foreground">Provision full OpenClaw workspace (`openclaw agents add`)</span>
|
| 1222 |
+
</label>
|
| 1223 |
</div>
|
| 1224 |
)}
|
| 1225 |
</div>
|
src/components/panels/agent-squad-panel-phase3.tsx
CHANGED
|
@@ -96,7 +96,14 @@ export function AgentSquadPanelPhase3() {
|
|
| 96 |
setSyncToast(null)
|
| 97 |
try {
|
| 98 |
const response = await fetch('/api/agents/sync', { method: 'POST' })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
| 100 |
if (!response.ok) throw new Error(data.error || 'Sync failed')
|
| 101 |
setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
|
| 102 |
fetchAgents()
|
|
@@ -116,7 +123,17 @@ export function AgentSquadPanelPhase3() {
|
|
| 116 |
if (agents.length === 0) setLoading(true)
|
| 117 |
|
| 118 |
const response = await fetch('/api/agents')
|
| 119 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
const data = await response.json()
|
| 122 |
setAgents(data.agents || [])
|
|
|
|
| 96 |
setSyncToast(null)
|
| 97 |
try {
|
| 98 |
const response = await fetch('/api/agents/sync', { method: 'POST' })
|
| 99 |
+
if (response.status === 401) {
|
| 100 |
+
window.location.assign('/login?next=%2Fagents')
|
| 101 |
+
return
|
| 102 |
+
}
|
| 103 |
const data = await response.json()
|
| 104 |
+
if (response.status === 403) {
|
| 105 |
+
throw new Error('Admin access required for agent sync')
|
| 106 |
+
}
|
| 107 |
if (!response.ok) throw new Error(data.error || 'Sync failed')
|
| 108 |
setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
|
| 109 |
fetchAgents()
|
|
|
|
| 123 |
if (agents.length === 0) setLoading(true)
|
| 124 |
|
| 125 |
const response = await fetch('/api/agents')
|
| 126 |
+
if (response.status === 401) {
|
| 127 |
+
window.location.assign('/login?next=%2Fagents')
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
if (response.status === 403) {
|
| 131 |
+
throw new Error('Access denied')
|
| 132 |
+
}
|
| 133 |
+
if (!response.ok) {
|
| 134 |
+
const data = await response.json().catch(() => ({}))
|
| 135 |
+
throw new Error(data.error || 'Failed to fetch agents')
|
| 136 |
+
}
|
| 137 |
|
| 138 |
const data = await response.json()
|
| 139 |
setAgents(data.agents || [])
|
src/components/panels/memory-browser-panel.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
|
|
| 47 |
setMemoryFiles(data.tree || [])
|
| 48 |
|
| 49 |
// Auto-expand some common directories
|
| 50 |
-
setExpandedFolders(new Set(['daily', 'knowledge']))
|
| 51 |
} catch (error) {
|
| 52 |
log.error('Failed to load file tree:', error)
|
| 53 |
} finally {
|
|
@@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
|
|
| 61 |
|
| 62 |
const getFilteredFiles = () => {
|
| 63 |
if (activeTab === 'all') return memoryFiles
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
return true
|
| 73 |
})
|
| 74 |
}
|
| 75 |
|
|
@@ -731,6 +730,8 @@ function CreateFileModal({
|
|
| 731 |
onChange={(e) => setFilePath(e.target.value)}
|
| 732 |
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 733 |
>
|
|
|
|
|
|
|
| 734 |
<option value="knowledge/">knowledge/</option>
|
| 735 |
<option value="daily/">daily/</option>
|
| 736 |
<option value="logs/">logs/</option>
|
|
|
|
| 47 |
setMemoryFiles(data.tree || [])
|
| 48 |
|
| 49 |
// Auto-expand some common directories
|
| 50 |
+
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
|
| 51 |
} catch (error) {
|
| 52 |
log.error('Failed to load file tree:', error)
|
| 53 |
} finally {
|
|
|
|
| 61 |
|
| 62 |
const getFilteredFiles = () => {
|
| 63 |
if (activeTab === 'all') return memoryFiles
|
| 64 |
+
|
| 65 |
+
const tabPrefixes = activeTab === 'daily'
|
| 66 |
+
? ['daily/', 'memory/']
|
| 67 |
+
: ['knowledge/', 'knowledge-base/']
|
| 68 |
+
|
| 69 |
+
return memoryFiles.filter((file) => {
|
| 70 |
+
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
|
| 71 |
+
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
|
|
|
|
| 72 |
})
|
| 73 |
}
|
| 74 |
|
|
|
|
| 730 |
onChange={(e) => setFilePath(e.target.value)}
|
| 731 |
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 732 |
>
|
| 733 |
+
<option value="knowledge-base/">knowledge-base/</option>
|
| 734 |
+
<option value="memory/">memory/</option>
|
| 735 |
<option value="knowledge/">knowledge/</option>
|
| 736 |
<option value="daily/">daily/</option>
|
| 737 |
<option value="logs/">logs/</option>
|
src/components/panels/office-panel.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
| 4 |
import { useMissionControl, Agent } from '@/store'
|
| 5 |
|
| 6 |
type ViewMode = 'office' | 'org-chart'
|
|
|
|
| 7 |
|
| 8 |
interface Desk {
|
| 9 |
agent: Agent
|
|
@@ -75,6 +76,7 @@ export function OfficePanel() {
|
|
| 75 |
const { agents } = useMissionControl()
|
| 76 |
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
| 77 |
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
|
|
|
| 78 |
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
| 79 |
const [loading, setLoading] = useState(true)
|
| 80 |
|
|
@@ -123,6 +125,64 @@ export function OfficePanel() {
|
|
| 123 |
return groups
|
| 124 |
}, [displayAgents])
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
if (loading && displayAgents.length === 0) {
|
| 127 |
return (
|
| 128 |
<div className="flex items-center justify-center h-64">
|
|
@@ -237,11 +297,40 @@ export function OfficePanel() {
|
|
| 237 |
</div>
|
| 238 |
) : (
|
| 239 |
<div className="space-y-6">
|
| 240 |
-
|
| 241 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
<div className="flex items-center gap-2 mb-4">
|
| 243 |
<div className="w-1 h-6 bg-primary rounded-full" />
|
| 244 |
-
<h3 className="font-semibold text-foreground">{
|
| 245 |
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
| 246 |
</div>
|
| 247 |
<div className="flex flex-wrap gap-3">
|
|
|
|
| 4 |
import { useMissionControl, Agent } from '@/store'
|
| 5 |
|
| 6 |
type ViewMode = 'office' | 'org-chart'
|
| 7 |
+
type OrgSegmentMode = 'category' | 'role' | 'status'
|
| 8 |
|
| 9 |
interface Desk {
|
| 10 |
agent: Agent
|
|
|
|
| 76 |
const { agents } = useMissionControl()
|
| 77 |
const [localAgents, setLocalAgents] = useState<Agent[]>([])
|
| 78 |
const [viewMode, setViewMode] = useState<ViewMode>('office')
|
| 79 |
+
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
|
| 80 |
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
|
| 81 |
const [loading, setLoading] = useState(true)
|
| 82 |
|
|
|
|
| 125 |
return groups
|
| 126 |
}, [displayAgents])
|
| 127 |
|
| 128 |
+
const categoryGroups = useMemo(() => {
|
| 129 |
+
const groups = new Map<string, Agent[]>()
|
| 130 |
+
const getCategory = (agent: Agent): string => {
|
| 131 |
+
const name = (agent.name || '').toLowerCase()
|
| 132 |
+
if (name.startsWith('habi-')) return 'Habi Lanes'
|
| 133 |
+
if (name.startsWith('ops-')) return 'Ops Automation'
|
| 134 |
+
if (name.includes('canary')) return 'Canary'
|
| 135 |
+
if (name.startsWith('main')) return 'Core'
|
| 136 |
+
if (name.startsWith('remote-')) return 'Remote'
|
| 137 |
+
return 'Other'
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
for (const a of displayAgents) {
|
| 141 |
+
const category = getCategory(a)
|
| 142 |
+
if (!groups.has(category)) groups.set(category, [])
|
| 143 |
+
groups.get(category)!.push(a)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
|
| 147 |
+
return new Map(
|
| 148 |
+
[...groups.entries()].sort(([a], [b]) => {
|
| 149 |
+
const ai = order.indexOf(a)
|
| 150 |
+
const bi = order.indexOf(b)
|
| 151 |
+
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
| 152 |
+
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
| 153 |
+
if (av !== bv) return av - bv
|
| 154 |
+
return a.localeCompare(b)
|
| 155 |
+
})
|
| 156 |
+
)
|
| 157 |
+
}, [displayAgents])
|
| 158 |
+
|
| 159 |
+
const statusGroups = useMemo(() => {
|
| 160 |
+
const groups = new Map<string, Agent[]>()
|
| 161 |
+
for (const a of displayAgents) {
|
| 162 |
+
const key = statusLabel[a.status] || a.status
|
| 163 |
+
if (!groups.has(key)) groups.set(key, [])
|
| 164 |
+
groups.get(key)!.push(a)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const order = ['Working', 'Available', 'Error', 'Away']
|
| 168 |
+
return new Map(
|
| 169 |
+
[...groups.entries()].sort(([a], [b]) => {
|
| 170 |
+
const ai = order.indexOf(a)
|
| 171 |
+
const bi = order.indexOf(b)
|
| 172 |
+
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
|
| 173 |
+
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
|
| 174 |
+
if (av !== bv) return av - bv
|
| 175 |
+
return a.localeCompare(b)
|
| 176 |
+
})
|
| 177 |
+
)
|
| 178 |
+
}, [displayAgents])
|
| 179 |
+
|
| 180 |
+
const orgGroups = useMemo(() => {
|
| 181 |
+
if (orgSegmentMode === 'role') return roleGroups
|
| 182 |
+
if (orgSegmentMode === 'status') return statusGroups
|
| 183 |
+
return categoryGroups
|
| 184 |
+
}, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
|
| 185 |
+
|
| 186 |
if (loading && displayAgents.length === 0) {
|
| 187 |
return (
|
| 188 |
<div className="flex items-center justify-center h-64">
|
|
|
|
| 297 |
</div>
|
| 298 |
) : (
|
| 299 |
<div className="space-y-6">
|
| 300 |
+
<div className="flex items-center justify-between">
|
| 301 |
+
<div className="text-sm text-muted-foreground">
|
| 302 |
+
Segmented by{' '}
|
| 303 |
+
<span className="font-medium text-foreground">
|
| 304 |
+
{orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
|
| 305 |
+
</span>
|
| 306 |
+
</div>
|
| 307 |
+
<div className="flex rounded-md overflow-hidden border border-border">
|
| 308 |
+
<button
|
| 309 |
+
onClick={() => setOrgSegmentMode('category')}
|
| 310 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'category' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 311 |
+
>
|
| 312 |
+
Category
|
| 313 |
+
</button>
|
| 314 |
+
<button
|
| 315 |
+
onClick={() => setOrgSegmentMode('role')}
|
| 316 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'role' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 317 |
+
>
|
| 318 |
+
Role
|
| 319 |
+
</button>
|
| 320 |
+
<button
|
| 321 |
+
onClick={() => setOrgSegmentMode('status')}
|
| 322 |
+
className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'status' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
|
| 323 |
+
>
|
| 324 |
+
Status
|
| 325 |
+
</button>
|
| 326 |
+
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
{[...orgGroups.entries()].map(([segment, members]) => (
|
| 330 |
+
<div key={segment} className="bg-card border border-border rounded-xl p-5">
|
| 331 |
<div className="flex items-center gap-2 mb-4">
|
| 332 |
<div className="w-1 h-6 bg-primary rounded-full" />
|
| 333 |
+
<h3 className="font-semibold text-foreground">{segment}</h3>
|
| 334 |
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
|
| 335 |
</div>
|
| 336 |
<div className="flex flex-wrap gap-3">
|
src/components/panels/settings-panel.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
|
|
|
| 5 |
|
| 6 |
interface Setting {
|
| 7 |
key: string
|
|
@@ -24,6 +25,7 @@ const categoryOrder = ['general', 'retention', 'gateway', 'custom']
|
|
| 24 |
|
| 25 |
export function SettingsPanel() {
|
| 26 |
const { currentUser } = useMissionControl()
|
|
|
|
| 27 |
const [settings, setSettings] = useState<Setting[]>([])
|
| 28 |
const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
|
| 29 |
const [loading, setLoading] = useState(true)
|
|
@@ -43,12 +45,17 @@ export function SettingsPanel() {
|
|
| 43 |
const fetchSettings = useCallback(async () => {
|
| 44 |
try {
|
| 45 |
const res = await fetch('/api/settings')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
if (res.status === 403) {
|
| 47 |
setError('Admin access required')
|
| 48 |
return
|
| 49 |
}
|
| 50 |
if (!res.ok) {
|
| 51 |
-
|
|
|
|
| 52 |
return
|
| 53 |
}
|
| 54 |
const data = await res.json()
|
|
@@ -180,6 +187,21 @@ export function SettingsPanel() {
|
|
| 180 |
</div>
|
| 181 |
</div>
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
{/* Feedback */}
|
| 184 |
{feedback && (
|
| 185 |
<div className={`rounded-lg p-3 text-xs font-medium ${
|
|
|
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from 'react'
|
| 4 |
import { useMissionControl } from '@/store'
|
| 5 |
+
import { useNavigateToPanel } from '@/lib/navigation'
|
| 6 |
|
| 7 |
interface Setting {
|
| 8 |
key: string
|
|
|
|
| 25 |
|
| 26 |
export function SettingsPanel() {
|
| 27 |
const { currentUser } = useMissionControl()
|
| 28 |
+
const navigateToPanel = useNavigateToPanel()
|
| 29 |
const [settings, setSettings] = useState<Setting[]>([])
|
| 30 |
const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
|
| 31 |
const [loading, setLoading] = useState(true)
|
|
|
|
| 45 |
const fetchSettings = useCallback(async () => {
|
| 46 |
try {
|
| 47 |
const res = await fetch('/api/settings')
|
| 48 |
+
if (res.status === 401) {
|
| 49 |
+
window.location.assign('/login?next=%2Fsettings')
|
| 50 |
+
return
|
| 51 |
+
}
|
| 52 |
if (res.status === 403) {
|
| 53 |
setError('Admin access required')
|
| 54 |
return
|
| 55 |
}
|
| 56 |
if (!res.ok) {
|
| 57 |
+
const data = await res.json().catch(() => ({}))
|
| 58 |
+
setError(data.error || 'Failed to load settings')
|
| 59 |
return
|
| 60 |
}
|
| 61 |
const data = await res.json()
|
|
|
|
| 187 |
</div>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
+
{/* Workspace Info */}
|
| 191 |
+
{currentUser?.role === 'admin' && (
|
| 192 |
+
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
|
| 193 |
+
<strong className="text-blue-200">Workspace Management:</strong>{' '}
|
| 194 |
+
To create or manage workspaces (tenant instances), go to the{' '}
|
| 195 |
+
<button
|
| 196 |
+
onClick={() => navigateToPanel('super-admin')}
|
| 197 |
+
className="text-blue-400 underline hover:text-blue-300 cursor-pointer"
|
| 198 |
+
>
|
| 199 |
+
Super Admin
|
| 200 |
+
</button>{' '}
|
| 201 |
+
panel under Admin > Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
|
| 202 |
+
</div>
|
| 203 |
+
)}
|
| 204 |
+
|
| 205 |
{/* Feedback */}
|
| 206 |
{feedback && (
|
| 207 |
<div className={`rounded-lg p-3 text-xs font-medium ${
|
src/components/panels/super-admin-panel.tsx
CHANGED
|
@@ -409,12 +409,20 @@ export function SuperAdminPanel() {
|
|
| 409 |
Multi-tenant provisioning control plane with approval gates and safer destructive actions.
|
| 410 |
</p>
|
| 411 |
</div>
|
| 412 |
-
<
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
</div>
|
| 419 |
|
| 420 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
@@ -452,17 +460,21 @@ export function SuperAdminPanel() {
|
|
| 452 |
</div>
|
| 453 |
)}
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
className="
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
<div className="p-4 space-y-3">
|
| 464 |
<div className="text-xs text-muted-foreground">
|
| 465 |
-
|
| 466 |
</div>
|
| 467 |
{gatewayLoadError && (
|
| 468 |
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
|
|
@@ -540,8 +552,8 @@ export function SuperAdminPanel() {
|
|
| 540 |
</button>
|
| 541 |
</div>
|
| 542 |
</div>
|
| 543 |
-
)}
|
| 544 |
</div>
|
|
|
|
| 545 |
|
| 546 |
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
| 547 |
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
|
|
|
| 409 |
Multi-tenant provisioning control plane with approval gates and safer destructive actions.
|
| 410 |
</p>
|
| 411 |
</div>
|
| 412 |
+
<div className="flex items-center gap-2">
|
| 413 |
+
<button
|
| 414 |
+
onClick={() => setCreateExpanded(true)}
|
| 415 |
+
className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-smooth"
|
| 416 |
+
>
|
| 417 |
+
+ Add Workspace
|
| 418 |
+
</button>
|
| 419 |
+
<button
|
| 420 |
+
onClick={load}
|
| 421 |
+
className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
|
| 422 |
+
>
|
| 423 |
+
Refresh
|
| 424 |
+
</button>
|
| 425 |
+
</div>
|
| 426 |
</div>
|
| 427 |
|
| 428 |
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
|
|
|
| 460 |
</div>
|
| 461 |
)}
|
| 462 |
|
| 463 |
+
{createExpanded && (
|
| 464 |
+
<div className="rounded-lg border border-primary/30 bg-card overflow-hidden">
|
| 465 |
+
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
|
| 466 |
+
<h3 className="text-sm font-medium text-foreground">Create New Workspace</h3>
|
| 467 |
+
<button
|
| 468 |
+
onClick={() => setCreateExpanded(false)}
|
| 469 |
+
className="text-muted-foreground hover:text-foreground text-lg leading-none transition-smooth"
|
| 470 |
+
aria-label="Close create form"
|
| 471 |
+
>
|
| 472 |
+
×
|
| 473 |
+
</button>
|
| 474 |
+
</div>
|
| 475 |
<div className="p-4 space-y-3">
|
| 476 |
<div className="text-xs text-muted-foreground">
|
| 477 |
+
Fill in the workspace details below and click <span className="text-foreground font-medium">Create + Queue</span> to provision a new client instance.
|
| 478 |
</div>
|
| 479 |
{gatewayLoadError && (
|
| 480 |
<div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
|
|
|
|
| 552 |
</button>
|
| 553 |
</div>
|
| 554 |
</div>
|
|
|
|
| 555 |
</div>
|
| 556 |
+
)}
|
| 557 |
|
| 558 |
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
| 559 |
<div className="px-3 py-2 border-b border-border flex items-center gap-2">
|
src/components/panels/task-board-panel.tsx
CHANGED
|
@@ -217,7 +217,7 @@ function MentionTextarea({
|
|
| 217 |
className={className}
|
| 218 |
/>
|
| 219 |
{open && filtered.length > 0 && (
|
| 220 |
-
<div className="absolute z-
|
| 221 |
{filtered.map((option, index) => (
|
| 222 |
<button
|
| 223 |
key={`${option.type}-${option.handle}-${option.recipient}`}
|
|
@@ -770,13 +770,14 @@ function TaskDetailModal({
|
|
| 770 |
onUpdate: () => void
|
| 771 |
onEdit: (task: Task) => void
|
| 772 |
}) {
|
|
|
|
|
|
|
| 773 |
const resolvedProjectName =
|
| 774 |
task.project_name ||
|
| 775 |
projects.find((project) => project.id === task.project_id)?.name
|
| 776 |
const [comments, setComments] = useState<Comment[]>([])
|
| 777 |
const [loadingComments, setLoadingComments] = useState(false)
|
| 778 |
const [commentText, setCommentText] = useState('')
|
| 779 |
-
const [commentAuthor, setCommentAuthor] = useState('system')
|
| 780 |
const [commentError, setCommentError] = useState<string | null>(null)
|
| 781 |
const [broadcastMessage, setBroadcastMessage] = useState('')
|
| 782 |
const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
|
|
@@ -1026,14 +1027,9 @@ function TaskDetailModal({
|
|
| 1026 |
)}
|
| 1027 |
|
| 1028 |
<form onSubmit={handleAddComment} className="mt-4 space-y-3">
|
| 1029 |
-
<div>
|
| 1030 |
-
<
|
| 1031 |
-
<
|
| 1032 |
-
type="text"
|
| 1033 |
-
value={commentAuthor}
|
| 1034 |
-
onChange={(e) => setCommentAuthor(e.target.value)}
|
| 1035 |
-
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 1036 |
-
/>
|
| 1037 |
</div>
|
| 1038 |
<div>
|
| 1039 |
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
|
@@ -1056,18 +1052,25 @@ function TaskDetailModal({
|
|
| 1056 |
</div>
|
| 1057 |
</form>
|
| 1058 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
<div className="mt-6 border-t border-border pt-4">
|
| 1060 |
<h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
|
| 1061 |
{broadcastStatus && (
|
| 1062 |
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
|
| 1063 |
)}
|
| 1064 |
<form onSubmit={handleBroadcast} className="space-y-2">
|
| 1065 |
-
<
|
| 1066 |
value={broadcastMessage}
|
| 1067 |
-
onChange={
|
| 1068 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 1069 |
rows={2}
|
| 1070 |
-
placeholder="Send a message to all task subscribers..."
|
|
|
|
| 1071 |
/>
|
| 1072 |
<div className="flex justify-end">
|
| 1073 |
<button
|
|
|
|
| 217 |
className={className}
|
| 218 |
/>
|
| 219 |
{open && filtered.length > 0 && (
|
| 220 |
+
<div className="absolute z-[60] mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
|
| 221 |
{filtered.map((option, index) => (
|
| 222 |
<button
|
| 223 |
key={`${option.type}-${option.handle}-${option.recipient}`}
|
|
|
|
| 770 |
onUpdate: () => void
|
| 771 |
onEdit: (task: Task) => void
|
| 772 |
}) {
|
| 773 |
+
const { currentUser } = useMissionControl()
|
| 774 |
+
const commentAuthor = currentUser?.username || 'system'
|
| 775 |
const resolvedProjectName =
|
| 776 |
task.project_name ||
|
| 777 |
projects.find((project) => project.id === task.project_id)?.name
|
| 778 |
const [comments, setComments] = useState<Comment[]>([])
|
| 779 |
const [loadingComments, setLoadingComments] = useState(false)
|
| 780 |
const [commentText, setCommentText] = useState('')
|
|
|
|
| 781 |
const [commentError, setCommentError] = useState<string | null>(null)
|
| 782 |
const [broadcastMessage, setBroadcastMessage] = useState('')
|
| 783 |
const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
|
|
|
|
| 1027 |
)}
|
| 1028 |
|
| 1029 |
<form onSubmit={handleAddComment} className="mt-4 space-y-3">
|
| 1030 |
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
| 1031 |
+
<span>Posting as</span>
|
| 1032 |
+
<span className="font-medium text-foreground">{commentAuthor}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
</div>
|
| 1034 |
<div>
|
| 1035 |
<label className="block text-xs text-muted-foreground mb-1">New Comment</label>
|
|
|
|
| 1052 |
</div>
|
| 1053 |
</form>
|
| 1054 |
|
| 1055 |
+
<div className="mt-5 bg-blue-500/5 border border-blue-500/15 rounded-lg p-3 text-xs text-muted-foreground space-y-1">
|
| 1056 |
+
<div className="font-medium text-blue-300">How notifications work</div>
|
| 1057 |
+
<div><strong className="text-foreground">Comments</strong> are persisted on the task and notify all subscribers. Subscribers are auto-added when they: create the task, are assigned to it, comment on it, or are @mentioned.</div>
|
| 1058 |
+
<div><strong className="text-foreground">Broadcasts</strong> send a one-time notification to all current subscribers without creating a comment record.</div>
|
| 1059 |
+
</div>
|
| 1060 |
+
|
| 1061 |
<div className="mt-6 border-t border-border pt-4">
|
| 1062 |
<h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
|
| 1063 |
{broadcastStatus && (
|
| 1064 |
<div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
|
| 1065 |
)}
|
| 1066 |
<form onSubmit={handleBroadcast} className="space-y-2">
|
| 1067 |
+
<MentionTextarea
|
| 1068 |
value={broadcastMessage}
|
| 1069 |
+
onChange={setBroadcastMessage}
|
| 1070 |
className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
|
| 1071 |
rows={2}
|
| 1072 |
+
placeholder="Send a message to all task subscribers... (use @ to mention)"
|
| 1073 |
+
mentionTargets={mentionTargets}
|
| 1074 |
/>
|
| 1075 |
<div className="flex justify-end">
|
| 1076 |
<button
|
src/lib/__tests__/task-status.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest'
|
| 2 |
+
import { normalizeTaskCreateStatus, normalizeTaskUpdateStatus } from '../task-status'
|
| 3 |
+
|
| 4 |
+
describe('task status normalization', () => {
|
| 5 |
+
it('sets assigned status on create when assignee is present', () => {
|
| 6 |
+
expect(normalizeTaskCreateStatus(undefined, 'main')).toBe('assigned')
|
| 7 |
+
expect(normalizeTaskCreateStatus('inbox', 'main')).toBe('assigned')
|
| 8 |
+
})
|
| 9 |
+
|
| 10 |
+
it('keeps explicit non-inbox status on create', () => {
|
| 11 |
+
expect(normalizeTaskCreateStatus('in_progress', 'main')).toBe('in_progress')
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
it('auto-promotes inbox to assigned when assignment is added via update', () => {
|
| 15 |
+
expect(
|
| 16 |
+
normalizeTaskUpdateStatus({
|
| 17 |
+
currentStatus: 'inbox',
|
| 18 |
+
requestedStatus: undefined,
|
| 19 |
+
assignedTo: 'main',
|
| 20 |
+
assignedToProvided: true,
|
| 21 |
+
})
|
| 22 |
+
).toBe('assigned')
|
| 23 |
+
})
|
| 24 |
+
|
| 25 |
+
it('auto-demotes assigned to inbox when assignment is removed via update', () => {
|
| 26 |
+
expect(
|
| 27 |
+
normalizeTaskUpdateStatus({
|
| 28 |
+
currentStatus: 'assigned',
|
| 29 |
+
requestedStatus: undefined,
|
| 30 |
+
assignedTo: '',
|
| 31 |
+
assignedToProvided: true,
|
| 32 |
+
})
|
| 33 |
+
).toBe('inbox')
|
| 34 |
+
})
|
| 35 |
+
|
| 36 |
+
it('does not override explicit status changes on update', () => {
|
| 37 |
+
expect(
|
| 38 |
+
normalizeTaskUpdateStatus({
|
| 39 |
+
currentStatus: 'inbox',
|
| 40 |
+
requestedStatus: 'in_progress',
|
| 41 |
+
assignedTo: 'main',
|
| 42 |
+
assignedToProvided: true,
|
| 43 |
+
})
|
| 44 |
+
).toBe('in_progress')
|
| 45 |
+
})
|
| 46 |
+
})
|
| 47 |
+
|
src/lib/config.ts
CHANGED
|
@@ -21,6 +21,23 @@ const openclawStateDir =
|
|
| 21 |
const openclawConfigPath =
|
| 22 |
explicitOpenClawConfigPath ||
|
| 23 |
path.join(openclawStateDir, 'openclaw.json')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
export const config = {
|
| 26 |
claudeHome:
|
|
@@ -45,10 +62,11 @@ export const config = {
|
|
| 45 |
process.env.OPENCLAW_LOG_DIR ||
|
| 46 |
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
| 47 |
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
| 48 |
-
memoryDir:
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
| 52 |
soulTemplatesDir:
|
| 53 |
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
| 54 |
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
|
@@ -61,6 +79,7 @@ export const config = {
|
|
| 61 |
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
| 62 |
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
| 63 |
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
|
|
|
| 64 |
},
|
| 65 |
}
|
| 66 |
|
|
|
|
| 21 |
const openclawConfigPath =
|
| 22 |
explicitOpenClawConfigPath ||
|
| 23 |
path.join(openclawStateDir, 'openclaw.json')
|
| 24 |
+
const openclawWorkspaceDir =
|
| 25 |
+
process.env.OPENCLAW_WORKSPACE_DIR ||
|
| 26 |
+
process.env.MISSION_CONTROL_WORKSPACE_DIR ||
|
| 27 |
+
(openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
|
| 28 |
+
const defaultMemoryDir = (() => {
|
| 29 |
+
if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
|
| 30 |
+
// Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
|
| 31 |
+
// when available; fallback to legacy sqlite memory path.
|
| 32 |
+
if (
|
| 33 |
+
openclawWorkspaceDir &&
|
| 34 |
+
(fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
|
| 35 |
+
fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
|
| 36 |
+
) {
|
| 37 |
+
return openclawWorkspaceDir
|
| 38 |
+
}
|
| 39 |
+
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
|
| 40 |
+
})()
|
| 41 |
|
| 42 |
export const config = {
|
| 43 |
claudeHome:
|
|
|
|
| 62 |
process.env.OPENCLAW_LOG_DIR ||
|
| 63 |
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
| 64 |
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
| 65 |
+
memoryDir: defaultMemoryDir,
|
| 66 |
+
memoryAllowedPrefixes:
|
| 67 |
+
defaultMemoryDir === openclawWorkspaceDir
|
| 68 |
+
? ['memory/', 'knowledge-base/']
|
| 69 |
+
: [],
|
| 70 |
soulTemplatesDir:
|
| 71 |
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
| 72 |
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
|
|
|
| 79 |
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
|
| 80 |
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
|
| 81 |
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
|
| 82 |
+
gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
|
| 83 |
},
|
| 84 |
}
|
| 85 |
|
src/lib/scheduler.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
|
|
| 6 |
import { logger } from './logger'
|
| 7 |
import { processWebhookRetries } from './webhooks'
|
| 8 |
import { syncClaudeSessions } from './claude-sessions'
|
|
|
|
| 9 |
|
| 10 |
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
| 11 |
|
|
@@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
|
|
| 130 |
}
|
| 131 |
}
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
if (totalDeleted > 0) {
|
| 134 |
logAuditEvent({
|
| 135 |
action: 'auto_cleanup',
|
|
|
|
| 6 |
import { logger } from './logger'
|
| 7 |
import { processWebhookRetries } from './webhooks'
|
| 8 |
import { syncClaudeSessions } from './claude-sessions'
|
| 9 |
+
import { pruneGatewaySessionsOlderThan } from './sessions'
|
| 10 |
|
| 11 |
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
|
| 12 |
|
|
|
|
| 131 |
}
|
| 132 |
}
|
| 133 |
|
| 134 |
+
if (ret.gatewaySessions > 0) {
|
| 135 |
+
const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
|
| 136 |
+
totalDeleted += sessionCleanup.deleted
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
if (totalDeleted > 0) {
|
| 140 |
logAuditEvent({
|
| 141 |
action: 'auto_cleanup',
|
src/lib/sessions.ts
CHANGED
|
@@ -19,25 +19,13 @@ export interface GatewaySession {
|
|
| 19 |
active: boolean
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
* Read all sessions from OpenClaw agent session stores on disk.
|
| 24 |
-
*
|
| 25 |
-
* OpenClaw stores sessions per-agent at:
|
| 26 |
-
* {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
|
| 27 |
-
*
|
| 28 |
-
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
| 29 |
-
* with session metadata as values.
|
| 30 |
-
*/
|
| 31 |
-
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
| 32 |
const openclawStateDir = config.openclawStateDir
|
| 33 |
if (!openclawStateDir) return []
|
| 34 |
|
| 35 |
const agentsDir = path.join(openclawStateDir, 'agents')
|
| 36 |
if (!fs.existsSync(agentsDir)) return []
|
| 37 |
|
| 38 |
-
const sessions: GatewaySession[] = []
|
| 39 |
-
const now = Date.now()
|
| 40 |
-
|
| 41 |
let agentDirs: string[]
|
| 42 |
try {
|
| 43 |
agentDirs = fs.readdirSync(agentsDir)
|
|
@@ -45,10 +33,33 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|
| 45 |
return []
|
| 46 |
}
|
| 47 |
|
|
|
|
| 48 |
for (const agentName of agentDirs) {
|
| 49 |
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
| 50 |
try {
|
| 51 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 53 |
const data = JSON.parse(raw)
|
| 54 |
|
|
@@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
|
|
| 80 |
return sessions
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
/**
|
| 84 |
* Derive agent active/idle/offline status from their sessions.
|
| 85 |
* Returns a map of agentName -> { status, lastActivity, channel }
|
|
|
|
| 19 |
active: boolean
|
| 20 |
}
|
| 21 |
|
| 22 |
+
function getGatewaySessionStoreFiles(): string[] {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const openclawStateDir = config.openclawStateDir
|
| 24 |
if (!openclawStateDir) return []
|
| 25 |
|
| 26 |
const agentsDir = path.join(openclawStateDir, 'agents')
|
| 27 |
if (!fs.existsSync(agentsDir)) return []
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
let agentDirs: string[]
|
| 30 |
try {
|
| 31 |
agentDirs = fs.readdirSync(agentsDir)
|
|
|
|
| 33 |
return []
|
| 34 |
}
|
| 35 |
|
| 36 |
+
const files: string[] = []
|
| 37 |
for (const agentName of agentDirs) {
|
| 38 |
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
|
| 39 |
try {
|
| 40 |
+
if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
|
| 41 |
+
} catch {
|
| 42 |
+
// Skip missing or unreadable session stores.
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
return files
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/**
|
| 49 |
+
* Read all sessions from OpenClaw agent session stores on disk.
|
| 50 |
+
*
|
| 51 |
+
* OpenClaw stores sessions per-agent at:
|
| 52 |
+
* {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
|
| 53 |
+
*
|
| 54 |
+
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
| 55 |
+
* with session metadata as values.
|
| 56 |
+
*/
|
| 57 |
+
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
| 58 |
+
const sessions: GatewaySession[] = []
|
| 59 |
+
const now = Date.now()
|
| 60 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 61 |
+
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
|
| 62 |
+
try {
|
| 63 |
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 64 |
const data = JSON.parse(raw)
|
| 65 |
|
|
|
|
| 91 |
return sessions
|
| 92 |
}
|
| 93 |
|
| 94 |
+
export function countStaleGatewaySessions(retentionDays: number): number {
|
| 95 |
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
|
| 96 |
+
const cutoff = Date.now() - retentionDays * 86400000
|
| 97 |
+
let stale = 0
|
| 98 |
+
|
| 99 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 100 |
+
try {
|
| 101 |
+
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 102 |
+
const data = JSON.parse(raw) as Record<string, any>
|
| 103 |
+
for (const entry of Object.values(data)) {
|
| 104 |
+
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
| 105 |
+
if (updatedAt > 0 && updatedAt < cutoff) stale += 1
|
| 106 |
+
}
|
| 107 |
+
} catch {
|
| 108 |
+
// Ignore malformed session stores.
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return stale
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
|
| 116 |
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
|
| 117 |
+
const cutoff = Date.now() - retentionDays * 86400000
|
| 118 |
+
let deleted = 0
|
| 119 |
+
let filesTouched = 0
|
| 120 |
+
|
| 121 |
+
for (const sessionsFile of getGatewaySessionStoreFiles()) {
|
| 122 |
+
try {
|
| 123 |
+
const raw = fs.readFileSync(sessionsFile, 'utf-8')
|
| 124 |
+
const data = JSON.parse(raw) as Record<string, any>
|
| 125 |
+
const nextEntries: Record<string, any> = {}
|
| 126 |
+
let fileDeleted = 0
|
| 127 |
+
|
| 128 |
+
for (const [key, entry] of Object.entries(data)) {
|
| 129 |
+
const updatedAt = Number((entry as any)?.updatedAt || 0)
|
| 130 |
+
if (updatedAt > 0 && updatedAt < cutoff) {
|
| 131 |
+
fileDeleted += 1
|
| 132 |
+
continue
|
| 133 |
+
}
|
| 134 |
+
nextEntries[key] = entry
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
if (fileDeleted > 0) {
|
| 138 |
+
const tempPath = `${sessionsFile}.tmp`
|
| 139 |
+
fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
|
| 140 |
+
fs.renameSync(tempPath, sessionsFile)
|
| 141 |
+
deleted += fileDeleted
|
| 142 |
+
filesTouched += 1
|
| 143 |
+
}
|
| 144 |
+
} catch {
|
| 145 |
+
// Ignore malformed/unwritable session stores.
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return { deleted, filesTouched }
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
/**
|
| 153 |
* Derive agent active/idle/offline status from their sessions.
|
| 154 |
* Returns a map of agentName -> { status, lastActivity, channel }
|
src/lib/task-status.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Task } from './db'
|
| 2 |
+
|
| 3 |
+
export type TaskStatus = Task['status']
|
| 4 |
+
|
| 5 |
+
function hasAssignee(assignedTo: string | null | undefined): boolean {
|
| 6 |
+
return Boolean(assignedTo && assignedTo.trim())
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Keep task state coherent when a task is created with an assignee.
|
| 11 |
+
* If caller asks for `inbox` but also sets `assigned_to`, normalize to `assigned`.
|
| 12 |
+
*/
|
| 13 |
+
export function normalizeTaskCreateStatus(
|
| 14 |
+
requestedStatus: TaskStatus | undefined,
|
| 15 |
+
assignedTo: string | undefined
|
| 16 |
+
): TaskStatus {
|
| 17 |
+
const status = requestedStatus ?? 'inbox'
|
| 18 |
+
if (status === 'inbox' && hasAssignee(assignedTo)) return 'assigned'
|
| 19 |
+
return status
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Auto-adjust status for assignment-only updates when caller does not
|
| 24 |
+
* explicitly request a status transition.
|
| 25 |
+
*/
|
| 26 |
+
export function normalizeTaskUpdateStatus(args: {
|
| 27 |
+
currentStatus: TaskStatus
|
| 28 |
+
requestedStatus: TaskStatus | undefined
|
| 29 |
+
assignedTo: string | null | undefined
|
| 30 |
+
assignedToProvided: boolean
|
| 31 |
+
}): TaskStatus | undefined {
|
| 32 |
+
const { currentStatus, requestedStatus, assignedTo, assignedToProvided } = args
|
| 33 |
+
if (requestedStatus !== undefined) return requestedStatus
|
| 34 |
+
if (!assignedToProvided) return undefined
|
| 35 |
+
|
| 36 |
+
if (hasAssignee(assignedTo) && currentStatus === 'inbox') return 'assigned'
|
| 37 |
+
if (!hasAssignee(assignedTo) && currentStatus === 'assigned') return 'inbox'
|
| 38 |
+
return undefined
|
| 39 |
+
}
|
| 40 |
+
|
src/lib/validation.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial()
|
|
| 45 |
|
| 46 |
export const createAgentSchema = z.object({
|
| 47 |
name: z.string().min(1, 'Name is required').max(100),
|
|
|
|
| 48 |
role: z.string().min(1, 'Role is required').max(100).optional(),
|
| 49 |
session_key: z.string().max(200).optional(),
|
| 50 |
soul_content: z.string().max(50000).optional(),
|
|
@@ -53,6 +54,8 @@ export const createAgentSchema = z.object({
|
|
| 53 |
template: z.string().max(100).optional(),
|
| 54 |
gateway_config: z.record(z.string(), z.unknown()).optional(),
|
| 55 |
write_to_gateway: z.boolean().optional(),
|
|
|
|
|
|
|
| 56 |
})
|
| 57 |
|
| 58 |
export const bulkUpdateTaskStatusSchema = z.object({
|
|
|
|
| 45 |
|
| 46 |
export const createAgentSchema = z.object({
|
| 47 |
name: z.string().min(1, 'Name is required').max(100),
|
| 48 |
+
openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(),
|
| 49 |
role: z.string().min(1, 'Role is required').max(100).optional(),
|
| 50 |
session_key: z.string().max(200).optional(),
|
| 51 |
soul_content: z.string().max(50000).optional(),
|
|
|
|
| 54 |
template: z.string().max(100).optional(),
|
| 55 |
gateway_config: z.record(z.string(), z.unknown()).optional(),
|
| 56 |
write_to_gateway: z.boolean().optional(),
|
| 57 |
+
provision_openclaw_workspace: z.boolean().optional(),
|
| 58 |
+
openclaw_workspace_path: z.string().min(1).max(500).optional(),
|
| 59 |
})
|
| 60 |
|
| 61 |
export const bulkUpdateTaskStatusSchema = z.object({
|
src/lib/websocket.ts
CHANGED
|
@@ -16,7 +16,7 @@ const log = createClientLogger('WebSocket')
|
|
| 16 |
|
| 17 |
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
| 18 |
const PROTOCOL_VERSION = 3
|
| 19 |
-
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui'
|
| 20 |
|
| 21 |
// Heartbeat configuration
|
| 22 |
const PING_INTERVAL_MS = 30_000
|
|
@@ -59,6 +59,8 @@ export function useWebSocket() {
|
|
| 59 |
const pingCounterRef = useRef<number>(0)
|
| 60 |
const pingSentTimestamps = useRef<Map<string, number>>(new Map())
|
| 61 |
const missedPongsRef = useRef<number>(0)
|
|
|
|
|
|
|
| 62 |
|
| 63 |
const {
|
| 64 |
connection,
|
|
@@ -116,6 +118,7 @@ export function useWebSocket() {
|
|
| 116 |
|
| 117 |
pingIntervalRef.current = setInterval(() => {
|
| 118 |
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
|
|
|
|
| 119 |
|
| 120 |
// Check missed pongs
|
| 121 |
if (missedPongsRef.current >= MAX_MISSED_PONGS) {
|
|
@@ -358,6 +361,13 @@ export function useWebSocket() {
|
|
| 358 |
|
| 359 |
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
| 360 |
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
handlePong(frame.id)
|
| 362 |
return
|
| 363 |
}
|
|
|
|
| 16 |
|
| 17 |
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
| 18 |
const PROTOCOL_VERSION = 3
|
| 19 |
+
const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'openclaw-control-ui'
|
| 20 |
|
| 21 |
// Heartbeat configuration
|
| 22 |
const PING_INTERVAL_MS = 30_000
|
|
|
|
| 59 |
const pingCounterRef = useRef<number>(0)
|
| 60 |
const pingSentTimestamps = useRef<Map<string, number>>(new Map())
|
| 61 |
const missedPongsRef = useRef<number>(0)
|
| 62 |
+
// Compat flag for gateway versions that may not implement ping RPC.
|
| 63 |
+
const gatewaySupportsPingRef = useRef<boolean>(true)
|
| 64 |
|
| 65 |
const {
|
| 66 |
connection,
|
|
|
|
| 118 |
|
| 119 |
pingIntervalRef.current = setInterval(() => {
|
| 120 |
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
|
| 121 |
+
if (!gatewaySupportsPingRef.current) return
|
| 122 |
|
| 123 |
// Check missed pongs
|
| 124 |
if (missedPongsRef.current >= MAX_MISSED_PONGS) {
|
|
|
|
| 361 |
|
| 362 |
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
|
| 363 |
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
|
| 364 |
+
const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
|
| 365 |
+
if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
|
| 366 |
+
gatewaySupportsPingRef.current = false
|
| 367 |
+
missedPongsRef.current = 0
|
| 368 |
+
pingSentTimestamps.current.clear()
|
| 369 |
+
log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
|
| 370 |
+
}
|
| 371 |
handlePong(frame.id)
|
| 372 |
return
|
| 373 |
}
|
src/live-feed.tsx
DELETED
|
@@ -1,161 +0,0 @@
|
|
| 1 |
-
'use client'
|
| 2 |
-
|
| 3 |
-
import { useMissionControl } from '@/store'
|
| 4 |
-
import { useEffect, useState } from 'react'
|
| 5 |
-
|
| 6 |
-
export function LiveFeed() {
|
| 7 |
-
const { logs, sessions, activities, connection, toggleLiveFeed } = useMissionControl()
|
| 8 |
-
const [expanded, setExpanded] = useState(true)
|
| 9 |
-
|
| 10 |
-
// Combine logs and activities into a unified feed
|
| 11 |
-
const feedItems = [
|
| 12 |
-
...logs.slice(0, 30).map(log => ({
|
| 13 |
-
id: log.id,
|
| 14 |
-
type: 'log' as const,
|
| 15 |
-
level: log.level,
|
| 16 |
-
message: log.message,
|
| 17 |
-
source: log.source,
|
| 18 |
-
timestamp: log.timestamp,
|
| 19 |
-
})),
|
| 20 |
-
...activities.slice(0, 20).map(act => ({
|
| 21 |
-
id: `act-${act.id}`,
|
| 22 |
-
type: 'activity' as const,
|
| 23 |
-
level: 'info' as const,
|
| 24 |
-
message: act.description,
|
| 25 |
-
source: act.actor,
|
| 26 |
-
timestamp: act.created_at * 1000,
|
| 27 |
-
})),
|
| 28 |
-
].sort((a, b) => b.timestamp - a.timestamp).slice(0, 40)
|
| 29 |
-
|
| 30 |
-
if (!expanded) {
|
| 31 |
-
return (
|
| 32 |
-
<div className="w-10 bg-card border-l border-border flex flex-col items-center py-3 shrink-0">
|
| 33 |
-
<button
|
| 34 |
-
onClick={() => setExpanded(true)}
|
| 35 |
-
className="w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
| 36 |
-
title="Show live feed"
|
| 37 |
-
>
|
| 38 |
-
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 39 |
-
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
|
| 40 |
-
</svg>
|
| 41 |
-
</button>
|
| 42 |
-
{/* Mini indicators */}
|
| 43 |
-
<div className="mt-4 flex flex-col gap-2 items-center">
|
| 44 |
-
{feedItems.slice(0, 5).map((item) => (
|
| 45 |
-
<div
|
| 46 |
-
key={item.id}
|
| 47 |
-
className={`w-1.5 h-1.5 rounded-full ${
|
| 48 |
-
item.level === 'error' ? 'bg-red-500' :
|
| 49 |
-
item.level === 'warn' ? 'bg-amber-500' :
|
| 50 |
-
'bg-blue-500/40'
|
| 51 |
-
}`}
|
| 52 |
-
/>
|
| 53 |
-
))}
|
| 54 |
-
</div>
|
| 55 |
-
</div>
|
| 56 |
-
)
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
return (
|
| 60 |
-
<div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
|
| 61 |
-
{/* Header */}
|
| 62 |
-
<div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
|
| 63 |
-
<div className="flex items-center gap-2">
|
| 64 |
-
<div className="w-1.5 h-1.5 rounded-full bg-green-500 pulse-dot" />
|
| 65 |
-
<span className="text-xs font-semibold text-foreground">Live Feed</span>
|
| 66 |
-
<span className="text-2xs text-muted-foreground font-mono-tight">{feedItems.length}</span>
|
| 67 |
-
</div>
|
| 68 |
-
<div className="flex items-center gap-0.5">
|
| 69 |
-
<button
|
| 70 |
-
onClick={() => setExpanded(false)}
|
| 71 |
-
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
| 72 |
-
title="Collapse feed"
|
| 73 |
-
>
|
| 74 |
-
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 75 |
-
<path d="M6 3l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
| 76 |
-
</svg>
|
| 77 |
-
</button>
|
| 78 |
-
<button
|
| 79 |
-
onClick={toggleLiveFeed}
|
| 80 |
-
className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
|
| 81 |
-
title="Close feed"
|
| 82 |
-
>
|
| 83 |
-
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 84 |
-
<path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" strokeLinejoin="round" />
|
| 85 |
-
</svg>
|
| 86 |
-
</button>
|
| 87 |
-
</div>
|
| 88 |
-
</div>
|
| 89 |
-
|
| 90 |
-
{/* Feed items */}
|
| 91 |
-
<div className="flex-1 overflow-y-auto">
|
| 92 |
-
{feedItems.length === 0 ? (
|
| 93 |
-
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
|
| 94 |
-
No activity yet
|
| 95 |
-
</div>
|
| 96 |
-
) : (
|
| 97 |
-
<div className="divide-y divide-border/50">
|
| 98 |
-
{feedItems.map((item) => (
|
| 99 |
-
<FeedItem key={item.id} item={item} />
|
| 100 |
-
))}
|
| 101 |
-
</div>
|
| 102 |
-
)}
|
| 103 |
-
</div>
|
| 104 |
-
|
| 105 |
-
{/* Active sessions mini-list */}
|
| 106 |
-
<div className="border-t border-border px-3 py-2 shrink-0">
|
| 107 |
-
<div className="text-2xs font-medium text-muted-foreground mb-1.5">Active Sessions</div>
|
| 108 |
-
<div className="space-y-1">
|
| 109 |
-
{sessions.filter(s => s.active).slice(0, 4).map(session => (
|
| 110 |
-
<div key={session.id} className="flex items-center gap-1.5 text-2xs">
|
| 111 |
-
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
| 112 |
-
<span className="text-foreground truncate flex-1 font-mono-tight">{session.key || session.id}</span>
|
| 113 |
-
<span className="text-muted-foreground">{session.model?.split('/').pop()?.slice(0, 8)}</span>
|
| 114 |
-
</div>
|
| 115 |
-
))}
|
| 116 |
-
{sessions.filter(s => s.active).length === 0 && (
|
| 117 |
-
<div className="text-2xs text-muted-foreground">No active sessions</div>
|
| 118 |
-
)}
|
| 119 |
-
</div>
|
| 120 |
-
</div>
|
| 121 |
-
</div>
|
| 122 |
-
)
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
function FeedItem({ item }: { item: { id: string; type: string; level: string; message: string; source: string; timestamp: number } }) {
|
| 126 |
-
const levelIndicator = item.level === 'error'
|
| 127 |
-
? 'bg-red-500'
|
| 128 |
-
: item.level === 'warn'
|
| 129 |
-
? 'bg-amber-500'
|
| 130 |
-
: item.level === 'debug'
|
| 131 |
-
? 'bg-gray-500'
|
| 132 |
-
: 'bg-blue-500/50'
|
| 133 |
-
|
| 134 |
-
const timeStr = formatRelativeTime(item.timestamp)
|
| 135 |
-
|
| 136 |
-
return (
|
| 137 |
-
<div className="px-3 py-2 hover:bg-secondary/50 transition-smooth group">
|
| 138 |
-
<div className="flex items-start gap-2">
|
| 139 |
-
<div className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${levelIndicator}`} />
|
| 140 |
-
<div className="flex-1 min-w-0">
|
| 141 |
-
<p className="text-xs text-foreground/90 leading-relaxed break-words">
|
| 142 |
-
{item.message.length > 120 ? item.message.slice(0, 120) + '...' : item.message}
|
| 143 |
-
</p>
|
| 144 |
-
<div className="flex items-center gap-1.5 mt-0.5">
|
| 145 |
-
<span className="text-2xs text-muted-foreground font-mono-tight">{item.source}</span>
|
| 146 |
-
<span className="text-2xs text-muted-foreground/50">·</span>
|
| 147 |
-
<span className="text-2xs text-muted-foreground">{timeStr}</span>
|
| 148 |
-
</div>
|
| 149 |
-
</div>
|
| 150 |
-
</div>
|
| 151 |
-
</div>
|
| 152 |
-
)
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
function formatRelativeTime(ts: number): string {
|
| 156 |
-
const diff = Date.now() - ts
|
| 157 |
-
if (diff < 60_000) return 'now'
|
| 158 |
-
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
|
| 159 |
-
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
|
| 160 |
-
return `${Math.floor(diff / 86_400_000)}d`
|
| 161 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/page.tsx
DELETED
|
@@ -1,166 +0,0 @@
|
|
| 1 |
-
'use client'
|
| 2 |
-
|
| 3 |
-
import { useEffect, useState } from 'react'
|
| 4 |
-
import { NavRail } from '@/components/layout/nav-rail'
|
| 5 |
-
import { HeaderBar } from '@/components/layout/header-bar'
|
| 6 |
-
import { LiveFeed } from '@/components/layout/live-feed'
|
| 7 |
-
import { Dashboard } from '@/components/dashboard/dashboard'
|
| 8 |
-
import { AgentSpawnPanel } from '@/components/panels/agent-spawn-panel'
|
| 9 |
-
import { LogViewerPanel } from '@/components/panels/log-viewer-panel'
|
| 10 |
-
import { CronManagementPanel } from '@/components/panels/cron-management-panel'
|
| 11 |
-
import { MemoryBrowserPanel } from '@/components/panels/memory-browser-panel'
|
| 12 |
-
import { TokenDashboardPanel } from '@/components/panels/token-dashboard-panel'
|
| 13 |
-
import { SessionDetailsPanel } from '@/components/panels/session-details-panel'
|
| 14 |
-
import { TaskBoardPanel } from '@/components/panels/task-board-panel'
|
| 15 |
-
import { ActivityFeedPanel } from '@/components/panels/activity-feed-panel'
|
| 16 |
-
import { AgentSquadPanelPhase3 } from '@/components/panels/agent-squad-panel-phase3'
|
| 17 |
-
import { StandupPanel } from '@/components/panels/standup-panel'
|
| 18 |
-
import { OrchestrationBar } from '@/components/panels/orchestration-bar'
|
| 19 |
-
import { NotificationsPanel } from '@/components/panels/notifications-panel'
|
| 20 |
-
import { UserManagementPanel } from '@/components/panels/user-management-panel'
|
| 21 |
-
import { AuditTrailPanel } from '@/components/panels/audit-trail-panel'
|
| 22 |
-
import { AgentHistoryPanel } from '@/components/panels/agent-history-panel'
|
| 23 |
-
import { WebhookPanel } from '@/components/panels/webhook-panel'
|
| 24 |
-
import { SettingsPanel } from '@/components/panels/settings-panel'
|
| 25 |
-
import { GatewayConfigPanel } from '@/components/panels/gateway-config-panel'
|
| 26 |
-
import { IntegrationsPanel } from '@/components/panels/integrations-panel'
|
| 27 |
-
import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
|
| 28 |
-
import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
|
| 29 |
-
import { ChatPanel } from '@/components/chat/chat-panel'
|
| 30 |
-
import { useWebSocket } from '@/lib/websocket'
|
| 31 |
-
import { useServerEvents } from '@/lib/use-server-events'
|
| 32 |
-
import { useMissionControl } from '@/store'
|
| 33 |
-
|
| 34 |
-
export default function Home() {
|
| 35 |
-
const { connect } = useWebSocket()
|
| 36 |
-
const { activeTab, setCurrentUser, liveFeedOpen, toggleLiveFeed } = useMissionControl()
|
| 37 |
-
|
| 38 |
-
// Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
|
| 39 |
-
useServerEvents()
|
| 40 |
-
const [isClient, setIsClient] = useState(false)
|
| 41 |
-
|
| 42 |
-
useEffect(() => {
|
| 43 |
-
setIsClient(true)
|
| 44 |
-
|
| 45 |
-
// Fetch current user
|
| 46 |
-
fetch('/api/auth/me')
|
| 47 |
-
.then(res => res.ok ? res.json() : null)
|
| 48 |
-
.then(data => { if (data?.user) setCurrentUser(data.user) })
|
| 49 |
-
.catch(() => {})
|
| 50 |
-
|
| 51 |
-
// Auto-connect to gateway on mount
|
| 52 |
-
const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
|
| 53 |
-
const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
|
| 54 |
-
const gatewayHost = window.location.hostname
|
| 55 |
-
const wsUrl = `ws://${gatewayHost}:${gatewayPort}`
|
| 56 |
-
connect(wsUrl, wsToken)
|
| 57 |
-
}, [connect, setCurrentUser])
|
| 58 |
-
|
| 59 |
-
if (!isClient) {
|
| 60 |
-
return (
|
| 61 |
-
<div className="flex items-center justify-center min-h-screen">
|
| 62 |
-
<div className="flex flex-col items-center gap-3">
|
| 63 |
-
<div className="w-10 h-10 rounded-xl bg-primary flex items-center justify-center">
|
| 64 |
-
<span className="text-primary-foreground font-bold text-sm">MC</span>
|
| 65 |
-
</div>
|
| 66 |
-
<div className="flex items-center gap-2">
|
| 67 |
-
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
| 68 |
-
<span className="text-sm text-muted-foreground">Loading Mission Control...</span>
|
| 69 |
-
</div>
|
| 70 |
-
</div>
|
| 71 |
-
</div>
|
| 72 |
-
)
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
return (
|
| 76 |
-
<div className="flex h-screen bg-background overflow-hidden">
|
| 77 |
-
{/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
|
| 78 |
-
<NavRail />
|
| 79 |
-
|
| 80 |
-
{/* Center: Header + Content */}
|
| 81 |
-
<div className="flex-1 flex flex-col min-w-0">
|
| 82 |
-
<HeaderBar />
|
| 83 |
-
<main className="flex-1 overflow-auto pb-16 md:pb-0">
|
| 84 |
-
<ContentRouter tab={activeTab} />
|
| 85 |
-
</main>
|
| 86 |
-
</div>
|
| 87 |
-
|
| 88 |
-
{/* Right: Live feed (hidden on mobile) */}
|
| 89 |
-
{liveFeedOpen && (
|
| 90 |
-
<div className="hidden lg:flex h-full">
|
| 91 |
-
<LiveFeed />
|
| 92 |
-
</div>
|
| 93 |
-
)}
|
| 94 |
-
|
| 95 |
-
{/* Floating button to reopen LiveFeed when closed */}
|
| 96 |
-
{!liveFeedOpen && (
|
| 97 |
-
<button
|
| 98 |
-
onClick={toggleLiveFeed}
|
| 99 |
-
className="hidden lg:flex fixed right-0 top-1/2 -translate-y-1/2 z-30 w-6 h-12 items-center justify-center bg-card border border-r-0 border-border rounded-l-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-all duration-200"
|
| 100 |
-
title="Show live feed"
|
| 101 |
-
>
|
| 102 |
-
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 103 |
-
<path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
|
| 104 |
-
</svg>
|
| 105 |
-
</button>
|
| 106 |
-
)}
|
| 107 |
-
|
| 108 |
-
{/* Chat panel overlay */}
|
| 109 |
-
<ChatPanel />
|
| 110 |
-
</div>
|
| 111 |
-
)
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
function ContentRouter({ tab }: { tab: string }) {
|
| 115 |
-
switch (tab) {
|
| 116 |
-
case 'overview':
|
| 117 |
-
return <Dashboard />
|
| 118 |
-
case 'tasks':
|
| 119 |
-
return <TaskBoardPanel />
|
| 120 |
-
case 'agents':
|
| 121 |
-
return (
|
| 122 |
-
<>
|
| 123 |
-
<OrchestrationBar />
|
| 124 |
-
<AgentSquadPanelPhase3 />
|
| 125 |
-
</>
|
| 126 |
-
)
|
| 127 |
-
case 'activity':
|
| 128 |
-
return <ActivityFeedPanel />
|
| 129 |
-
case 'notifications':
|
| 130 |
-
return <NotificationsPanel />
|
| 131 |
-
case 'standup':
|
| 132 |
-
return <StandupPanel />
|
| 133 |
-
case 'spawn':
|
| 134 |
-
return <AgentSpawnPanel />
|
| 135 |
-
case 'sessions':
|
| 136 |
-
return <SessionDetailsPanel />
|
| 137 |
-
case 'logs':
|
| 138 |
-
return <LogViewerPanel />
|
| 139 |
-
case 'cron':
|
| 140 |
-
return <CronManagementPanel />
|
| 141 |
-
case 'memory':
|
| 142 |
-
return <MemoryBrowserPanel />
|
| 143 |
-
case 'tokens':
|
| 144 |
-
return <TokenDashboardPanel />
|
| 145 |
-
case 'users':
|
| 146 |
-
return <UserManagementPanel />
|
| 147 |
-
case 'history':
|
| 148 |
-
return <AgentHistoryPanel />
|
| 149 |
-
case 'audit':
|
| 150 |
-
return <AuditTrailPanel />
|
| 151 |
-
case 'webhooks':
|
| 152 |
-
return <WebhookPanel />
|
| 153 |
-
case 'alerts':
|
| 154 |
-
return <AlertRulesPanel />
|
| 155 |
-
case 'gateways':
|
| 156 |
-
return <MultiGatewayPanel />
|
| 157 |
-
case 'gateway-config':
|
| 158 |
-
return <GatewayConfigPanel />
|
| 159 |
-
case 'integrations':
|
| 160 |
-
return <IntegrationsPanel />
|
| 161 |
-
case 'settings':
|
| 162 |
-
return <SettingsPanel />
|
| 163 |
-
default:
|
| 164 |
-
return <Dashboard />
|
| 165 |
-
}
|
| 166 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/agent-diagnostics.spec.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect } from '@playwright/test'
|
| 2 |
+
import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
|
| 3 |
+
|
| 4 |
+
test.describe('Agent Diagnostics API', () => {
|
| 5 |
+
const cleanup: number[] = []
|
| 6 |
+
|
| 7 |
+
test.afterEach(async ({ request }) => {
|
| 8 |
+
for (const id of cleanup) {
|
| 9 |
+
await deleteTestAgent(request, id).catch(() => {})
|
| 10 |
+
}
|
| 11 |
+
cleanup.length = 0
|
| 12 |
+
})
|
| 13 |
+
|
| 14 |
+
test('self access is allowed with x-agent-name', async ({ request }) => {
|
| 15 |
+
const { id, name } = await createTestAgent(request)
|
| 16 |
+
cleanup.push(id)
|
| 17 |
+
|
| 18 |
+
const res = await request.get(`/api/agents/${name}/diagnostics?section=summary`, {
|
| 19 |
+
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
|
| 20 |
+
})
|
| 21 |
+
|
| 22 |
+
expect(res.status()).toBe(200)
|
| 23 |
+
const body = await res.json()
|
| 24 |
+
expect(body.agent.name).toBe(name)
|
| 25 |
+
expect(body.summary).toBeDefined()
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
test('cross-agent access is denied by default', async ({ request }) => {
|
| 29 |
+
const a = await createTestAgent(request)
|
| 30 |
+
const b = await createTestAgent(request)
|
| 31 |
+
cleanup.push(a.id, b.id)
|
| 32 |
+
|
| 33 |
+
const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary`, {
|
| 34 |
+
headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
expect(res.status()).toBe(403)
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
test('cross-agent access is allowed with privileged=1 for admin', async ({ request }) => {
|
| 41 |
+
const a = await createTestAgent(request)
|
| 42 |
+
const b = await createTestAgent(request)
|
| 43 |
+
cleanup.push(a.id, b.id)
|
| 44 |
+
|
| 45 |
+
const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary&privileged=1`, {
|
| 46 |
+
headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
expect(res.status()).toBe(200)
|
| 50 |
+
const body = await res.json()
|
| 51 |
+
expect(body.agent.name).toBe(a.name)
|
| 52 |
+
expect(body.summary).toBeDefined()
|
| 53 |
+
})
|
| 54 |
+
|
| 55 |
+
test('invalid section query is rejected', async ({ request }) => {
|
| 56 |
+
const { id, name } = await createTestAgent(request)
|
| 57 |
+
cleanup.push(id)
|
| 58 |
+
|
| 59 |
+
const res = await request.get(`/api/agents/${name}/diagnostics?section=summary,invalid`, {
|
| 60 |
+
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
|
| 61 |
+
})
|
| 62 |
+
|
| 63 |
+
expect(res.status()).toBe(400)
|
| 64 |
+
})
|
| 65 |
+
|
| 66 |
+
test('invalid hours query is rejected', async ({ request }) => {
|
| 67 |
+
const { id, name } = await createTestAgent(request)
|
| 68 |
+
cleanup.push(id)
|
| 69 |
+
|
| 70 |
+
const res = await request.get(`/api/agents/${name}/diagnostics?hours=0`, {
|
| 71 |
+
headers: { ...API_KEY_HEADER, 'x-agent-name': name },
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
expect(res.status()).toBe(400)
|
| 75 |
+
})
|
| 76 |
+
})
|