merge: resolve PR conflicts and add OpenClaw offline E2E harness
This commit is contained in:
commit
caf1dbf5ef
10
README.md
10
README.md
|
|
@ -342,7 +342,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
|
||||||
| `AUTH_PASS` | No | Initial admin password |
|
| `AUTH_PASS` | No | Initial admin password |
|
||||||
| `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) |
|
| `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) |
|
||||||
| `API_KEY` | No | API key for headless access |
|
| `API_KEY` | No | API key for headless access |
|
||||||
| `OPENCLAW_HOME` | Yes* | Path to `.openclaw` directory |
|
| `OPENCLAW_CONFIG_PATH` | Yes* | Absolute path to `openclaw.json` (preferred) |
|
||||||
|
| `OPENCLAW_STATE_DIR` | Yes* | OpenClaw state root (default: `~/.openclaw`) |
|
||||||
|
| `OPENCLAW_HOME` | No | Legacy alias for state dir (fallback if `OPENCLAW_STATE_DIR` unset) |
|
||||||
| `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
|
| `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
|
||||||
| `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
|
| `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
|
||||||
| `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
|
| `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
|
||||||
|
|
@ -354,10 +356,10 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
|
||||||
| `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
|
| `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
|
||||||
| `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
|
| `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
|
||||||
|
|
||||||
*Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`.
|
*Memory browser, log viewer, and gateway config require OpenClaw config/state resolution (`OPENCLAW_CONFIG_PATH` and/or `OPENCLAW_STATE_DIR`).
|
||||||
|
|
||||||
> **Memory Browser note:** OpenClaw does not store agent memory markdown files under
|
> **Memory Browser note:** OpenClaw does not store agent memory markdown files under
|
||||||
> `$OPENCLAW_HOME/memory/` — that directory does not exist by default. Agent memory lives
|
> `$OPENCLAW_STATE_DIR/memory/` — that directory does not exist by default. Agent memory lives
|
||||||
> in each agent's workspace (e.g. `~/clawd-agents/{agent}/memory/`). Set
|
> in each agent's workspace (e.g. `~/clawd-agents/{agent}/memory/`). Set
|
||||||
> `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show
|
> `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show
|
||||||
> daily logs, `MEMORY.md`, and other markdown files:
|
> daily logs, `MEMORY.md`, and other markdown files:
|
||||||
|
|
@ -399,7 +401,7 @@ pnpm install --frozen-lockfile
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
OPENCLAW_HOME=/path/to/.openclaw pnpm start
|
OPENCLAW_CONFIG_PATH=/path/to/.openclaw/openclaw.json OPENCLAW_STATE_DIR=/path/to/.openclaw pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access.
|
Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:openclaw:local": "E2E_GATEWAY_EXPECTED=0 playwright test -c playwright.openclaw.local.config.ts",
|
||||||
|
"test:e2e:openclaw:gateway": "E2E_GATEWAY_EXPECTED=1 playwright test -c playwright.openclaw.gateway.config.ts",
|
||||||
|
"test:e2e:openclaw": "pnpm test:e2e:openclaw:local && pnpm test:e2e:openclaw:gateway",
|
||||||
"test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e",
|
"test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e",
|
||||||
"quality:gate": "pnpm test:all"
|
"quality:gate": "pnpm test:all"
|
||||||
},
|
},
|
||||||
|
|
@ -77,4 +80,4 @@
|
||||||
"better-sqlite3"
|
"better-sqlite3"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests',
|
||||||
|
testMatch: /openclaw-harness\.spec\.ts/,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:3005',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=gateway',
|
||||||
|
url: 'http://127.0.0.1:3005',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests',
|
||||||
|
testMatch: /openclaw-harness\.spec\.ts/,
|
||||||
|
timeout: 60_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:3005',
|
||||||
|
trace: 'retain-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=local',
|
||||||
|
url: 'http://127.0.0.1:3005',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "clawdbot 2026.3.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-c" ]]; then
|
||||||
|
cmd="${2:-}"
|
||||||
|
if [[ "$cmd" == sessions_spawn* ]]; then
|
||||||
|
echo "Session created: mock-clawdbot-session"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "ok"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "clawdbot mock: unsupported args: $*" >&2
|
||||||
|
exit 0
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "openclaw 2026.3.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-c" ]]; then
|
||||||
|
cmd="${2:-}"
|
||||||
|
if [[ "$cmd" == sessions_spawn* ]]; then
|
||||||
|
echo "Session created: mock-openclaw-session"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "ok"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "openclaw mock: unsupported args: $*" >&2
|
||||||
|
exit 0
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import http from 'node:http'
|
||||||
|
import { WebSocketServer } from 'ws'
|
||||||
|
|
||||||
|
const host = process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1'
|
||||||
|
const port = Number(process.env.OPENCLAW_GATEWAY_PORT || 18789)
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (!req.url) {
|
||||||
|
res.writeHead(404)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/health') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ ok: true, service: 'openclaw-mock-gateway' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ ok: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.send(JSON.stringify({ type: 'status', connected: true, source: 'mock-gateway' }))
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
const text = raw.toString()
|
||||||
|
if (text.includes('ping')) {
|
||||||
|
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'event', message: 'ack', raw: text }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, req)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
process.stdout.write(`[openclaw-mock-gateway] listening on ${host}:${port}\n`)
|
||||||
|
})
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.close()
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.close(() => process.exit(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import process from 'node:process'
|
||||||
|
|
||||||
|
const modeArg = process.argv.find((arg) => arg.startsWith('--mode='))
|
||||||
|
const mode = modeArg ? modeArg.split('=')[1] : 'local'
|
||||||
|
if (mode !== 'local' && mode !== 'gateway') {
|
||||||
|
process.stderr.write(`Invalid mode: ${mode}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = process.cwd()
|
||||||
|
const fixtureSource = path.join(repoRoot, 'tests', 'fixtures', 'openclaw')
|
||||||
|
const runtimeRoot = path.join(repoRoot, '.tmp', 'e2e-openclaw', mode)
|
||||||
|
const dataDir = path.join(runtimeRoot, 'data')
|
||||||
|
const mockBinDir = path.join(repoRoot, 'scripts', 'e2e-openclaw', 'bin')
|
||||||
|
|
||||||
|
fs.rmSync(runtimeRoot, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(runtimeRoot, { recursive: true })
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
fs.cpSync(fixtureSource, runtimeRoot, { recursive: true })
|
||||||
|
|
||||||
|
const gatewayHost = '127.0.0.1'
|
||||||
|
const gatewayPort = '18789'
|
||||||
|
|
||||||
|
const baseEnv = {
|
||||||
|
...process.env,
|
||||||
|
API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
|
||||||
|
AUTH_USER: process.env.AUTH_USER || 'admin',
|
||||||
|
AUTH_PASS: process.env.AUTH_PASS || 'admin',
|
||||||
|
MC_DISABLE_RATE_LIMIT: '1',
|
||||||
|
MISSION_CONTROL_DATA_DIR: dataDir,
|
||||||
|
MISSION_CONTROL_DB_PATH: path.join(dataDir, 'mission-control.db'),
|
||||||
|
OPENCLAW_STATE_DIR: runtimeRoot,
|
||||||
|
OPENCLAW_CONFIG_PATH: path.join(runtimeRoot, 'openclaw.json'),
|
||||||
|
OPENCLAW_GATEWAY_HOST: gatewayHost,
|
||||||
|
OPENCLAW_GATEWAY_PORT: gatewayPort,
|
||||||
|
OPENCLAW_BIN: path.join(mockBinDir, 'openclaw'),
|
||||||
|
CLAWDBOT_BIN: path.join(mockBinDir, 'clawdbot'),
|
||||||
|
PATH: `${mockBinDir}:${process.env.PATH || ''}`,
|
||||||
|
E2E_GATEWAY_EXPECTED: mode === 'gateway' ? '1' : '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = []
|
||||||
|
|
||||||
|
if (mode === 'gateway') {
|
||||||
|
const gw = spawn('node', ['scripts/e2e-openclaw/mock-gateway.mjs'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: baseEnv,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
children.push(gw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const standaloneServerPath = path.join(repoRoot, '.next', 'standalone', 'server.js')
|
||||||
|
const app = fs.existsSync(standaloneServerPath)
|
||||||
|
? spawn('node', [standaloneServerPath], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: {
|
||||||
|
...baseEnv,
|
||||||
|
HOSTNAME: '127.0.0.1',
|
||||||
|
PORT: '3005',
|
||||||
|
},
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
: spawn('pnpm', ['start'], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
env: baseEnv,
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
children.push(app)
|
||||||
|
|
||||||
|
function shutdown(signal = 'SIGTERM') {
|
||||||
|
for (const child of children) {
|
||||||
|
if (!child.killed) {
|
||||||
|
try {
|
||||||
|
child.kill(signal)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
shutdown('SIGINT')
|
||||||
|
process.exit(130)
|
||||||
|
})
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
shutdown('SIGTERM')
|
||||||
|
process.exit(143)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('exit', (code) => {
|
||||||
|
shutdown('SIGTERM')
|
||||||
|
process.exit(code ?? 0)
|
||||||
|
})
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getDatabase, db_helpers } from '@/lib/db';
|
import { getDatabase, db_helpers } from '@/lib/db';
|
||||||
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
|
import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
|
||||||
import { join, dirname } from 'path';
|
import { join, dirname, isAbsolute, resolve } from 'path';
|
||||||
import { config } from '@/lib/config';
|
import { config } from '@/lib/config';
|
||||||
import { resolveWithin } from '@/lib/paths';
|
import { resolveWithin } from '@/lib/paths';
|
||||||
import { requireRole } from '@/lib/auth';
|
import { requireRole } from '@/lib/auth';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
function resolveAgentWorkspacePath(workspace: string): string {
|
||||||
|
if (isAbsolute(workspace)) return resolve(workspace)
|
||||||
|
if (!config.openclawStateDir) throw new Error('OPENCLAW_STATE_DIR not configured')
|
||||||
|
return resolveWithin(config.openclawStateDir, workspace)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/agents/[id]/soul - Get agent's SOUL content
|
* GET /api/agents/[id]/soul - Get agent's SOUL content
|
||||||
*/
|
*/
|
||||||
|
|
@ -41,8 +47,8 @@ export async function GET(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
||||||
if (agentConfig.workspace && config.openclawHome) {
|
if (agentConfig.workspace) {
|
||||||
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
|
const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace)
|
||||||
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
||||||
if (existsSync(safeSoulPath)) {
|
if (existsSync(safeSoulPath)) {
|
||||||
soulContent = readFileSync(safeSoulPath, 'utf-8')
|
soulContent = readFileSync(safeSoulPath, 'utf-8')
|
||||||
|
|
@ -157,8 +163,8 @@ export async function PUT(
|
||||||
let savedToWorkspace = false
|
let savedToWorkspace = false
|
||||||
try {
|
try {
|
||||||
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
const agentConfig = agent.config ? JSON.parse(agent.config) : {}
|
||||||
if (agentConfig.workspace && config.openclawHome) {
|
if (agentConfig.workspace) {
|
||||||
const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
|
const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace)
|
||||||
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
|
||||||
mkdirSync(dirname(safeSoulPath), { recursive: true })
|
mkdirSync(dirname(safeSoulPath), { recursive: true })
|
||||||
writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
|
writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,9 @@ interface OpenClawCronFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCronFilePath(): string {
|
function getCronFilePath(): string {
|
||||||
const openclawHome = config.openclawHome
|
const openclawStateDir = config.openclawStateDir
|
||||||
if (!openclawHome) return ''
|
if (!openclawStateDir) return ''
|
||||||
return path.join(openclawHome, 'cron', 'jobs.json')
|
return path.join(openclawStateDir, 'cron', 'jobs.json')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCronFile(): Promise<OpenClawCronFile | null> {
|
async function loadCronFile(): Promise<OpenClawCronFile | null> {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { requireRole } from '@/lib/auth'
|
import { requireRole } from '@/lib/auth'
|
||||||
import { logAuditEvent } from '@/lib/db'
|
import { logAuditEvent } from '@/lib/db'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import { join } from 'path'
|
|
||||||
import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
|
import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
|
||||||
import { mutationLimiter } from '@/lib/rate-limit'
|
import { mutationLimiter } from '@/lib/rate-limit'
|
||||||
|
|
||||||
function getConfigPath(): string | null {
|
function getConfigPath(): string | null {
|
||||||
if (!config.openclawHome) return null
|
return config.openclawConfigPath || null
|
||||||
return join(config.openclawHome, 'openclaw.json')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +18,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (!configPath) {
|
if (!configPath) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -60,7 +58,7 @@ export async function PUT(request: NextRequest) {
|
||||||
|
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (!configPath) {
|
if (!configPath) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await validateBody(request, gatewayConfigUpdateSchema)
|
const result = await validateBody(request, gatewayConfigUpdateSchema)
|
||||||
|
|
|
||||||
|
|
@ -108,8 +108,8 @@ function serializeEnv(lines: EnvLine[]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEnvPath(): string | null {
|
function getEnvPath(): string | null {
|
||||||
if (!config.openclawHome) return null
|
if (!config.openclawStateDir) return null
|
||||||
return join(config.openclawHome, '.env')
|
return join(config.openclawStateDir, '.env')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> {
|
async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> {
|
||||||
|
|
@ -184,7 +184,7 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const envMap = new Map<string, string>()
|
const envMap = new Map<string, string>()
|
||||||
|
|
@ -261,7 +261,7 @@ export async function PUT(request: NextRequest) {
|
||||||
|
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lines } = envData
|
const { lines } = envData
|
||||||
|
|
@ -324,7 +324,7 @@ export async function DELETE(request: NextRequest) {
|
||||||
|
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const removed: string[] = []
|
const removed: string[] = []
|
||||||
|
|
@ -408,7 +408,7 @@ async function handleTest(
|
||||||
|
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const envMap = new Map<string, string>()
|
const envMap = new Map<string, string>()
|
||||||
|
|
@ -552,7 +552,7 @@ async function handlePull(
|
||||||
// Write to .env
|
// Write to .env
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lines } = envData
|
const { lines } = envData
|
||||||
|
|
@ -621,7 +621,7 @@ async function handlePullAll(
|
||||||
|
|
||||||
const envData = await readEnvFile()
|
const envData = await readEnvFile()
|
||||||
if (!envData) {
|
if (!envData) {
|
||||||
return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
|
return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lines } = envData
|
const { lines } = envData
|
||||||
|
|
|
||||||
|
|
@ -475,7 +475,10 @@ async function performHealthCheck() {
|
||||||
async function getCapabilities() {
|
async function getCapabilities() {
|
||||||
const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort)
|
const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort)
|
||||||
|
|
||||||
const openclawHome = !!(config.openclawHome && existsSync(config.openclawHome))
|
const openclawHome = Boolean(
|
||||||
|
(config.openclawStateDir && existsSync(config.openclawStateDir)) ||
|
||||||
|
(config.openclawConfigPath && existsSync(config.openclawConfigPath))
|
||||||
|
)
|
||||||
|
|
||||||
const claudeProjectsPath = path.join(config.claudeHome, 'projects')
|
const claudeProjectsPath = path.join(config.claudeHome, 'projects')
|
||||||
const claudeHome = existsSync(claudeProjectsPath)
|
const claudeHome = existsSync(claudeProjectsPath)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('ErrorBoundary')
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
|
@ -23,7 +26,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
console.error('Panel error:', error, errorInfo)
|
log.error('Panel error:', error, errorInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@
|
||||||
import { useEffect, useCallback, useState, useRef } from 'react'
|
import { useEffect, useCallback, useState, useRef } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
import { ConversationList } from './conversation-list'
|
import { ConversationList } from './conversation-list'
|
||||||
import { MessageList } from './message-list'
|
import { MessageList } from './message-list'
|
||||||
import { ChatInput } from './chat-input'
|
import { ChatInput } from './chat-input'
|
||||||
|
|
||||||
|
const log = createClientLogger('ChatPanel')
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
const {
|
const {
|
||||||
chatPanelOpen,
|
chatPanelOpen,
|
||||||
|
|
@ -52,7 +55,7 @@ export function ChatPanel() {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.agents) setAgents(data.agents)
|
if (data.agents) setAgents(data.agents)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load agents:', err)
|
log.error('Failed to load agents:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (chatPanelOpen) loadAgents()
|
if (chatPanelOpen) loadAgents()
|
||||||
|
|
@ -67,7 +70,7 @@ export function ChatPanel() {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.messages) setChatMessages(data.messages)
|
if (data.messages) setChatMessages(data.messages)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load messages:', err)
|
log.error('Failed to load messages:', err)
|
||||||
}
|
}
|
||||||
}, [activeConversation, setChatMessages])
|
}, [activeConversation, setChatMessages])
|
||||||
|
|
||||||
|
|
@ -145,7 +148,7 @@ export function ChatPanel() {
|
||||||
updatePendingMessage(tempId, { pendingStatus: 'failed' })
|
updatePendingMessage(tempId, { pendingStatus: 'failed' })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to send message:', err)
|
log.error('Failed to send message:', err)
|
||||||
updatePendingMessage(tempId, { pendingStatus: 'failed' })
|
updatePendingMessage(tempId, { pendingStatus: 'failed' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useMissionControl, Conversation, Agent } from '@/store'
|
import { useMissionControl, Conversation, Agent } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('ConversationList')
|
||||||
|
|
||||||
function timeAgo(timestamp: number): string {
|
function timeAgo(timestamp: number): string {
|
||||||
const diff = Math.floor(Date.now() / 1000) - timestamp
|
const diff = Math.floor(Date.now() / 1000) - timestamp
|
||||||
|
|
@ -64,7 +67,7 @@ export function ConversationList({ onNewConversation }: ConversationListProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load conversations:', err)
|
log.error('Failed to load conversations:', err)
|
||||||
}
|
}
|
||||||
}, [setConversations])
|
}, [setConversations])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useNavigateToPanel } from '@/lib/navigation'
|
import { useNavigateToPanel } from '@/lib/navigation'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('Sidebar')
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -36,7 +39,7 @@ export function Sidebar() {
|
||||||
fetch('/api/status?action=overview')
|
fetch('/api/status?action=overview')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setSystemStats(data))
|
.then(data => setSystemStats(data))
|
||||||
.catch(err => console.error('Failed to fetch system status:', err))
|
.catch(err => log.error('Failed to fetch system status:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const activeSessions = sessions.filter(s => s.active).length
|
const activeSessions = sessions.filter(s => s.active).length
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
import {
|
import {
|
||||||
PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip, Legend, ResponsiveContainer,
|
Tooltip, Legend, ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
|
|
||||||
|
const log = createClientLogger('AgentCostPanel')
|
||||||
|
|
||||||
interface AgentCostData {
|
interface AgentCostData {
|
||||||
stats: { totalTokens: number; totalCost: number; requestCount: number; avgTokensPerRequest: number; avgCostPerRequest: number }
|
stats: { totalTokens: number; totalCost: number; requestCount: number; avgTokensPerRequest: number; avgCostPerRequest: number }
|
||||||
models: Record<string, { totalTokens: number; totalCost: number; requestCount: number }>
|
models: Record<string, { totalTokens: number; totalCost: number; requestCount: number }>
|
||||||
|
|
@ -34,7 +37,7 @@ export function AgentCostPanel() {
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
setData(json)
|
setData(json)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load agent costs:', err)
|
log.error('Failed to load agent costs:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
const log = createClientLogger('AgentDetailTabs')
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -374,7 +377,7 @@ export function SoulTab({
|
||||||
setSelectedTemplate(templateName)
|
setSelectedTemplate(templateName)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load template:', error)
|
log.error('Failed to load template:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -626,7 +629,7 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||||
setTasks(data.tasks || [])
|
setTasks(data.tasks || [])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch tasks:', error)
|
log.error('Failed to fetch tasks:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -725,7 +728,7 @@ export function ActivityTab({ agent }: { agent: Agent }) {
|
||||||
setActivities(data.activities || [])
|
setActivities(data.activities || [])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch activities:', error)
|
log.error('Failed to fetch activities:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('AgentSpawnPanel')
|
||||||
|
|
||||||
interface SpawnFormData {
|
interface SpawnFormData {
|
||||||
task: string
|
task: string
|
||||||
|
|
@ -33,7 +36,7 @@ export function AgentSpawnPanel() {
|
||||||
fetch('/api/spawn')
|
fetch('/api/spawn')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setSpawnHistory(data.history || []))
|
.then(data => setSpawnHistory(data.history || []))
|
||||||
.catch(err => console.error('Failed to load spawn history:', err))
|
.catch(err => log.error('Failed to load spawn history:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSpawn = async () => {
|
const handleSpawn = async () => {
|
||||||
|
|
@ -95,7 +98,7 @@ export function AgentSpawnPanel() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spawn error:', error)
|
log.error('Spawn error:', error)
|
||||||
updateSpawnRequest(spawnId, {
|
updateSpawnRequest(spawnId, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : 'Network error'
|
error: error instanceof Error ? error.message : 'Network error'
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
||||||
import {
|
import {
|
||||||
OverviewTab,
|
OverviewTab,
|
||||||
|
|
@ -13,6 +14,8 @@ import {
|
||||||
CreateAgentModal
|
CreateAgentModal
|
||||||
} from './agent-detail-tabs'
|
} from './agent-detail-tabs'
|
||||||
|
|
||||||
|
const log = createClientLogger('AgentSquadPhase3')
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -148,7 +151,7 @@ export function AgentSquadPanelPhase3() {
|
||||||
: agent
|
: agent
|
||||||
))
|
))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update agent status:', error)
|
log.error('Failed to update agent status:', error)
|
||||||
setError('Failed to update agent status')
|
setError('Failed to update agent status')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -171,7 +174,7 @@ export function AgentSquadPanelPhase3() {
|
||||||
|
|
||||||
await updateAgentStatus(agentName, 'idle', 'Manually woken via session')
|
await updateAgentStatus(agentName, 'idle', 'Manually woken via session')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to wake agent:', error)
|
log.error('Failed to wake agent:', error)
|
||||||
setError('Failed to wake agent')
|
setError('Failed to wake agent')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -503,7 +506,7 @@ function AgentDetailModalPhase3({
|
||||||
setSoulTemplates(data.templates || [])
|
setSoulTemplates(data.templates || [])
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load SOUL templates:', error)
|
log.error('Failed to load SOUL templates:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -522,7 +525,7 @@ function AgentDetailModalPhase3({
|
||||||
setHeartbeatData(data)
|
setHeartbeatData(data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to perform heartbeat:', error)
|
log.error('Failed to perform heartbeat:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingHeartbeat(false)
|
setLoadingHeartbeat(false)
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +547,7 @@ function AgentDetailModalPhase3({
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update agent:', error)
|
log.error('Failed to update agent:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -564,7 +567,7 @@ function AgentDetailModalPhase3({
|
||||||
setFormData(prev => ({ ...prev, soul_content: content }))
|
setFormData(prev => ({ ...prev, soul_content: content }))
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update SOUL:', error)
|
log.error('Failed to update SOUL:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -585,7 +588,7 @@ function AgentDetailModalPhase3({
|
||||||
setFormData(prev => ({ ...prev, working_memory: data.working_memory }))
|
setFormData(prev => ({ ...prev, working_memory: data.working_memory }))
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update memory:', error)
|
log.error('Failed to update memory:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -746,7 +749,7 @@ function QuickSpawnModal({
|
||||||
alert(result.error || 'Failed to spawn agent')
|
alert(result.error || 'Failed to spawn agent')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spawn failed:', error)
|
log.error('Spawn failed:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSpawning(false)
|
setIsSpawning(false)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('AgentSquadPanel')
|
||||||
|
|
||||||
interface Agent {
|
interface Agent {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -103,7 +106,7 @@ export function AgentSquadPanel() {
|
||||||
: agent
|
: agent
|
||||||
))
|
))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update agent status:', error)
|
log.error('Failed to update agent status:', error)
|
||||||
setError('Failed to update agent status')
|
setError('Failed to update agent status')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -355,7 +358,7 @@ function AgentDetailModal({
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
onUpdate()
|
onUpdate()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update agent:', error)
|
log.error('Failed to update agent:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -540,7 +543,7 @@ function CreateAgentModal({
|
||||||
onCreated()
|
onCreated()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating agent:', error)
|
log.error('Error creating agent:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useMissionControl, CronJob } from '@/store'
|
import { useMissionControl, CronJob } from '@/store'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
const log = createClientLogger('CronManagement')
|
||||||
import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
|
import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences'
|
||||||
|
|
||||||
interface NewJobForm {
|
interface NewJobForm {
|
||||||
|
|
@ -92,7 +94,7 @@ export function CronManagementPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setCronJobs(data.jobs || [])
|
setCronJobs(data.jobs || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load cron jobs:', error)
|
log.error('Failed to load cron jobs:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +128,7 @@ export function CronManagementPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setJobLogs(data.logs || [])
|
setJobLogs(data.logs || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load job logs:', error)
|
log.error('Failed to load job logs:', error)
|
||||||
setJobLogs([])
|
setJobLogs([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +152,7 @@ export function CronManagementPanel() {
|
||||||
alert(`Failed to toggle job: ${error.error}`)
|
alert(`Failed to toggle job: ${error.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to toggle job:', error)
|
log.error('Failed to toggle job:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -175,7 +177,7 @@ export function CronManagementPanel() {
|
||||||
alert(`Job failed:\n${result.error}\n${result.stderr}`)
|
alert(`Job failed:\n${result.error}\n${result.stderr}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to trigger job:', error)
|
log.error('Failed to trigger job:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +216,7 @@ export function CronManagementPanel() {
|
||||||
alert(`Failed to add job: ${error.error}`)
|
alert(`Failed to add job: ${error.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add job:', error)
|
log.error('Failed to add job:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +246,7 @@ export function CronManagementPanel() {
|
||||||
alert(`Failed to remove job: ${error.error}`)
|
alert(`Failed to remove job: ${error.error}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove job:', error)
|
log.error('Failed to remove job:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ export function GatewayConfigPanel() {
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div>
|
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Ensure OPENCLAW_HOME is set and openclaw.json exists at the expected path.
|
Ensure `OPENCLAW_CONFIG_PATH` (or `OPENCLAW_STATE_DIR`) is set and the config file exists.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('LogViewer')
|
||||||
|
|
||||||
interface LogFilters {
|
interface LogFilters {
|
||||||
level?: string
|
level?: string
|
||||||
|
|
@ -36,7 +39,7 @@ export function LogViewerPanel() {
|
||||||
}, [logFilters])
|
}, [logFilters])
|
||||||
|
|
||||||
const loadLogs = useCallback(async (tail = false) => {
|
const loadLogs = useCallback(async (tail = false) => {
|
||||||
console.log(`LogViewer: Loading logs (tail=${tail})`)
|
log.debug(`Loading logs (tail=${tail})`)
|
||||||
setIsLoading(!tail) // Only show loading for initial load, not for tailing
|
setIsLoading(!tail) // Only show loading for initial load, not for tailing
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -53,37 +56,37 @@ export function LogViewerPanel() {
|
||||||
...(tail && currentLogs.length > 0 && { since: currentLogs[0]?.timestamp.toString() })
|
...(tail && currentLogs.length > 0 && { since: currentLogs[0]?.timestamp.toString() })
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`LogViewer: Fetching /api/logs?${params}`)
|
log.debug(`Fetching /api/logs?${params}`)
|
||||||
const response = await fetch(`/api/logs?${params}`)
|
const response = await fetch(`/api/logs?${params}`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
console.log(`LogViewer: Received ${data.logs?.length || 0} logs from API`)
|
log.debug(`Received ${data.logs?.length || 0} logs from API`)
|
||||||
|
|
||||||
if (data.logs && data.logs.length > 0) {
|
if (data.logs && data.logs.length > 0) {
|
||||||
if (tail) {
|
if (tail) {
|
||||||
// Add new logs for tail mode - prepend to existing logs
|
// Add new logs for tail mode - prepend to existing logs
|
||||||
let newLogsAdded = 0
|
let newLogsAdded = 0
|
||||||
const existingIds = new Set((currentLogs || []).map((l: any) => l?.id).filter(Boolean))
|
const existingIds = new Set((currentLogs || []).map((l: any) => l?.id).filter(Boolean))
|
||||||
data.logs.reverse().forEach((log: any) => {
|
data.logs.reverse().forEach((entry: any) => {
|
||||||
if (existingIds.has(log?.id)) return
|
if (existingIds.has(entry?.id)) return
|
||||||
addLog(log)
|
addLog(entry)
|
||||||
newLogsAdded++
|
newLogsAdded++
|
||||||
})
|
})
|
||||||
console.log(`LogViewer: Added ${newLogsAdded} new logs (tail mode)`)
|
log.debug(`Added ${newLogsAdded} new logs (tail mode)`)
|
||||||
} else {
|
} else {
|
||||||
// Replace logs for initial load or refresh
|
// Replace logs for initial load or refresh
|
||||||
console.log(`LogViewer: Clearing existing logs and loading ${data.logs.length} logs`)
|
log.debug(`Clearing existing logs and loading ${data.logs.length} logs`)
|
||||||
clearLogs() // Clear existing logs
|
clearLogs() // Clear existing logs
|
||||||
data.logs.reverse().forEach((log: any) => {
|
data.logs.reverse().forEach((entry: any) => {
|
||||||
addLog(log)
|
addLog(entry)
|
||||||
})
|
})
|
||||||
console.log(`LogViewer: Successfully added ${data.logs.length} logs to store`)
|
log.debug(`Successfully added ${data.logs.length} logs to store`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('LogViewer: No logs received from API')
|
log.debug('No logs received from API')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('LogViewer: Failed to load logs:', error)
|
log.error('Failed to load logs:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -95,13 +98,13 @@ export function LogViewerPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setAvailableSources(data.sources || [])
|
setAvailableSources(data.sources || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load log sources:', error)
|
log.error('Failed to load log sources:', error)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load initial logs and sources
|
// Load initial logs and sources
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('LogViewer: Initial load started')
|
log.debug('Initial load started')
|
||||||
loadLogs()
|
loadLogs()
|
||||||
loadSources()
|
loadSources()
|
||||||
}, [loadLogs, loadSources])
|
}, [loadLogs, loadSources])
|
||||||
|
|
@ -154,16 +157,16 @@ export function LogViewerPanel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredLogs = logs.filter(log => {
|
const filteredLogs = logs.filter(entry => {
|
||||||
if (logFilters.level && log.level !== logFilters.level) return false
|
if (logFilters.level && entry.level !== logFilters.level) return false
|
||||||
if (logFilters.source && log.source !== logFilters.source) return false
|
if (logFilters.source && entry.source !== logFilters.source) return false
|
||||||
if (logFilters.search && !log.message.toLowerCase().includes(logFilters.search.toLowerCase())) return false
|
if (logFilters.search && !entry.message.toLowerCase().includes(logFilters.search.toLowerCase())) return false
|
||||||
if (logFilters.session && (!log.session || !log.session.includes(logFilters.session))) return false
|
if (logFilters.session && (!entry.session || !entry.session.includes(logFilters.session))) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log(`LogViewer: Store has ${logs.length} logs, filtered to ${filteredLogs.length}`)
|
log.debug(`Store has ${logs.length} logs, filtered to ${filteredLogs.length}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full p-6 space-y-4">
|
<div className="flex flex-col h-full p-6 space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('MemoryBrowser')
|
||||||
|
|
||||||
interface MemoryFile {
|
interface MemoryFile {
|
||||||
path: string
|
path: string
|
||||||
|
|
@ -46,7 +49,7 @@ export function MemoryBrowserPanel() {
|
||||||
// Auto-expand some common directories
|
// Auto-expand some common directories
|
||||||
setExpandedFolders(new Set(['daily', 'knowledge']))
|
setExpandedFolders(new Set(['daily', 'knowledge']))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load file tree:', error)
|
log.error('Failed to load file tree:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +86,7 @@ export function MemoryBrowserPanel() {
|
||||||
alert(data.error || 'Failed to load file content')
|
alert(data.error || 'Failed to load file content')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load file content:', error)
|
log.error('Failed to load file content:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
@ -99,7 +102,7 @@ export function MemoryBrowserPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setSearchResults(data.results || [])
|
setSearchResults(data.results || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error)
|
log.error('Search failed:', error)
|
||||||
setSearchResults([])
|
setSearchResults([])
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearching(false)
|
setIsSearching(false)
|
||||||
|
|
@ -165,7 +168,7 @@ export function MemoryBrowserPanel() {
|
||||||
alert(data.error || 'Failed to save file')
|
alert(data.error || 'Failed to save file')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save file:', error)
|
log.error('Failed to save file:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
|
@ -192,7 +195,7 @@ export function MemoryBrowserPanel() {
|
||||||
alert(data.error || 'Failed to create file')
|
alert(data.error || 'Failed to create file')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create file:', error)
|
log.error('Failed to create file:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +223,7 @@ export function MemoryBrowserPanel() {
|
||||||
alert(data.error || 'Failed to delete file')
|
alert(data.error || 'Failed to delete file')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete file:', error)
|
log.error('Failed to delete file:', error)
|
||||||
alert('Network error occurred')
|
alert('Network error occurred')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +274,30 @@ export function MemoryBrowserPanel() {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderInlineFormatting = (text: string): React.ReactNode[] => {
|
||||||
|
const parts: React.ReactNode[] = []
|
||||||
|
const regex = /(\*\*.*?\*\*|\*.*?\*)/g
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
let key = 0
|
||||||
|
while ((match = regex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index))
|
||||||
|
}
|
||||||
|
const m = match[0]
|
||||||
|
if (m.startsWith('**') && m.endsWith('**')) {
|
||||||
|
parts.push(<strong key={key++}>{m.slice(2, -2)}</strong>)
|
||||||
|
} else if (m.startsWith('*') && m.endsWith('*')) {
|
||||||
|
parts.push(<em key={key++}>{m.slice(1, -1)}</em>)
|
||||||
|
}
|
||||||
|
lastIndex = regex.lastIndex
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex))
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
const renderMarkdown = (content: string) => {
|
const renderMarkdown = (content: string) => {
|
||||||
// Improved markdown rendering with proper line handling
|
// Improved markdown rendering with proper line handling
|
||||||
const lines = content.split('\n')
|
const lines = content.split('\n')
|
||||||
|
|
@ -323,20 +350,10 @@ export function MemoryBrowserPanel() {
|
||||||
elements.push(<div key={`${i}-space`} className="mb-2"></div>)
|
elements.push(<div key={`${i}-space`} className="mb-2"></div>)
|
||||||
} else if (trimmedLine.length > 0) {
|
} else if (trimmedLine.length > 0) {
|
||||||
if (inList) inList = false
|
if (inList) inList = false
|
||||||
// Handle inline formatting — escape HTML entities first to prevent XSS
|
|
||||||
let content = trimmedLine
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
// Simple bold formatting
|
|
||||||
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
// Simple italic formatting
|
|
||||||
content = content.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
||||||
|
|
||||||
elements.push(
|
elements.push(
|
||||||
<p key={`${i}-p`} className="mb-2" dangerouslySetInnerHTML={{ __html: content }}></p>
|
<p key={`${i}-p`} className="mb-2">
|
||||||
|
{renderInlineFormatting(trimmedLine)}
|
||||||
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('SessionDetails')
|
||||||
|
|
||||||
export function SessionDetailsPanel() {
|
export function SessionDetailsPanel() {
|
||||||
const {
|
const {
|
||||||
|
|
@ -20,7 +23,7 @@ export function SessionDetailsPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setSessions(data.sessions || data)
|
setSessions(data.sessions || data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load sessions:', error)
|
log.error('Failed to load sessions:', error)
|
||||||
}
|
}
|
||||||
}, [setSessions])
|
}, [setSessions])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('StandupPanel')
|
||||||
|
|
||||||
interface StandupReport {
|
interface StandupReport {
|
||||||
date: string
|
date: string
|
||||||
|
|
@ -133,7 +136,7 @@ export function StandupPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setStandupHistory(data.history || [])
|
setStandupHistory(data.history || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch standup history:', err)
|
log.error('Failed to fetch standup history:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,16 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
import { useSmartPoll } from '@/lib/use-smart-poll'
|
import { useSmartPoll } from '@/lib/use-smart-poll'
|
||||||
|
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
import { useFocusTrap } from '@/lib/use-focus-trap'
|
import { useFocusTrap } from '@/lib/use-focus-trap'
|
||||||
|
|
||||||
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
import { AgentAvatar } from '@/components/ui/agent-avatar'
|
||||||
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
import { MarkdownRenderer } from '@/components/markdown-renderer'
|
||||||
|
|
||||||
|
const log = createClientLogger('TaskBoard')
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
|
|
@ -1024,7 +1030,7 @@ function CreateTaskModal({
|
||||||
onCreated()
|
onCreated()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating task:', error)
|
log.error('Error creating task:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1193,7 +1199,7 @@ function EditTaskModal({
|
||||||
|
|
||||||
onUpdated()
|
onUpdated()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating task:', error)
|
log.error('Error updating task:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell } from 'recharts'
|
||||||
|
|
||||||
|
const log = createClientLogger('TokenDashboard')
|
||||||
|
|
||||||
interface UsageStats {
|
interface UsageStats {
|
||||||
summary: {
|
summary: {
|
||||||
totalTokens: number
|
totalTokens: number
|
||||||
|
|
@ -39,7 +42,7 @@ export function TokenDashboardPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setUsageStats(data)
|
setUsageStats(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load usage stats:', error)
|
log.error('Failed to load usage stats:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -51,7 +54,7 @@ export function TokenDashboardPanel() {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setTrendData(data)
|
setTrendData(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load trend data:', error)
|
log.error('Failed to load trend data:', error)
|
||||||
}
|
}
|
||||||
}, [selectedTimeframe])
|
}, [selectedTimeframe])
|
||||||
|
|
||||||
|
|
@ -80,7 +83,7 @@ export function TokenDashboardPanel() {
|
||||||
window.URL.revokeObjectURL(url)
|
window.URL.revokeObjectURL(url)
|
||||||
document.body.removeChild(a)
|
document.body.removeChild(a)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Export failed:', error)
|
log.error('Export failed:', error)
|
||||||
alert('Export failed: ' + error)
|
alert('Export failed: ' + error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsExporting(false)
|
setIsExporting(false)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
import { getDatabase, db_helpers, logAuditEvent } from './db'
|
import { getDatabase, db_helpers, logAuditEvent } from './db'
|
||||||
import { eventBus } from './event-bus'
|
import { eventBus } from './event-bus'
|
||||||
import { join } from 'path'
|
import { join, isAbsolute, resolve } from 'path'
|
||||||
import { existsSync, readFileSync } from 'fs'
|
import { existsSync, readFileSync } from 'fs'
|
||||||
import { resolveWithin } from './paths'
|
import { resolveWithin } from './paths'
|
||||||
import { logger } from './logger'
|
import { logger } from './logger'
|
||||||
|
|
@ -127,15 +127,22 @@ function parseToolsFromFile(content: string): { allow?: string[]; raw?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfigPath(): string | null {
|
function getConfigPath(): string | null {
|
||||||
if (!config.openclawHome) return null
|
return config.openclawConfigPath || null
|
||||||
return join(config.openclawHome, 'openclaw.json')
|
}
|
||||||
|
|
||||||
|
function resolveAgentWorkspacePath(workspace: string): string {
|
||||||
|
if (isAbsolute(workspace)) return resolve(workspace)
|
||||||
|
if (!config.openclawStateDir) {
|
||||||
|
throw new Error('OPENCLAW_STATE_DIR not configured')
|
||||||
|
}
|
||||||
|
return resolveWithin(config.openclawStateDir, workspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Safely read a file from an agent's workspace directory */
|
/** Safely read a file from an agent's workspace directory */
|
||||||
function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
|
function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
|
||||||
if (!workspace || !config.openclawHome) return null
|
if (!workspace) return null
|
||||||
try {
|
try {
|
||||||
const safeWorkspace = resolveWithin(config.openclawHome, workspace)
|
const safeWorkspace = resolveAgentWorkspacePath(workspace)
|
||||||
const safePath = resolveWithin(safeWorkspace, filename)
|
const safePath = resolveWithin(safeWorkspace, filename)
|
||||||
if (existsSync(safePath)) {
|
if (existsSync(safePath)) {
|
||||||
return readFileSync(safePath, 'utf-8')
|
return readFileSync(safePath, 'utf-8')
|
||||||
|
|
@ -173,7 +180,7 @@ export function enrichAgentConfigFromWorkspace(configData: any): any {
|
||||||
/** Read and parse openclaw.json agents list */
|
/** Read and parse openclaw.json agents list */
|
||||||
async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
|
async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (!configPath) throw new Error('OPENCLAW_HOME not configured')
|
if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured')
|
||||||
|
|
||||||
const { readFile } = require('fs/promises')
|
const { readFile } = require('fs/promises')
|
||||||
const raw = await readFile(configPath, 'utf-8')
|
const raw = await readFile(configPath, 'utf-8')
|
||||||
|
|
@ -334,7 +341,7 @@ export async function previewSyncDiff(): Promise<SyncDiff> {
|
||||||
/** Write an agent config back to openclaw.json agents.list */
|
/** Write an agent config back to openclaw.json agents.list */
|
||||||
export async function writeAgentToConfig(agentConfig: any): Promise<void> {
|
export async function writeAgentToConfig(agentConfig: any): Promise<void> {
|
||||||
const configPath = getConfigPath()
|
const configPath = getConfigPath()
|
||||||
if (!configPath) throw new Error('OPENCLAW_HOME not configured')
|
if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured')
|
||||||
|
|
||||||
const { readFile, writeFile } = require('fs/promises')
|
const { readFile, writeFile } = require('fs/promises')
|
||||||
const raw = await readFile(configPath, 'utf-8')
|
const raw = await readFile(configPath, 'utf-8')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* Lightweight structured logger for client-side ('use client') components.
|
||||||
|
*
|
||||||
|
* Mirrors pino's API surface (info/warn/error/debug) so call sites are
|
||||||
|
* consistent with the server-side logger in src/lib/logger.ts.
|
||||||
|
* In production builds, debug and info messages are suppressed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
const minLevel: number =
|
||||||
|
process.env.NODE_ENV === 'production' ? LOG_LEVELS.warn : LOG_LEVELS.debug
|
||||||
|
|
||||||
|
function shouldLog(level: LogLevel): boolean {
|
||||||
|
return LOG_LEVELS[level] >= minLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatArgs(
|
||||||
|
level: LogLevel,
|
||||||
|
module: string,
|
||||||
|
msgOrObj: unknown,
|
||||||
|
...rest: unknown[]
|
||||||
|
): unknown[] {
|
||||||
|
const prefix = `[${level.toUpperCase()}] ${module}:`
|
||||||
|
if (typeof msgOrObj === 'string') {
|
||||||
|
return [prefix, msgOrObj, ...rest]
|
||||||
|
}
|
||||||
|
return [prefix, msgOrObj, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientLogger {
|
||||||
|
debug(msg: string, ...args: unknown[]): void
|
||||||
|
debug(obj: Record<string, unknown>, msg?: string): void
|
||||||
|
info(msg: string, ...args: unknown[]): void
|
||||||
|
info(obj: Record<string, unknown>, msg?: string): void
|
||||||
|
warn(msg: string, ...args: unknown[]): void
|
||||||
|
warn(obj: Record<string, unknown>, msg?: string): void
|
||||||
|
error(msg: string, ...args: unknown[]): void
|
||||||
|
error(obj: Record<string, unknown>, msg?: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientLogger(module: string): ClientLogger {
|
||||||
|
return {
|
||||||
|
debug(msgOrObj: unknown, ...rest: unknown[]) {
|
||||||
|
if (!shouldLog('debug')) return
|
||||||
|
console.debug(...formatArgs('debug', module, msgOrObj, ...rest))
|
||||||
|
},
|
||||||
|
info(msgOrObj: unknown, ...rest: unknown[]) {
|
||||||
|
if (!shouldLog('info')) return
|
||||||
|
console.info(...formatArgs('info', module, msgOrObj, ...rest))
|
||||||
|
},
|
||||||
|
warn(msgOrObj: unknown, ...rest: unknown[]) {
|
||||||
|
if (!shouldLog('warn')) return
|
||||||
|
console.warn(...formatArgs('warn', module, msgOrObj, ...rest))
|
||||||
|
},
|
||||||
|
error(msgOrObj: unknown, ...rest: unknown[]) {
|
||||||
|
if (!shouldLog('error')) return
|
||||||
|
console.error(...formatArgs('error', module, msgOrObj, ...rest))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -74,13 +74,13 @@ export function runCommand(
|
||||||
export function runOpenClaw(args: string[], options: CommandOptions = {}) {
|
export function runOpenClaw(args: string[], options: CommandOptions = {}) {
|
||||||
return runCommand(config.openclawBin, args, {
|
return runCommand(config.openclawBin, args, {
|
||||||
...options,
|
...options,
|
||||||
cwd: options.cwd || config.openclawHome || process.cwd()
|
cwd: options.cwd || config.openclawStateDir || process.cwd()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runClawdbot(args: string[], options: CommandOptions = {}) {
|
export function runClawdbot(args: string[], options: CommandOptions = {}) {
|
||||||
return runCommand(config.clawdbotBin, args, {
|
return runCommand(config.clawdbotBin, args, {
|
||||||
...options,
|
...options,
|
||||||
cwd: options.cwd || config.openclawHome || process.cwd()
|
cwd: options.cwd || config.openclawStateDir || process.cwd()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,24 @@ import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
const defaultDataDir = path.join(process.cwd(), '.data')
|
const defaultDataDir = path.join(process.cwd(), '.data')
|
||||||
const openclawHome =
|
const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw')
|
||||||
|
const explicitOpenClawConfigPath =
|
||||||
|
process.env.OPENCLAW_CONFIG_PATH ||
|
||||||
|
process.env.MISSION_CONTROL_OPENCLAW_CONFIG_PATH ||
|
||||||
|
''
|
||||||
|
const legacyOpenClawHome =
|
||||||
process.env.OPENCLAW_HOME ||
|
process.env.OPENCLAW_HOME ||
|
||||||
process.env.CLAWDBOT_HOME ||
|
process.env.CLAWDBOT_HOME ||
|
||||||
process.env.MISSION_CONTROL_OPENCLAW_HOME ||
|
process.env.MISSION_CONTROL_OPENCLAW_HOME ||
|
||||||
''
|
''
|
||||||
|
const openclawStateDir =
|
||||||
|
process.env.OPENCLAW_STATE_DIR ||
|
||||||
|
process.env.CLAWDBOT_STATE_DIR ||
|
||||||
|
legacyOpenClawHome ||
|
||||||
|
(explicitOpenClawConfigPath ? path.dirname(explicitOpenClawConfigPath) : defaultOpenClawStateDir)
|
||||||
|
const openclawConfigPath =
|
||||||
|
explicitOpenClawConfigPath ||
|
||||||
|
path.join(openclawStateDir, 'openclaw.json')
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
claudeHome:
|
claudeHome:
|
||||||
|
|
@ -20,22 +33,25 @@ export const config = {
|
||||||
tokensPath:
|
tokensPath:
|
||||||
process.env.MISSION_CONTROL_TOKENS_PATH ||
|
process.env.MISSION_CONTROL_TOKENS_PATH ||
|
||||||
path.join(defaultDataDir, 'mission-control-tokens.json'),
|
path.join(defaultDataDir, 'mission-control-tokens.json'),
|
||||||
openclawHome,
|
// Keep openclawHome as a legacy alias for existing code paths.
|
||||||
|
openclawHome: openclawStateDir,
|
||||||
|
openclawStateDir,
|
||||||
|
openclawConfigPath,
|
||||||
openclawBin: process.env.OPENCLAW_BIN || 'openclaw',
|
openclawBin: process.env.OPENCLAW_BIN || 'openclaw',
|
||||||
clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot',
|
clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot',
|
||||||
gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1',
|
gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1',
|
||||||
gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'),
|
gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'),
|
||||||
logsDir:
|
logsDir:
|
||||||
process.env.OPENCLAW_LOG_DIR ||
|
process.env.OPENCLAW_LOG_DIR ||
|
||||||
(openclawHome ? path.join(openclawHome, 'logs') : ''),
|
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
|
||||||
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
|
||||||
memoryDir:
|
memoryDir:
|
||||||
process.env.OPENCLAW_MEMORY_DIR ||
|
process.env.OPENCLAW_MEMORY_DIR ||
|
||||||
(openclawHome ? path.join(openclawHome, 'memory') : '') ||
|
(openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
|
||||||
path.join(defaultDataDir, 'memory'),
|
path.join(defaultDataDir, 'memory'),
|
||||||
soulTemplatesDir:
|
soulTemplatesDir:
|
||||||
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
|
||||||
(openclawHome ? path.join(openclawHome, 'templates', 'souls') : ''),
|
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
|
||||||
homeDir: os.homedir(),
|
homeDir: os.homedir(),
|
||||||
// Data retention (days). 0 = keep forever.
|
// Data retention (days). 0 = keep forever.
|
||||||
retention: {
|
retention: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('DeviceIdentity')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response.
|
* Ed25519 device identity for OpenClaw gateway protocol v3 challenge-response.
|
||||||
*
|
*
|
||||||
|
|
@ -101,7 +105,7 @@ export async function getOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Stored key corrupted — regenerate
|
// Stored key corrupted — regenerate
|
||||||
console.warn('Device identity keys corrupted, regenerating...')
|
log.warn('Device identity keys corrupted, regenerating...')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,16 @@ export interface GatewaySession {
|
||||||
* Read all sessions from OpenClaw agent session stores on disk.
|
* Read all sessions from OpenClaw agent session stores on disk.
|
||||||
*
|
*
|
||||||
* OpenClaw stores sessions per-agent at:
|
* OpenClaw stores sessions per-agent at:
|
||||||
* {OPENCLAW_HOME}/agents/{agentName}/sessions/sessions.json
|
* {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
|
||||||
*
|
*
|
||||||
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
* Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
|
||||||
* with session metadata as values.
|
* with session metadata as values.
|
||||||
*/
|
*/
|
||||||
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
|
||||||
const openclawHome = config.openclawHome
|
const openclawStateDir = config.openclawStateDir
|
||||||
if (!openclawHome) return []
|
if (!openclawStateDir) return []
|
||||||
|
|
||||||
const agentsDir = path.join(openclawHome, 'agents')
|
const agentsDir = path.join(openclawStateDir, 'agents')
|
||||||
if (!fs.existsSync(agentsDir)) return []
|
if (!fs.existsSync(agentsDir)) return []
|
||||||
|
|
||||||
const sessions: GatewaySession[] = []
|
const sessions: GatewaySession[] = []
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useMissionControl } from '@/store'
|
import { useMissionControl } from '@/store'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('SSE')
|
||||||
|
|
||||||
interface ServerEvent {
|
interface ServerEvent {
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -73,7 +76,7 @@ export function useServerEvents() {
|
||||||
|
|
||||||
const attempts = sseReconnectAttemptsRef.current
|
const attempts = sseReconnectAttemptsRef.current
|
||||||
if (attempts >= SSE_MAX_RECONNECT_ATTEMPTS) {
|
if (attempts >= SSE_MAX_RECONNECT_ATTEMPTS) {
|
||||||
console.error(`SSE: max reconnect attempts (${SSE_MAX_RECONNECT_ATTEMPTS}) reached`)
|
log.error(`Max reconnect attempts (${SSE_MAX_RECONNECT_ATTEMPTS}) reached`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,7 +85,7 @@ export function useServerEvents() {
|
||||||
const delay = Math.round(base + Math.random() * base * 0.5)
|
const delay = Math.round(base + Math.random() * base * 0.5)
|
||||||
sseReconnectAttemptsRef.current = attempts + 1
|
sseReconnectAttemptsRef.current = attempts + 1
|
||||||
|
|
||||||
console.warn(`SSE: reconnecting in ${delay}ms (attempt ${attempts + 1}/${SSE_MAX_RECONNECT_ATTEMPTS})`)
|
log.warn(`Reconnecting in ${delay}ms (attempt ${attempts + 1}/${SSE_MAX_RECONNECT_ATTEMPTS})`)
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
if (mounted) connect()
|
if (mounted) connect()
|
||||||
}, delay)
|
}, delay)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@ import {
|
||||||
cacheDeviceToken,
|
cacheDeviceToken,
|
||||||
} from '@/lib/device-identity'
|
} from '@/lib/device-identity'
|
||||||
import { APP_VERSION } from '@/lib/version'
|
import { APP_VERSION } from '@/lib/version'
|
||||||
|
import { createClientLogger } from '@/lib/client-logger'
|
||||||
|
|
||||||
|
const log = createClientLogger('WebSocket')
|
||||||
|
|
||||||
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
// Gateway protocol version (v3 required by OpenClaw 2026.x)
|
||||||
const PROTOCOL_VERSION = 3
|
const PROTOCOL_VERSION = 3
|
||||||
|
|
@ -116,7 +119,7 @@ export function useWebSocket() {
|
||||||
|
|
||||||
// Check missed pongs
|
// Check missed pongs
|
||||||
if (missedPongsRef.current >= MAX_MISSED_PONGS) {
|
if (missedPongsRef.current >= MAX_MISSED_PONGS) {
|
||||||
console.warn(`Missed ${MAX_MISSED_PONGS} pongs, triggering reconnect`)
|
log.warn(`Missed ${MAX_MISSED_PONGS} pongs, triggering reconnect`)
|
||||||
addLog({
|
addLog({
|
||||||
id: `heartbeat-${Date.now()}`,
|
id: `heartbeat-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -213,7 +216,7 @@ export function useWebSocket() {
|
||||||
nonce,
|
nonce,
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Device identity unavailable, proceeding without:', err)
|
log.warn('Device identity unavailable, proceeding without:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,7 +242,7 @@ export function useWebSocket() {
|
||||||
deviceToken: cachedToken || undefined,
|
deviceToken: cachedToken || undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Sending connect handshake:', connectRequest)
|
log.info('Sending connect handshake')
|
||||||
ws.send(JSON.stringify(connectRequest))
|
ws.send(JSON.stringify(connectRequest))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -249,7 +252,7 @@ export function useWebSocket() {
|
||||||
|
|
||||||
// Debug logging for development
|
// Debug logging for development
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.log('WebSocket message received:', message.type, message)
|
log.debug(`Message received: ${message.type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
|
|
@ -319,24 +322,24 @@ export function useWebSocket() {
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown gateway message type:', message.type)
|
log.warn(`Unknown gateway message type: ${message.type}`)
|
||||||
}
|
}
|
||||||
}, [setLastMessage, setSessions, addLog, updateSpawnRequest, setCronJobs, addTokenUsage])
|
}, [setLastMessage, setSessions, addLog, updateSpawnRequest, setCronJobs, addTokenUsage])
|
||||||
|
|
||||||
// Handle gateway protocol frames
|
// Handle gateway protocol frames
|
||||||
const handleGatewayFrame = useCallback((frame: GatewayFrame, ws: WebSocket) => {
|
const handleGatewayFrame = useCallback((frame: GatewayFrame, ws: WebSocket) => {
|
||||||
console.log('Gateway frame:', frame)
|
log.debug(`Gateway frame: ${frame.type}`)
|
||||||
|
|
||||||
// Handle connect challenge
|
// Handle connect challenge
|
||||||
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
||||||
console.log('Received connect challenge, sending handshake...')
|
log.info('Received connect challenge, sending handshake')
|
||||||
sendConnectHandshake(ws, frame.payload?.nonce)
|
sendConnectHandshake(ws, frame.payload?.nonce)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle connect response (handshake success)
|
// Handle connect response (handshake success)
|
||||||
if (frame.type === 'res' && frame.ok && !handshakeCompleteRef.current) {
|
if (frame.type === 'res' && frame.ok && !handshakeCompleteRef.current) {
|
||||||
console.log('Handshake complete!')
|
log.info('Handshake complete')
|
||||||
handshakeCompleteRef.current = true
|
handshakeCompleteRef.current = true
|
||||||
reconnectAttemptsRef.current = 0
|
reconnectAttemptsRef.current = 0
|
||||||
// Cache device token if returned by gateway
|
// Cache device token if returned by gateway
|
||||||
|
|
@ -361,7 +364,7 @@ export function useWebSocket() {
|
||||||
|
|
||||||
// Handle connect error
|
// Handle connect error
|
||||||
if (frame.type === 'res' && !frame.ok) {
|
if (frame.type === 'res' && !frame.ok) {
|
||||||
console.error('Gateway error:', frame.error)
|
log.error(`Gateway error: ${frame.error?.message || JSON.stringify(frame.error)}`)
|
||||||
const rawMessage = frame.error?.message || JSON.stringify(frame.error)
|
const rawMessage = frame.error?.message || JSON.stringify(frame.error)
|
||||||
const help = getGatewayErrorHelp(rawMessage)
|
const help = getGatewayErrorHelp(rawMessage)
|
||||||
const nonRetryable = isNonRetryableGatewayError(rawMessage)
|
const nonRetryable = isNonRetryableGatewayError(rawMessage)
|
||||||
|
|
@ -510,14 +513,14 @@ export function useWebSocket() {
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected to', url.split('?')[0])
|
log.info(`Connected to ${url.split('?')[0]}`)
|
||||||
// Don't set isConnected yet - wait for handshake
|
// Don't set isConnected yet - wait for handshake
|
||||||
setConnection({
|
setConnection({
|
||||||
url: url.split('?')[0],
|
url: url.split('?')[0],
|
||||||
reconnectAttempts: 0
|
reconnectAttempts: 0
|
||||||
})
|
})
|
||||||
// Wait for connect.challenge from server
|
// Wait for connect.challenge from server
|
||||||
console.log('Waiting for connect challenge...')
|
log.debug('Waiting for connect challenge')
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
|
|
@ -525,7 +528,7 @@ export function useWebSocket() {
|
||||||
const frame = JSON.parse(event.data) as GatewayFrame
|
const frame = JSON.parse(event.data) as GatewayFrame
|
||||||
handleGatewayFrame(frame, ws)
|
handleGatewayFrame(frame, ws)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse WebSocket message:', error)
|
log.error('Failed to parse WebSocket message:', error)
|
||||||
addLog({
|
addLog({
|
||||||
id: `raw-${Date.now()}`,
|
id: `raw-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -537,7 +540,7 @@ export function useWebSocket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
console.log('Disconnected from Gateway:', event.code, event.reason)
|
log.info(`Disconnected from Gateway: ${event.code} ${event.reason}`)
|
||||||
setConnection({ isConnected: false })
|
setConnection({ isConnected: false })
|
||||||
handshakeCompleteRef.current = false
|
handshakeCompleteRef.current = false
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
|
|
@ -555,7 +558,7 @@ export function useWebSocket() {
|
||||||
if (attempts < maxReconnectAttempts) {
|
if (attempts < maxReconnectAttempts) {
|
||||||
const base = Math.min(Math.pow(2, attempts) * 1000, 30000)
|
const base = Math.min(Math.pow(2, attempts) * 1000, 30000)
|
||||||
const timeout = Math.round(base + Math.random() * base * 0.5)
|
const timeout = Math.round(base + Math.random() * base * 0.5)
|
||||||
console.log(`Reconnecting in ${timeout}ms... (attempt ${attempts + 1}/${maxReconnectAttempts})`)
|
log.info(`Reconnecting in ${timeout}ms (attempt ${attempts + 1}/${maxReconnectAttempts})`)
|
||||||
|
|
||||||
reconnectAttemptsRef.current = attempts + 1
|
reconnectAttemptsRef.current = attempts + 1
|
||||||
setConnection({ reconnectAttempts: attempts + 1 })
|
setConnection({ reconnectAttempts: attempts + 1 })
|
||||||
|
|
@ -563,7 +566,7 @@ export function useWebSocket() {
|
||||||
connectRef.current(reconnectUrl.current, authTokenRef.current)
|
connectRef.current(reconnectUrl.current, authTokenRef.current)
|
||||||
}, timeout)
|
}, timeout)
|
||||||
} else {
|
} else {
|
||||||
console.error('Max reconnection attempts reached.')
|
log.error('Max reconnection attempts reached')
|
||||||
addLog({
|
addLog({
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -575,7 +578,7 @@ export function useWebSocket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error)
|
log.error('WebSocket error:', error)
|
||||||
addLog({
|
addLog({
|
||||||
id: `error-${Date.now()}`,
|
id: `error-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
|
@ -586,7 +589,7 @@ export function useWebSocket() {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to connect to WebSocket:', error)
|
log.error('Failed to connect to WebSocket:', error)
|
||||||
setConnection({ isConnected: false })
|
setConnection({ isConnected: false })
|
||||||
}
|
}
|
||||||
}, [setConnection, handleGatewayFrame, addLog, stopHeartbeat])
|
}, [setConnection, handleGatewayFrame, addLog, stopHeartbeat])
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ pnpm dev --hostname 127.0.0.1 --port 3005
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pnpm test:e2e
|
pnpm test:e2e
|
||||||
|
|
||||||
|
# Run offline OpenClaw harness (no OpenClaw install required)
|
||||||
|
pnpm test:e2e:openclaw
|
||||||
|
|
||||||
# Run a specific spec
|
# Run a specific spec
|
||||||
pnpm exec playwright test tests/tasks-crud.spec.ts
|
pnpm exec playwright test tests/tasks-crud.spec.ts
|
||||||
```
|
```
|
||||||
|
|
@ -21,6 +24,17 @@ Tests require `.env.local` with:
|
||||||
- `API_KEY=test-api-key-e2e-12345`
|
- `API_KEY=test-api-key-e2e-12345`
|
||||||
- `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active)
|
- `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active)
|
||||||
|
|
||||||
|
## OpenClaw Offline Harness
|
||||||
|
|
||||||
|
The harness runs Mission Control against fixture data and mock binaries/gateway:
|
||||||
|
- fixtures: `tests/fixtures/openclaw/`
|
||||||
|
- mock CLI: `scripts/e2e-openclaw/bin/{openclaw,clawdbot}`
|
||||||
|
- mock gateway: `scripts/e2e-openclaw/mock-gateway.mjs`
|
||||||
|
|
||||||
|
Profiles:
|
||||||
|
- `pnpm test:e2e:openclaw:local` - local mode (gateway not running)
|
||||||
|
- `pnpm test:e2e:openclaw:gateway` - gateway mode (mock gateway running)
|
||||||
|
|
||||||
## Spec Files
|
## Spec Files
|
||||||
|
|
||||||
### Security & Auth
|
### Security & Auth
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"agent:engineering-bot:main": {
|
||||||
|
"sessionId": "sess-eng-main",
|
||||||
|
"updatedAt": 4102444800000,
|
||||||
|
"chatType": "dm",
|
||||||
|
"lastChannel": "engineering",
|
||||||
|
"model": {
|
||||||
|
"primary": "openai/gpt-5"
|
||||||
|
},
|
||||||
|
"totalTokens": 24500,
|
||||||
|
"inputTokens": 16100,
|
||||||
|
"outputTokens": 8400,
|
||||||
|
"contextTokens": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"agent:research-bot:main": {
|
||||||
|
"sessionId": "sess-research-main",
|
||||||
|
"updatedAt": 4102444700000,
|
||||||
|
"chatType": "dm",
|
||||||
|
"lastChannel": "research",
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-sonnet-4-5"
|
||||||
|
},
|
||||||
|
"totalTokens": 12800,
|
||||||
|
"inputTokens": 8100,
|
||||||
|
"outputTokens": 4700,
|
||||||
|
"contextTokens": 64000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"jobs": [
|
||||||
|
{
|
||||||
|
"id": "job-eng-daily",
|
||||||
|
"agentId": "engineering-bot",
|
||||||
|
"name": "daily engineering standup",
|
||||||
|
"enabled": true,
|
||||||
|
"createdAtMs": 1762550000000,
|
||||||
|
"updatedAtMs": 1762550600000,
|
||||||
|
"schedule": {
|
||||||
|
"kind": "cron",
|
||||||
|
"expr": "0 9 * * 1-5",
|
||||||
|
"tz": "UTC"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"kind": "message",
|
||||||
|
"message": "Post daily engineering summary",
|
||||||
|
"model": "openai/gpt-5"
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"mode": "dm",
|
||||||
|
"channel": "engineering"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"nextRunAtMs": 1762602000000,
|
||||||
|
"lastRunAtMs": 1762515600000,
|
||||||
|
"lastStatus": "success"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "job-research-hourly",
|
||||||
|
"agentId": "research-bot",
|
||||||
|
"name": "hourly trend scan",
|
||||||
|
"enabled": true,
|
||||||
|
"createdAtMs": 1762551000000,
|
||||||
|
"updatedAtMs": 1762551000000,
|
||||||
|
"schedule": {
|
||||||
|
"kind": "cron",
|
||||||
|
"expr": "0 * * * *",
|
||||||
|
"tz": "UTC"
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"kind": "message",
|
||||||
|
"message": "Scan latest market/AI trends",
|
||||||
|
"model": "anthropic/claude-sonnet-4-5"
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"mode": "dm",
|
||||||
|
"channel": "research"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"nextRunAtMs": 1762594800000,
|
||||||
|
"lastRunAtMs": 1762591200000,
|
||||||
|
"lastStatus": "success"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"gateway": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 18789
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "workspaces"
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "engineering-bot",
|
||||||
|
"name": "engineering-bot",
|
||||||
|
"default": true,
|
||||||
|
"workspace": "workspaces/engineering-bot",
|
||||||
|
"agentDir": "agents/engineering-bot",
|
||||||
|
"identity": {
|
||||||
|
"name": "Engineering Bot",
|
||||||
|
"theme": "software-engineer",
|
||||||
|
"emoji": ":gear:"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"primary": "openai/gpt-5"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"allow": ["sessions_send", "sessions_history", "shell"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "research-bot",
|
||||||
|
"name": "research-bot",
|
||||||
|
"workspace": "workspaces/research-bot",
|
||||||
|
"agentDir": "agents/research-bot",
|
||||||
|
"identity": {
|
||||||
|
"name": "Research Bot",
|
||||||
|
"theme": "analyst",
|
||||||
|
"emoji": ":book:"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"primary": "anthropic/claude-sonnet-4-5"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"allow": ["web", "sessions_send"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
- `shell`
|
||||||
|
- `sessions_send`
|
||||||
|
- `sessions_history`
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Engineering Bot
|
||||||
|
theme: software-engineer
|
||||||
|
emoji: ⚙️
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Engineering Bot
|
||||||
|
Focus on implementation quality, safe migrations, and test coverage.
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
- `web`
|
||||||
|
- `sessions_send`
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Research Bot
|
||||||
|
theme: analyst
|
||||||
|
emoji: 📚
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Research Bot
|
||||||
|
Focus on evidence, citations, and concise recommendations.
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { API_KEY_HEADER } from './helpers'
|
||||||
|
|
||||||
|
const EXPECT_GATEWAY = process.env.E2E_GATEWAY_EXPECTED === '1'
|
||||||
|
|
||||||
|
test.describe('OpenClaw Offline Harness', () => {
|
||||||
|
test('capabilities expose OpenClaw state dir/config in offline test mode', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/status?action=capabilities', {
|
||||||
|
headers: API_KEY_HEADER,
|
||||||
|
})
|
||||||
|
expect(res.status()).toBe(200)
|
||||||
|
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.openclawHome).toBe(true)
|
||||||
|
expect(Boolean(body.claudeHome)).toBeTruthy()
|
||||||
|
expect(Boolean(body.gateway)).toBe(EXPECT_GATEWAY)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sessions API reads fixture sessions without OpenClaw install', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/sessions', {
|
||||||
|
headers: API_KEY_HEADER,
|
||||||
|
})
|
||||||
|
expect(res.status()).toBe(200)
|
||||||
|
|
||||||
|
const body = await res.json()
|
||||||
|
expect(Array.isArray(body.sessions)).toBe(true)
|
||||||
|
expect(body.sessions.length).toBeGreaterThan(0)
|
||||||
|
expect(body.sessions[0]).toHaveProperty('agent')
|
||||||
|
expect(body.sessions[0]).toHaveProperty('tokens')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cron API reads fixture jobs', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/cron?action=list', {
|
||||||
|
headers: API_KEY_HEADER,
|
||||||
|
})
|
||||||
|
expect(res.status()).toBe(200)
|
||||||
|
|
||||||
|
const body = await res.json()
|
||||||
|
expect(Array.isArray(body.jobs)).toBe(true)
|
||||||
|
expect(body.jobs.length).toBeGreaterThan(0)
|
||||||
|
expect(body.jobs[0]).toHaveProperty('name')
|
||||||
|
expect(body.jobs[0]).toHaveProperty('schedule')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('gateway config API reads fixture config', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/gateway-config', {
|
||||||
|
headers: API_KEY_HEADER,
|
||||||
|
})
|
||||||
|
expect(res.status()).toBe(200)
|
||||||
|
|
||||||
|
const body = await res.json()
|
||||||
|
expect(typeof body.path).toBe('string')
|
||||||
|
expect(body.path.endsWith('openclaw.json')).toBe(true)
|
||||||
|
expect(body.config).toHaveProperty('agents')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue