diff --git a/README.md b/README.md index 5b3cdeb..dc4ab10 100644 --- a/README.md +++ b/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_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) | | `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_PORT` | No | Gateway WebSocket port (default: `18789`) | | `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_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 -> `$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 > `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show > daily logs, `MEMORY.md`, and other markdown files: @@ -365,6 +367,32 @@ See [`.env.example`](.env.example) for the complete list. Key variables: > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents > ``` +### Workspace Creation Flow + +To add a new workspace/client instance in the UI: + +1. Open `Workspaces` from the left navigation. +2. Expand `Show Create Client Instance`. +3. Fill tenant/workspace fields (`slug`, `display_name`, optional ports/gateway owner). +4. Click `Create + Queue`. +5. Approve/run the generated provisioning job in the same panel. + +`Workspaces` and `Super Admin` currently point to the same provisioning control plane. + +### Projects and Ticket Prefixes + +Mission Control supports multi-project task organization per workspace: + +- Create/manage projects via Task Board → `Projects`. +- Each project has its own ticket prefix and counter. +- New tasks receive project-scoped ticket refs like `PA-001`, `PA-002`. +- Task board supports filtering by project. + +### Memory Scope Clarification + +- **Agent profile → Memory tab**: per-agent working memory stored in Mission Control DB (`working_memory`). +- **Memory Browser page**: workspace/local filesystem memory tree under `OPENCLAW_MEMORY_DIR`. + ## Deployment ```bash @@ -373,7 +401,7 @@ pnpm install --frozen-lockfile pnpm build # 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. diff --git a/package.json b/package.json index 7cb7289..25e8136 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:watch": "vitest", "test:ui": "vitest --ui", "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", "quality:gate": "pnpm test:all" }, @@ -77,4 +80,4 @@ "better-sqlite3" ] } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index 9a97815..f50bcea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: 'tests', + testIgnore: /openclaw-harness\.spec\.ts/, timeout: 60_000, expect: { timeout: 10_000 diff --git a/playwright.openclaw.gateway.config.ts b/playwright.openclaw.gateway.config.ts new file mode 100644 index 0000000..8df49dc --- /dev/null +++ b/playwright.openclaw.gateway.config.ts @@ -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, + }, +}) diff --git a/playwright.openclaw.local.config.ts b/playwright.openclaw.local.config.ts new file mode 100644 index 0000000..ec80cf2 --- /dev/null +++ b/playwright.openclaw.local.config.ts @@ -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, + }, +}) diff --git a/scripts/e2e-openclaw/bin/clawdbot b/scripts/e2e-openclaw/bin/clawdbot new file mode 100755 index 0000000..0aa1e80 --- /dev/null +++ b/scripts/e2e-openclaw/bin/clawdbot @@ -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 diff --git a/scripts/e2e-openclaw/bin/openclaw b/scripts/e2e-openclaw/bin/openclaw new file mode 100755 index 0000000..38c4a1c --- /dev/null +++ b/scripts/e2e-openclaw/bin/openclaw @@ -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 diff --git a/scripts/e2e-openclaw/mock-gateway.mjs b/scripts/e2e-openclaw/mock-gateway.mjs new file mode 100755 index 0000000..e3ff063 --- /dev/null +++ b/scripts/e2e-openclaw/mock-gateway.mjs @@ -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) diff --git a/scripts/e2e-openclaw/start-e2e-server.mjs b/scripts/e2e-openclaw/start-e2e-server.mjs new file mode 100755 index 0000000..35703b0 --- /dev/null +++ b/scripts/e2e-openclaw/start-e2e-server.mjs @@ -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) +}) diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index e6358eb..0b0dcaa 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -261,6 +261,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'super-admin': return + case 'workspaces': + return default: return } diff --git a/src/app/api/agents/[id]/memory/route.ts b/src/app/api/agents/[id]/memory/route.ts index db5ef30..56277b2 100644 --- a/src/app/api/agents/[id]/memory/route.ts +++ b/src/app/api/agents/[id]/memory/route.ts @@ -6,8 +6,8 @@ import { logger } from '@/lib/logger'; /** * GET /api/agents/[id]/memory - Get agent's working memory * - * Working memory is stored as WORKING.md content in the database - * Each agent has their own working memory space for temporary notes + * Working memory is stored in the agents.working_memory DB column. + * This endpoint is per-agent scratchpad memory (not the global Memory Browser filesystem view). */ export async function GET( request: NextRequest, diff --git a/src/app/api/agents/[id]/soul/route.ts b/src/app/api/agents/[id]/soul/route.ts index 061472a..5e506ad 100644 --- a/src/app/api/agents/[id]/soul/route.ts +++ b/src/app/api/agents/[id]/soul/route.ts @@ -1,12 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { getDatabase, db_helpers } from '@/lib/db'; 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 { resolveWithin } from '@/lib/paths'; import { requireRole } from '@/lib/auth'; 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 */ @@ -41,8 +47,8 @@ export async function GET( try { const agentConfig = agent.config ? JSON.parse(agent.config) : {} - if (agentConfig.workspace && config.openclawHome) { - const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace) + if (agentConfig.workspace) { + const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace) const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md') if (existsSync(safeSoulPath)) { soulContent = readFileSync(safeSoulPath, 'utf-8') @@ -157,8 +163,8 @@ export async function PUT( let savedToWorkspace = false try { const agentConfig = agent.config ? JSON.parse(agent.config) : {} - if (agentConfig.workspace && config.openclawHome) { - const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace) + if (agentConfig.workspace) { + const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace) const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md') mkdirSync(dirname(safeSoulPath), { recursive: true }) writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8') diff --git a/src/app/api/cron/route.ts b/src/app/api/cron/route.ts index e25ccd4..8973ab8 100644 --- a/src/app/api/cron/route.ts +++ b/src/app/api/cron/route.ts @@ -67,9 +67,9 @@ interface OpenClawCronFile { } function getCronFilePath(): string { - const openclawHome = config.openclawHome - if (!openclawHome) return '' - return path.join(openclawHome, 'cron', 'jobs.json') + const openclawStateDir = config.openclawStateDir + if (!openclawStateDir) return '' + return path.join(openclawStateDir, 'cron', 'jobs.json') } async function loadCronFile(): Promise { diff --git a/src/app/api/gateway-config/route.ts b/src/app/api/gateway-config/route.ts index 7fa1e43..9aac4d0 100644 --- a/src/app/api/gateway-config/route.ts +++ b/src/app/api/gateway-config/route.ts @@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' import { logAuditEvent } from '@/lib/db' import { config } from '@/lib/config' -import { join } from 'path' import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation' import { mutationLimiter } from '@/lib/rate-limit' function getConfigPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, 'openclaw.json') + return config.openclawConfigPath || null } /** @@ -20,7 +18,7 @@ export async function GET(request: NextRequest) { const configPath = getConfigPath() if (!configPath) { - return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 }) + return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 }) } try { @@ -60,7 +58,7 @@ export async function PUT(request: NextRequest) { const configPath = getConfigPath() 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) diff --git a/src/app/api/integrations/route.ts b/src/app/api/integrations/route.ts index 1a1f4a7..921d1c9 100644 --- a/src/app/api/integrations/route.ts +++ b/src/app/api/integrations/route.ts @@ -108,8 +108,8 @@ function serializeEnv(lines: EnvLine[]): string { } function getEnvPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, '.env') + if (!config.openclawStateDir) return null + return join(config.openclawStateDir, '.env') } async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> { @@ -184,7 +184,7 @@ export async function GET(request: NextRequest) { const envData = await readEnvFile() 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() @@ -261,7 +261,7 @@ export async function PUT(request: NextRequest) { const envData = await readEnvFile() 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 @@ -324,7 +324,7 @@ export async function DELETE(request: NextRequest) { const envData = await readEnvFile() 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[] = [] @@ -408,7 +408,7 @@ async function handleTest( const envData = await readEnvFile() 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() @@ -552,7 +552,7 @@ async function handlePull( // Write to .env const envData = await readEnvFile() 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 @@ -621,7 +621,7 @@ async function handlePullAll( const envData = await readEnvFile() 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 diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..3fe1832 --- /dev/null +++ b/src/app/api/projects/[id]/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { mutationLimiter } from '@/lib/rate-limit' +import { logger } from '@/lib/logger' + +function normalizePrefix(input: string): string { + const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '') + return normalized.slice(0, 12) +} + +function toProjectId(raw: string): number { + const id = Number.parseInt(raw, 10) + return Number.isFinite(id) ? id : NaN +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + + return NextResponse.json({ project }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 }) + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any + if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + if (current.slug === 'general' && current.workspace_id === workspaceId && current.id === projectId) { + const body = await request.json() + if (body?.status === 'archived') { + return NextResponse.json({ error: 'Default project cannot be archived' }, { status: 400 }) + } + } + + const body = await request.json() + const updates: string[] = [] + const paramsList: Array = [] + + if (typeof body?.name === 'string') { + const name = body.name.trim() + if (!name) return NextResponse.json({ error: 'Project name cannot be empty' }, { status: 400 }) + updates.push('name = ?') + paramsList.push(name) + } + if (typeof body?.description === 'string') { + updates.push('description = ?') + paramsList.push(body.description.trim() || null) + } + if (typeof body?.ticket_prefix === 'string' || typeof body?.ticketPrefix === 'string') { + const raw = String(body.ticket_prefix ?? body.ticketPrefix) + const prefix = normalizePrefix(raw) + if (!prefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 }) + const conflict = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND ticket_prefix = ? AND id != ? + `).get(workspaceId, prefix, projectId) + if (conflict) return NextResponse.json({ error: 'Ticket prefix already in use' }, { status: 409 }) + updates.push('ticket_prefix = ?') + paramsList.push(prefix) + } + if (typeof body?.status === 'string') { + const status = body.status === 'archived' ? 'archived' : 'active' + updates.push('status = ?') + paramsList.push(status) + } + + if (updates.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + + updates.push('updated_at = unixepoch()') + db.prepare(` + UPDATE projects + SET ${updates.join(', ')} + WHERE id = ? AND workspace_id = ? + `).run(...paramsList, projectId, workspaceId) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + + return NextResponse.json({ project }) + } catch (error) { + logger.error({ err: error }, 'PATCH /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to update project' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'admin') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any + if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + if (current.slug === 'general') { + return NextResponse.json({ error: 'Default project cannot be deleted' }, { status: 400 }) + } + + const mode = new URL(request.url).searchParams.get('mode') || 'archive' + if (mode !== 'delete') { + db.prepare(`UPDATE projects SET status = 'archived', updated_at = unixepoch() WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId) + return NextResponse.json({ success: true, mode: 'archive' }) + } + + const fallback = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND slug = 'general' + LIMIT 1 + `).get(workspaceId) as { id: number } | undefined + if (!fallback) return NextResponse.json({ error: 'Default project missing' }, { status: 500 }) + + const tx = db.transaction(() => { + db.prepare(` + UPDATE tasks + SET project_id = ? + WHERE workspace_id = ? AND project_id = ? + `).run(fallback.id, workspaceId, projectId) + + db.prepare(`DELETE FROM projects WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId) + }) + tx() + + return NextResponse.json({ success: true, mode: 'delete' }) + } catch (error) { + logger.error({ err: error }, 'DELETE /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/[id]/tasks/route.ts b/src/app/api/projects/[id]/tasks/route.ts new file mode 100644 index 0000000..ada6891 --- /dev/null +++ b/src/app/api/projects/[id]/tasks/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { logger } from '@/lib/logger' + +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = Number.parseInt(id, 10) + if (!Number.isFinite(projectId)) { + return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + } + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + + const tasks = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.workspace_id = ? AND t.project_id = ? + ORDER BY t.created_at DESC + `).all(workspaceId, projectId) + + return NextResponse.json({ + project, + tasks: tasks.map((task: any) => ({ + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + })) + }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects/[id]/tasks error') + return NextResponse.json({ error: 'Failed to fetch project tasks' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..177fb83 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { mutationLimiter } from '@/lib/rate-limit' +import { logger } from '@/lib/logger' + +function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) +} + +function normalizePrefix(input: string): string { + const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '') + return normalized.slice(0, 12) +} + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const includeArchived = new URL(request.url).searchParams.get('includeArchived') === '1' + + const projects = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE workspace_id = ? + ${includeArchived ? '' : "AND status = 'active'"} + ORDER BY name COLLATE NOCASE ASC + `).all(workspaceId) + + return NextResponse.json({ projects }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects error') + return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const body = await request.json() + + const name = String(body?.name || '').trim() + const description = typeof body?.description === 'string' ? body.description.trim() : '' + const prefixInput = String(body?.ticket_prefix || body?.ticketPrefix || '').trim() + const slugInput = String(body?.slug || '').trim() + + if (!name) return NextResponse.json({ error: 'Project name is required' }, { status: 400 }) + + const slug = slugInput ? slugify(slugInput) : slugify(name) + const ticketPrefix = normalizePrefix(prefixInput || name.slice(0, 5)) + if (!slug) return NextResponse.json({ error: 'Invalid project slug' }, { status: 400 }) + if (!ticketPrefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 }) + + const exists = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND (slug = ? OR ticket_prefix = ?) + LIMIT 1 + `).get(workspaceId, slug, ticketPrefix) as { id: number } | undefined + if (exists) { + return NextResponse.json({ error: 'Project slug or ticket prefix already exists' }, { status: 409 }) + } + + const result = db.prepare(` + INSERT INTO projects (workspace_id, name, slug, description, ticket_prefix, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', unixepoch(), unixepoch()) + `).run(workspaceId, name, slug, description || null, ticketPrefix) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? + `).get(Number(result.lastInsertRowid)) + + return NextResponse.json({ project }, { status: 201 }) + } catch (error) { + logger.error({ err: error }, 'POST /api/projects error') + return NextResponse.json({ error: 'Failed to create project' }, { status: 500 }) + } +} diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 3c286d5..d26886b 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -475,7 +475,10 @@ async function performHealthCheck() { async function getCapabilities() { 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 claudeHome = existsSync(claudeProjectsPath) diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 1e5fd47..7bd79c3 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -6,6 +6,20 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record } { + return { + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + } +} + function hasAegisApproval( db: ReturnType, taskId: number, @@ -40,7 +54,12 @@ export async function GET( return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); } - const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?'); + const stmt = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `); const task = stmt.get(taskId, workspaceId) as Task; if (!task) { @@ -48,11 +67,7 @@ export async function GET( } // Parse JSON fields - const taskWithParsedData = { - ...task, - tags: task.tags ? JSON.parse(task.tags) : [], - metadata: task.metadata ? JSON.parse(task.metadata) : {} - }; + const taskWithParsedData = mapTaskRow(task); return NextResponse.json({ task: taskWithParsedData }); } catch (error) { @@ -101,6 +116,7 @@ export async function PUT( description, status, priority, + project_id, assigned_to, due_date, estimated_hours, @@ -114,6 +130,7 @@ export async function PUT( // Build dynamic update query const fieldsToUpdate = []; const updateParams: any[] = []; + let nextProjectTicketNo: number | null = null; if (title !== undefined) { fieldsToUpdate.push('title = ?'); @@ -137,6 +154,36 @@ export async function PUT( fieldsToUpdate.push('priority = ?'); updateParams.push(priority); } + if (project_id !== undefined) { + const project = db.prepare(` + SELECT id FROM projects + WHERE id = ? AND workspace_id = ? AND status = 'active' + `).get(project_id, workspaceId) as { id: number } | undefined + if (!project) { + return NextResponse.json({ error: 'Project not found or archived' }, { status: 400 }) + } + if (project_id !== currentTask.project_id) { + db.prepare(` + UPDATE projects + SET ticket_counter = ticket_counter + 1, updated_at = unixepoch() + WHERE id = ? AND workspace_id = ? + `).run(project_id, workspaceId) + const row = db.prepare(` + SELECT ticket_counter FROM projects + WHERE id = ? AND workspace_id = ? + `).get(project_id, workspaceId) as { ticket_counter: number } | undefined + if (!row || !row.ticket_counter) { + return NextResponse.json({ error: 'Failed to allocate project ticket number' }, { status: 500 }) + } + nextProjectTicketNo = row.ticket_counter + } + fieldsToUpdate.push('project_id = ?'); + updateParams.push(project_id); + if (nextProjectTicketNo !== null) { + fieldsToUpdate.push('project_ticket_no = ?'); + updateParams.push(nextProjectTicketNo); + } + } if (assigned_to !== undefined) { fieldsToUpdate.push('assigned_to = ?'); updateParams.push(assigned_to); @@ -223,6 +270,10 @@ export async function PUT( if (priority && priority !== currentTask.priority) { changes.push(`priority: ${currentTask.priority} → ${priority}`); } + + if (project_id !== undefined && project_id !== currentTask.project_id) { + changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`); + } // Log activity if there were meaningful changes if (changes.length > 0) { @@ -247,14 +298,13 @@ export async function PUT( } // Fetch updated task - const updatedTask = db - .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') - .get(taskId, workspaceId) as Task; - const parsedTask = { - ...updatedTask, - tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [], - metadata: updatedTask.metadata ? JSON.parse(updatedTask.metadata) : {} - }; + const updatedTask = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `).get(taskId, workspaceId) as Task; + const parsedTask = mapTaskRow(updatedTask); // Broadcast to SSE clients eventBus.broadcast('task.updated', parsedTask); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index ec04cc9..e8f20cf 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -6,6 +6,43 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record } { + return { + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + } +} + +function resolveProjectId(db: ReturnType, workspaceId: number, requestedProjectId?: number): number { + if (typeof requestedProjectId === 'number' && Number.isFinite(requestedProjectId)) { + const project = db.prepare(` + SELECT id FROM projects + WHERE id = ? AND workspace_id = ? AND status = 'active' + LIMIT 1 + `).get(requestedProjectId, workspaceId) as { id: number } | undefined + if (project) return project.id + } + + const fallback = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND status = 'active' + ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, id ASC + LIMIT 1 + `).get(workspaceId) as { id: number } | undefined + + if (!fallback) { + throw new Error('No active project available in workspace') + } + return fallback.id +} + function hasAegisApproval(db: ReturnType, taskId: number, workspaceId: number): boolean { const review = db.prepare(` SELECT status FROM quality_reviews @@ -18,7 +55,7 @@ function hasAegisApproval(db: ReturnType, taskId: number, wo /** * GET /api/tasks - List all tasks with optional filtering - * Query params: status, assigned_to, priority, limit, offset + * Query params: status, assigned_to, priority, project_id, limit, offset */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer'); @@ -33,40 +70,48 @@ export async function GET(request: NextRequest) { const status = searchParams.get('status'); const assigned_to = searchParams.get('assigned_to'); const priority = searchParams.get('priority'); + const projectIdParam = Number.parseInt(searchParams.get('project_id') || '', 10); const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); const offset = parseInt(searchParams.get('offset') || '0'); // Build dynamic query - let query = 'SELECT * FROM tasks WHERE workspace_id = ?'; + let query = ` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p + ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.workspace_id = ? + `; const params: any[] = [workspaceId]; if (status) { - query += ' AND status = ?'; + query += ' AND t.status = ?'; params.push(status); } if (assigned_to) { - query += ' AND assigned_to = ?'; + query += ' AND t.assigned_to = ?'; params.push(assigned_to); } if (priority) { - query += ' AND priority = ?'; + query += ' AND t.priority = ?'; params.push(priority); } + + if (Number.isFinite(projectIdParam)) { + query += ' AND t.project_id = ?'; + params.push(projectIdParam); + } - query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + query += ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); const stmt = db.prepare(query); const tasks = stmt.all(...params) as Task[]; // Parse JSON fields - const tasksWithParsedData = tasks.map(task => ({ - ...task, - tags: task.tags ? JSON.parse(task.tags) : [], - metadata: task.metadata ? JSON.parse(task.metadata) : {} - })); + const tasksWithParsedData = tasks.map(mapTaskRow); // Get total count for pagination let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?'; @@ -83,6 +128,10 @@ export async function GET(request: NextRequest) { countQuery += ' AND priority = ?'; countParams.push(priority); } + if (Number.isFinite(projectIdParam)) { + countQuery += ' AND project_id = ?'; + countParams.push(projectIdParam); + } const countRow = db.prepare(countQuery).get(...countParams) as { total: number }; return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit }); @@ -115,6 +164,7 @@ export async function POST(request: NextRequest) { description, status = 'inbox', priority = 'medium', + project_id, assigned_to, created_by = user?.username || 'system', due_date, @@ -130,31 +180,47 @@ export async function POST(request: NextRequest) { } const now = Math.floor(Date.now() / 1000); - - const stmt = db.prepare(` - INSERT INTO tasks ( - title, description, status, priority, assigned_to, created_by, - created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - const dbResult = stmt.run( - title, - description, - status, - priority, - assigned_to, - created_by, - now, - now, - due_date, - estimated_hours, - JSON.stringify(tags), - JSON.stringify(metadata), - workspaceId - ); + const createTaskTx = db.transaction(() => { + const resolvedProjectId = resolveProjectId(db, workspaceId, project_id) + db.prepare(` + UPDATE projects + SET ticket_counter = ticket_counter + 1, updated_at = unixepoch() + WHERE id = ? AND workspace_id = ? + `).run(resolvedProjectId, workspaceId) + const row = db.prepare(` + SELECT ticket_counter FROM projects + WHERE id = ? AND workspace_id = ? + `).get(resolvedProjectId, workspaceId) as { ticket_counter: number } | undefined + if (!row || !row.ticket_counter) throw new Error('Failed to allocate project ticket number') - const taskId = dbResult.lastInsertRowid as number; + const insertStmt = db.prepare(` + INSERT INTO tasks ( + title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by, + created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const dbResult = insertStmt.run( + title, + description, + status, + priority, + resolvedProjectId, + row.ticket_counter, + assigned_to, + created_by, + now, + now, + due_date, + estimated_hours, + JSON.stringify(tags), + JSON.stringify(metadata), + workspaceId + ) + return Number(dbResult.lastInsertRowid) + }) + + const taskId = createTaskTx() // Log activity db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, { @@ -183,12 +249,14 @@ export async function POST(request: NextRequest) { } // Fetch the created task - const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task; - const parsedTask = { - ...createdTask, - tags: JSON.parse(createdTask.tags || '[]'), - metadata: JSON.parse(createdTask.metadata || '{}') - }; + const createdTask = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p + ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `).get(taskId, workspaceId) as Task; + const parsedTask = mapTaskRow(createdTask); // Broadcast to SSE clients eventBus.broadcast('task.created', parsedTask); diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx index 8d38cfb..e0aa3a3 100644 --- a/src/components/layout/header-bar.tsx +++ b/src/components/layout/header-bar.tsx @@ -43,6 +43,7 @@ export function HeaderBar() { alerts: 'Alert Rules', gateways: 'Gateway Manager', users: 'Users', + workspaces: 'Workspaces', 'gateway-config': 'Gateway Config', settings: 'Settings', } diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index c208078..996e004 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -61,6 +61,7 @@ const navGroups: NavGroup[] = [ { id: 'gateways', label: 'Gateways', icon: , priority: false }, { id: 'gateway-config', label: 'Config', icon: , priority: false, requiresGateway: true }, { id: 'integrations', label: 'Integrations', icon: , priority: false }, + { id: 'workspaces', label: 'Workspaces', icon: , priority: false }, { id: 'super-admin', label: 'Super Admin', icon: , priority: false }, { id: 'settings', label: 'Settings', icon: , priority: false }, ], diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 09f8180..ee1973b 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react' import { createClientLogger } from '@/lib/client-logger' -const log = createClientLogger('AgentDetailTabs') import Link from 'next/link' +const log = createClientLogger('AgentDetailTabs') interface Agent { id: number @@ -672,7 +672,10 @@ export function TasksTab({ agent }: { agent: Agent }) { {task.title} -
Task #{task.id}
+
+ {task.ticket_ref || `Task #${task.id}`} + {task.project_name ? ` · ${task.project_name}` : ''} +
{task.description && (

{task.description}

)} diff --git a/src/components/panels/gateway-config-panel.tsx b/src/components/panels/gateway-config-panel.tsx index 3ddff4f..9daf69b 100644 --- a/src/components/panels/gateway-config-panel.tsx +++ b/src/components/panels/gateway-config-panel.tsx @@ -113,7 +113,7 @@ export function GatewayConfigPanel() {
{error}

- 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.

) diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 22651b2..6401e2c 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -30,6 +30,11 @@ interface Task { tags?: string[] metadata?: any aegisApproved?: boolean + project_id?: number + project_ticket_no?: number + project_name?: string + project_prefix?: string + ticket_ref?: string } interface Agent { @@ -56,6 +61,14 @@ interface Comment { replies?: Comment[] } +interface Project { + id: number + name: string + slug: string + ticket_prefix: string + status: 'active' | 'archived' +} + const statusColumns = [ { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' }, { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' }, @@ -78,11 +91,14 @@ export function TaskBoardPanel() { const pathname = usePathname() const searchParams = useSearchParams() const [agents, setAgents] = useState([]) + const [projects, setProjects] = useState([]) + const [projectFilter, setProjectFilter] = useState('all') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [aegisMap, setAegisMap] = useState>({}) const [draggedTask, setDraggedTask] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) + const [showProjectManager, setShowProjectManager] = useState(false) const [editingTask, setEditingTask] = useState(null) const dragCounter = useRef(0) const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10) @@ -109,23 +125,31 @@ export function TaskBoardPanel() { aegisApproved: Boolean(aegisMap[t.id]) })) - // Fetch tasks and agents + // Fetch tasks, agents, and projects const fetchData = useCallback(async () => { try { setLoading(true) setError(null) - const [tasksResponse, agentsResponse] = await Promise.all([ - fetch('/api/tasks'), - fetch('/api/agents') + const tasksQuery = new URLSearchParams() + if (projectFilter !== 'all') { + tasksQuery.set('project_id', projectFilter) + } + const tasksUrl = tasksQuery.toString() ? `/api/tasks?${tasksQuery.toString()}` : '/api/tasks' + + const [tasksResponse, agentsResponse, projectsResponse] = await Promise.all([ + fetch(tasksUrl), + fetch('/api/agents'), + fetch('/api/projects') ]) - if (!tasksResponse.ok || !agentsResponse.ok) { + if (!tasksResponse.ok || !agentsResponse.ok || !projectsResponse.ok) { throw new Error('Failed to fetch data') } const tasksData = await tasksResponse.json() const agentsData = await agentsResponse.json() + const projectsData = await projectsResponse.json() const tasksList = tasksData.tasks || [] const taskIds = tasksList.map((task: Task) => task.id) @@ -152,12 +176,13 @@ export function TaskBoardPanel() { storeSetTasks(tasksList) setAegisMap(newAegisMap) setAgents(agentsData.agents || []) + setProjects(projectsData.projects || []) } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred') } finally { setLoading(false) } - }, [storeSetTasks]) + }, [projectFilter, storeSetTasks]) useEffect(() => { fetchData() @@ -327,8 +352,28 @@ export function TaskBoardPanel() {
{/* Header */}
-

Task Board

+
+

Task Board

+ +
+
+
+ + +
+
setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="Project name" + className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + required + /> + setForm((prev) => ({ ...prev, ticket_prefix: e.target.value }))} + placeholder="Ticket prefix (e.g. PA)" + className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + /> + + setForm((prev) => ({ ...prev, description: e.target.value }))} + placeholder="Description (optional)" + className="md:col-span-3 bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + /> + + + {loading ? ( +
Loading projects...
+ ) : ( +
+ {projects.map((project) => ( +
+
+
{project.name}
+
{project.ticket_prefix} · {project.slug} · {project.status}
+
+
+ {project.slug !== 'general' && ( + <> + + + + )} +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/index.ts b/src/index.ts index e9861ad..54c36f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,11 @@ export interface Task { description?: string status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' priority: 'low' | 'medium' | 'high' | 'urgent' + project_id?: number + project_ticket_no?: number + project_name?: string + project_prefix?: string + ticket_ref?: string assigned_to?: string created_by: string created_at: number diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 57a91ae..e0d6416 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -8,7 +8,7 @@ import { config } from './config' import { getDatabase, db_helpers, logAuditEvent } from './db' import { eventBus } from './event-bus' -import { join } from 'path' +import { join, isAbsolute, resolve } from 'path' import { existsSync, readFileSync } from 'fs' import { resolveWithin } from './paths' import { logger } from './logger' @@ -127,15 +127,22 @@ function parseToolsFromFile(content: string): { allow?: string[]; raw?: string } } function getConfigPath(): string | null { - if (!config.openclawHome) return null - return join(config.openclawHome, 'openclaw.json') + return config.openclawConfigPath || null +} + +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 */ function readWorkspaceFile(workspace: string | undefined, filename: string): string | null { - if (!workspace || !config.openclawHome) return null + if (!workspace) return null try { - const safeWorkspace = resolveWithin(config.openclawHome, workspace) + const safeWorkspace = resolveAgentWorkspacePath(workspace) const safePath = resolveWithin(safeWorkspace, filename) if (existsSync(safePath)) { return readFileSync(safePath, 'utf-8') @@ -173,7 +180,7 @@ export function enrichAgentConfigFromWorkspace(configData: any): any { /** Read and parse openclaw.json agents list */ async function readOpenClawAgents(): Promise { 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 raw = await readFile(configPath, 'utf-8') @@ -334,7 +341,7 @@ export async function previewSyncDiff(): Promise { /** Write an agent config back to openclaw.json agents.list */ export async function writeAgentToConfig(agentConfig: any): Promise { 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 raw = await readFile(configPath, 'utf-8') diff --git a/src/lib/command.ts b/src/lib/command.ts index b1b4705..70313e9 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -74,13 +74,13 @@ export function runCommand( export function runOpenClaw(args: string[], options: CommandOptions = {}) { return runCommand(config.openclawBin, args, { ...options, - cwd: options.cwd || config.openclawHome || process.cwd() + cwd: options.cwd || config.openclawStateDir || process.cwd() }) } export function runClawdbot(args: string[], options: CommandOptions = {}) { return runCommand(config.clawdbotBin, args, { ...options, - cwd: options.cwd || config.openclawHome || process.cwd() + cwd: options.cwd || config.openclawStateDir || process.cwd() }) } diff --git a/src/lib/config.ts b/src/lib/config.ts index b6a4ca2..54214c1 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,11 +3,24 @@ import os from 'node:os' import path from 'node:path' 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.CLAWDBOT_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 = { claudeHome: @@ -20,22 +33,25 @@ export const config = { tokensPath: process.env.MISSION_CONTROL_TOKENS_PATH || 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', clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot', gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1', gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'), logsDir: process.env.OPENCLAW_LOG_DIR || - (openclawHome ? path.join(openclawHome, 'logs') : ''), + (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''), tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '', memoryDir: process.env.OPENCLAW_MEMORY_DIR || - (openclawHome ? path.join(openclawHome, 'memory') : '') || + (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory'), soulTemplatesDir: process.env.OPENCLAW_SOUL_TEMPLATES_DIR || - (openclawHome ? path.join(openclawHome, 'templates', 'souls') : ''), + (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''), homeDir: os.homedir(), // Data retention (days). 0 = keep forever. retention: { diff --git a/src/lib/db.ts b/src/lib/db.ts index 931ab43..7a2f234 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -164,6 +164,11 @@ export interface Task { description?: string; status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'; priority: 'low' | 'medium' | 'high' | 'urgent'; + project_id?: number; + project_ticket_no?: number; + project_name?: string; + project_prefix?: string; + ticket_ref?: string; assigned_to?: string; created_by: string; created_at: number; diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index b659f6e..0b1fc70 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -676,6 +676,83 @@ const migrations: Migration[] = [ db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`) } + }, + { + id: '024_projects_support', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + ticket_prefix TEXT NOT NULL, + ticket_counter INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(workspace_id, slug), + UNIQUE(workspace_id, ticket_prefix) + ) + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_workspace_status ON projects(workspace_id, status)`) + + const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }> + if (!taskCols.some((c) => c.name === 'project_id')) { + db.exec(`ALTER TABLE tasks ADD COLUMN project_id INTEGER`) + } + if (!taskCols.some((c) => c.name === 'project_ticket_no')) { + db.exec(`ALTER TABLE tasks ADD COLUMN project_ticket_no INTEGER`) + } + db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_project ON tasks(workspace_id, project_id)`) + + const workspaceRows = db.prepare(`SELECT id FROM workspaces ORDER BY id ASC`).all() as Array<{ id: number }> + const ensureDefaultProject = db.prepare(` + INSERT OR IGNORE INTO projects (workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at) + VALUES (?, 'General', 'general', 'Default project for uncategorized tasks', 'TASK', 0, 'active', unixepoch(), unixepoch()) + `) + const getDefaultProject = db.prepare(` + SELECT id, ticket_counter FROM projects + WHERE workspace_id = ? AND slug = 'general' + LIMIT 1 + `) + const setTaskProject = db.prepare(` + UPDATE tasks SET project_id = ? + WHERE workspace_id = ? AND (project_id IS NULL OR project_id = 0) + `) + const listProjectTasks = db.prepare(` + SELECT id FROM tasks + WHERE workspace_id = ? AND project_id = ? + ORDER BY created_at ASC, id ASC + `) + const setTaskNo = db.prepare(`UPDATE tasks SET project_ticket_no = ? WHERE id = ?`) + const setProjectCounter = db.prepare(`UPDATE projects SET ticket_counter = ?, updated_at = unixepoch() WHERE id = ?`) + + for (const workspace of workspaceRows) { + ensureDefaultProject.run(workspace.id) + const defaultProject = getDefaultProject.get(workspace.id) as { id: number; ticket_counter: number } | undefined + if (!defaultProject) continue + + setTaskProject.run(defaultProject.id, workspace.id) + + const projectRows = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? + ORDER BY id ASC + `).all(workspace.id) as Array<{ id: number }> + + for (const project of projectRows) { + const tasks = listProjectTasks.all(workspace.id, project.id) as Array<{ id: number }> + let counter = 0 + for (const task of tasks) { + counter += 1 + setTaskNo.run(counter, task.id) + } + setProjectCounter.run(counter, project.id) + } + } + } } ] diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts index 8983e6e..17421fd 100644 --- a/src/lib/sessions.ts +++ b/src/lib/sessions.ts @@ -23,16 +23,16 @@ export interface GatewaySession { * Read all sessions from OpenClaw agent session stores on disk. * * 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::main") * with session metadata as values. */ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] { - const openclawHome = config.openclawHome - if (!openclawHome) return [] + const openclawStateDir = config.openclawStateDir + if (!openclawStateDir) return [] - const agentsDir = path.join(openclawHome, 'agents') + const agentsDir = path.join(openclawStateDir, 'agents') if (!fs.existsSync(agentsDir)) return [] const sessions: GatewaySession[] = [] diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 9108e83..f26e7a5 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -31,6 +31,7 @@ export const createTaskSchema = z.object({ description: z.string().max(5000).optional(), status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'), priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), + project_id: z.number().int().positive().optional(), assigned_to: z.string().max(100).optional(), created_by: z.string().max(100).optional(), due_date: z.number().optional(), diff --git a/src/store/index.ts b/src/store/index.ts index 4eb0578..e130a16 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -93,6 +93,11 @@ export interface Task { description?: string status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' + project_id?: number + project_ticket_no?: number + project_name?: string + project_prefix?: string + ticket_ref?: string assigned_to?: string created_by: string created_at: number diff --git a/tests/README.md b/tests/README.md index e75e461..8026c68 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,6 +11,9 @@ pnpm dev --hostname 127.0.0.1 --port 3005 # Run all tests pnpm test:e2e +# Run offline OpenClaw harness (no OpenClaw install required) +pnpm test:e2e:openclaw + # Run a specific spec 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` - `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 ### Security & Auth diff --git a/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json b/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json new file mode 100644 index 0000000..acb9f70 --- /dev/null +++ b/tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json @@ -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 + } +} diff --git a/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json b/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json new file mode 100644 index 0000000..29b0b7c --- /dev/null +++ b/tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json @@ -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 + } +} diff --git a/tests/fixtures/openclaw/cron/jobs.json b/tests/fixtures/openclaw/cron/jobs.json new file mode 100644 index 0000000..be775ca --- /dev/null +++ b/tests/fixtures/openclaw/cron/jobs.json @@ -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" + } + } + ] +} diff --git a/tests/fixtures/openclaw/openclaw.json b/tests/fixtures/openclaw/openclaw.json new file mode 100644 index 0000000..08545c8 --- /dev/null +++ b/tests/fixtures/openclaw/openclaw.json @@ -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"] + } + } + ] + } +} diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md b/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md new file mode 100644 index 0000000..9c3d5ab --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md @@ -0,0 +1,3 @@ +- `shell` +- `sessions_send` +- `sessions_history` diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md b/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md new file mode 100644 index 0000000..a384832 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/identity.md @@ -0,0 +1,3 @@ +# Engineering Bot +theme: software-engineer +emoji: ⚙️ diff --git a/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md b/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md new file mode 100644 index 0000000..0ea5aa2 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/engineering-bot/soul.md @@ -0,0 +1,2 @@ +# Engineering Bot +Focus on implementation quality, safe migrations, and test coverage. diff --git a/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md b/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md new file mode 100644 index 0000000..b233f88 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md @@ -0,0 +1,2 @@ +- `web` +- `sessions_send` diff --git a/tests/fixtures/openclaw/workspaces/research-bot/identity.md b/tests/fixtures/openclaw/workspaces/research-bot/identity.md new file mode 100644 index 0000000..d694831 --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/identity.md @@ -0,0 +1,3 @@ +# Research Bot +theme: analyst +emoji: 📚 diff --git a/tests/fixtures/openclaw/workspaces/research-bot/soul.md b/tests/fixtures/openclaw/workspaces/research-bot/soul.md new file mode 100644 index 0000000..63d5e1e --- /dev/null +++ b/tests/fixtures/openclaw/workspaces/research-bot/soul.md @@ -0,0 +1,2 @@ +# Research Bot +Focus on evidence, citations, and concise recommendations. diff --git a/tests/openclaw-harness.spec.ts b/tests/openclaw-harness.spec.ts new file mode 100644 index 0000000..3d1d9ea --- /dev/null +++ b/tests/openclaw-harness.spec.ts @@ -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') + }) +})