nyk commited on
Commit
90c8b4e
·
unverified ·
2 Parent(s): 98f07b9e305653

Merge pull request #155 from builderz-labs/feat/issues-146-143-145

Browse files
Files changed (47) hide show
  1. README.md +32 -4
  2. package.json +4 -1
  3. playwright.config.ts +1 -0
  4. playwright.openclaw.gateway.config.ts +26 -0
  5. playwright.openclaw.local.config.ts +26 -0
  6. scripts/e2e-openclaw/bin/clawdbot +20 -0
  7. scripts/e2e-openclaw/bin/openclaw +20 -0
  8. scripts/e2e-openclaw/mock-gateway.mjs +61 -0
  9. scripts/e2e-openclaw/start-e2e-server.mjs +99 -0
  10. src/app/[[...panel]]/page.tsx +2 -0
  11. src/app/api/agents/[id]/memory/route.ts +2 -2
  12. src/app/api/agents/[id]/soul/route.ts +11 -5
  13. src/app/api/cron/route.ts +3 -3
  14. src/app/api/gateway-config/route.ts +3 -5
  15. src/app/api/integrations/route.ts +8 -8
  16. src/app/api/projects/[id]/route.ts +177 -0
  17. src/app/api/projects/[id]/tasks/route.ts +55 -0
  18. src/app/api/projects/route.ts +94 -0
  19. src/app/api/status/route.ts +4 -1
  20. src/app/api/tasks/[id]/route.ts +64 -14
  21. src/app/api/tasks/route.ts +109 -41
  22. src/components/layout/header-bar.tsx +1 -0
  23. src/components/layout/nav-rail.tsx +1 -0
  24. src/components/panels/agent-detail-tabs.tsx +5 -2
  25. src/components/panels/gateway-config-panel.tsx +1 -1
  26. src/components/panels/task-board-panel.tsx +302 -17
  27. src/index.ts +5 -0
  28. src/lib/agent-sync.ts +14 -7
  29. src/lib/command.ts +2 -2
  30. src/lib/config.ts +21 -5
  31. src/lib/db.ts +5 -0
  32. src/lib/migrations.ts +77 -0
  33. src/lib/sessions.ts +4 -4
  34. src/lib/validation.ts +1 -0
  35. src/store/index.ts +5 -0
  36. tests/README.md +14 -0
  37. tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json +15 -0
  38. tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json +15 -0
  39. tests/fixtures/openclaw/cron/jobs.json +59 -0
  40. tests/fixtures/openclaw/openclaw.json +49 -0
  41. tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md +3 -0
  42. tests/fixtures/openclaw/workspaces/engineering-bot/identity.md +3 -0
  43. tests/fixtures/openclaw/workspaces/engineering-bot/soul.md +2 -0
  44. tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md +2 -0
  45. tests/fixtures/openclaw/workspaces/research-bot/identity.md +3 -0
  46. tests/fixtures/openclaw/workspaces/research-bot/soul.md +2 -0
  47. tests/openclaw-harness.spec.ts +56 -0
README.md CHANGED
@@ -342,7 +342,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
342
  | `AUTH_PASS` | No | Initial admin password |
343
  | `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) |
344
  | `API_KEY` | No | API key for headless access |
345
- | `OPENCLAW_HOME` | Yes* | Path to `.openclaw` directory |
 
 
346
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
347
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
348
  | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
@@ -354,10 +356,10 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
354
  | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
355
  | `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
356
 
357
- *Memory browser, log viewer, and gateway config require `OPENCLAW_HOME`.
358
 
359
  > **Memory Browser note:** OpenClaw does not store agent memory markdown files under
360
- > `$OPENCLAW_HOME/memory/` — that directory does not exist by default. Agent memory lives
361
  > in each agent's workspace (e.g. `~/clawd-agents/{agent}/memory/`). Set
362
  > `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show
363
  > daily logs, `MEMORY.md`, and other markdown files:
@@ -365,6 +367,32 @@ See [`.env.example`](.env.example) for the complete list. Key variables:
365
  > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents
366
  > ```
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  ## Deployment
369
 
370
  ```bash
@@ -373,7 +401,7 @@ pnpm install --frozen-lockfile
373
  pnpm build
374
 
375
  # Run
376
- OPENCLAW_HOME=/path/to/.openclaw pnpm start
377
  ```
378
 
379
  Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access.
 
342
  | `AUTH_PASS` | No | Initial admin password |
343
  | `AUTH_PASS_B64` | No | Base64-encoded admin password (overrides `AUTH_PASS` if set) |
344
  | `API_KEY` | No | API key for headless access |
345
+ | `OPENCLAW_CONFIG_PATH` | Yes* | Absolute path to `openclaw.json` (preferred) |
346
+ | `OPENCLAW_STATE_DIR` | Yes* | OpenClaw state root (default: `~/.openclaw`) |
347
+ | `OPENCLAW_HOME` | No | Legacy alias for state dir (fallback if `OPENCLAW_STATE_DIR` unset) |
348
  | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) |
349
  | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) |
350
  | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token |
 
356
  | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing |
357
  | `MC_ALLOWED_HOSTS` | No | Host allowlist for production |
358
 
359
+ *Memory browser, log viewer, and gateway config require OpenClaw config/state resolution (`OPENCLAW_CONFIG_PATH` and/or `OPENCLAW_STATE_DIR`).
360
 
361
  > **Memory Browser note:** OpenClaw does not store agent memory markdown files under
362
+ > `$OPENCLAW_STATE_DIR/memory/` — that directory does not exist by default. Agent memory lives
363
  > in each agent's workspace (e.g. `~/clawd-agents/{agent}/memory/`). Set
364
  > `OPENCLAW_MEMORY_DIR` to your agents root directory to make the Memory Browser show
365
  > daily logs, `MEMORY.md`, and other markdown files:
 
367
  > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents
368
  > ```
369
 
370
+ ### Workspace Creation Flow
371
+
372
+ To add a new workspace/client instance in the UI:
373
+
374
+ 1. Open `Workspaces` from the left navigation.
375
+ 2. Expand `Show Create Client Instance`.
376
+ 3. Fill tenant/workspace fields (`slug`, `display_name`, optional ports/gateway owner).
377
+ 4. Click `Create + Queue`.
378
+ 5. Approve/run the generated provisioning job in the same panel.
379
+
380
+ `Workspaces` and `Super Admin` currently point to the same provisioning control plane.
381
+
382
+ ### Projects and Ticket Prefixes
383
+
384
+ Mission Control supports multi-project task organization per workspace:
385
+
386
+ - Create/manage projects via Task Board → `Projects`.
387
+ - Each project has its own ticket prefix and counter.
388
+ - New tasks receive project-scoped ticket refs like `PA-001`, `PA-002`.
389
+ - Task board supports filtering by project.
390
+
391
+ ### Memory Scope Clarification
392
+
393
+ - **Agent profile → Memory tab**: per-agent working memory stored in Mission Control DB (`working_memory`).
394
+ - **Memory Browser page**: workspace/local filesystem memory tree under `OPENCLAW_MEMORY_DIR`.
395
+
396
  ## Deployment
397
 
398
  ```bash
 
401
  pnpm build
402
 
403
  # Run
404
+ OPENCLAW_CONFIG_PATH=/path/to/.openclaw/openclaw.json OPENCLAW_STATE_DIR=/path/to/.openclaw pnpm start
405
  ```
406
 
407
  Network access is restricted by default in production. Set `MC_ALLOWED_HOSTS` (comma-separated) or `MC_ALLOW_ANY_HOST=1` to control access.
package.json CHANGED
@@ -12,6 +12,9 @@
12
  "test:watch": "vitest",
13
  "test:ui": "vitest --ui",
14
  "test:e2e": "playwright test",
 
 
 
15
  "test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e",
16
  "quality:gate": "pnpm test:all"
17
  },
@@ -77,4 +80,4 @@
77
  "better-sqlite3"
78
  ]
79
  }
80
- }
 
12
  "test:watch": "vitest",
13
  "test:ui": "vitest --ui",
14
  "test:e2e": "playwright test",
15
+ "test:e2e:openclaw:local": "E2E_GATEWAY_EXPECTED=0 playwright test -c playwright.openclaw.local.config.ts",
16
+ "test:e2e:openclaw:gateway": "E2E_GATEWAY_EXPECTED=1 playwright test -c playwright.openclaw.gateway.config.ts",
17
+ "test:e2e:openclaw": "pnpm test:e2e:openclaw:local && pnpm test:e2e:openclaw:gateway",
18
  "test:all": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm test:e2e",
19
  "quality:gate": "pnpm test:all"
20
  },
 
80
  "better-sqlite3"
81
  ]
82
  }
83
+ }
playwright.config.ts CHANGED
@@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test'
2
 
3
  export default defineConfig({
4
  testDir: 'tests',
 
5
  timeout: 60_000,
6
  expect: {
7
  timeout: 10_000
 
2
 
3
  export default defineConfig({
4
  testDir: 'tests',
5
+ testIgnore: /openclaw-harness\.spec\.ts/,
6
  timeout: 60_000,
7
  expect: {
8
  timeout: 10_000
playwright.openclaw.gateway.config.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: 'tests',
5
+ testMatch: /openclaw-harness\.spec\.ts/,
6
+ timeout: 60_000,
7
+ expect: {
8
+ timeout: 10_000,
9
+ },
10
+ fullyParallel: false,
11
+ workers: 1,
12
+ reporter: [['list']],
13
+ use: {
14
+ baseURL: 'http://127.0.0.1:3005',
15
+ trace: 'retain-on-failure',
16
+ },
17
+ projects: [
18
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
19
+ ],
20
+ webServer: {
21
+ command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=gateway',
22
+ url: 'http://127.0.0.1:3005',
23
+ reuseExistingServer: false,
24
+ timeout: 120_000,
25
+ },
26
+ })
playwright.openclaw.local.config.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: 'tests',
5
+ testMatch: /openclaw-harness\.spec\.ts/,
6
+ timeout: 60_000,
7
+ expect: {
8
+ timeout: 10_000,
9
+ },
10
+ fullyParallel: false,
11
+ workers: 1,
12
+ reporter: [['list']],
13
+ use: {
14
+ baseURL: 'http://127.0.0.1:3005',
15
+ trace: 'retain-on-failure',
16
+ },
17
+ projects: [
18
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
19
+ ],
20
+ webServer: {
21
+ command: 'node scripts/e2e-openclaw/start-e2e-server.mjs --mode=local',
22
+ url: 'http://127.0.0.1:3005',
23
+ reuseExistingServer: false,
24
+ timeout: 120_000,
25
+ },
26
+ })
scripts/e2e-openclaw/bin/clawdbot ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ "${1:-}" == "--version" ]]; then
5
+ echo "clawdbot 2026.3.2"
6
+ exit 0
7
+ fi
8
+
9
+ if [[ "${1:-}" == "-c" ]]; then
10
+ cmd="${2:-}"
11
+ if [[ "$cmd" == sessions_spawn* ]]; then
12
+ echo "Session created: mock-clawdbot-session"
13
+ exit 0
14
+ fi
15
+ echo "ok"
16
+ exit 0
17
+ fi
18
+
19
+ echo "clawdbot mock: unsupported args: $*" >&2
20
+ exit 0
scripts/e2e-openclaw/bin/openclaw ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ "${1:-}" == "--version" ]]; then
5
+ echo "openclaw 2026.3.2"
6
+ exit 0
7
+ fi
8
+
9
+ if [[ "${1:-}" == "-c" ]]; then
10
+ cmd="${2:-}"
11
+ if [[ "$cmd" == sessions_spawn* ]]; then
12
+ echo "Session created: mock-openclaw-session"
13
+ exit 0
14
+ fi
15
+ echo "ok"
16
+ exit 0
17
+ fi
18
+
19
+ echo "openclaw mock: unsupported args: $*" >&2
20
+ exit 0
scripts/e2e-openclaw/mock-gateway.mjs ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import http from 'node:http'
3
+ import { WebSocketServer } from 'ws'
4
+
5
+ const host = process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1'
6
+ const port = Number(process.env.OPENCLAW_GATEWAY_PORT || 18789)
7
+
8
+ const server = http.createServer((req, res) => {
9
+ if (!req.url) {
10
+ res.writeHead(404)
11
+ res.end()
12
+ return
13
+ }
14
+
15
+ if (req.url === '/health') {
16
+ res.writeHead(200, { 'content-type': 'application/json' })
17
+ res.end(JSON.stringify({ ok: true, service: 'openclaw-mock-gateway' }))
18
+ return
19
+ }
20
+
21
+ res.writeHead(200, { 'content-type': 'application/json' })
22
+ res.end(JSON.stringify({ ok: true }))
23
+ })
24
+
25
+ const wss = new WebSocketServer({ noServer: true })
26
+
27
+ wss.on('connection', (ws) => {
28
+ ws.send(JSON.stringify({ type: 'status', connected: true, source: 'mock-gateway' }))
29
+ ws.on('message', (raw) => {
30
+ const text = raw.toString()
31
+ if (text.includes('ping')) {
32
+ ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }))
33
+ return
34
+ }
35
+ ws.send(JSON.stringify({ type: 'event', message: 'ack', raw: text }))
36
+ })
37
+ })
38
+
39
+ server.on('upgrade', (req, socket, head) => {
40
+ wss.handleUpgrade(req, socket, head, (ws) => {
41
+ wss.emit('connection', ws, req)
42
+ })
43
+ })
44
+
45
+ server.listen(port, host, () => {
46
+ process.stdout.write(`[openclaw-mock-gateway] listening on ${host}:${port}\n`)
47
+ })
48
+
49
+ function shutdown() {
50
+ wss.clients.forEach((client) => {
51
+ try {
52
+ client.close()
53
+ } catch {
54
+ // noop
55
+ }
56
+ })
57
+ server.close(() => process.exit(0))
58
+ }
59
+
60
+ process.on('SIGINT', shutdown)
61
+ process.on('SIGTERM', shutdown)
scripts/e2e-openclaw/start-e2e-server.mjs ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import process from 'node:process'
6
+
7
+ const modeArg = process.argv.find((arg) => arg.startsWith('--mode='))
8
+ const mode = modeArg ? modeArg.split('=')[1] : 'local'
9
+ if (mode !== 'local' && mode !== 'gateway') {
10
+ process.stderr.write(`Invalid mode: ${mode}\n`)
11
+ process.exit(1)
12
+ }
13
+
14
+ const repoRoot = process.cwd()
15
+ const fixtureSource = path.join(repoRoot, 'tests', 'fixtures', 'openclaw')
16
+ const runtimeRoot = path.join(repoRoot, '.tmp', 'e2e-openclaw', mode)
17
+ const dataDir = path.join(runtimeRoot, 'data')
18
+ const mockBinDir = path.join(repoRoot, 'scripts', 'e2e-openclaw', 'bin')
19
+
20
+ fs.rmSync(runtimeRoot, { recursive: true, force: true })
21
+ fs.mkdirSync(runtimeRoot, { recursive: true })
22
+ fs.mkdirSync(dataDir, { recursive: true })
23
+ fs.cpSync(fixtureSource, runtimeRoot, { recursive: true })
24
+
25
+ const gatewayHost = '127.0.0.1'
26
+ const gatewayPort = '18789'
27
+
28
+ const baseEnv = {
29
+ ...process.env,
30
+ API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
31
+ AUTH_USER: process.env.AUTH_USER || 'admin',
32
+ AUTH_PASS: process.env.AUTH_PASS || 'admin',
33
+ MC_DISABLE_RATE_LIMIT: '1',
34
+ MISSION_CONTROL_DATA_DIR: dataDir,
35
+ MISSION_CONTROL_DB_PATH: path.join(dataDir, 'mission-control.db'),
36
+ OPENCLAW_STATE_DIR: runtimeRoot,
37
+ OPENCLAW_CONFIG_PATH: path.join(runtimeRoot, 'openclaw.json'),
38
+ OPENCLAW_GATEWAY_HOST: gatewayHost,
39
+ OPENCLAW_GATEWAY_PORT: gatewayPort,
40
+ OPENCLAW_BIN: path.join(mockBinDir, 'openclaw'),
41
+ CLAWDBOT_BIN: path.join(mockBinDir, 'clawdbot'),
42
+ PATH: `${mockBinDir}:${process.env.PATH || ''}`,
43
+ E2E_GATEWAY_EXPECTED: mode === 'gateway' ? '1' : '0',
44
+ }
45
+
46
+ const children = []
47
+
48
+ if (mode === 'gateway') {
49
+ const gw = spawn('node', ['scripts/e2e-openclaw/mock-gateway.mjs'], {
50
+ cwd: repoRoot,
51
+ env: baseEnv,
52
+ stdio: 'inherit',
53
+ })
54
+ children.push(gw)
55
+ }
56
+
57
+ const standaloneServerPath = path.join(repoRoot, '.next', 'standalone', 'server.js')
58
+ const app = fs.existsSync(standaloneServerPath)
59
+ ? spawn('node', [standaloneServerPath], {
60
+ cwd: repoRoot,
61
+ env: {
62
+ ...baseEnv,
63
+ HOSTNAME: '127.0.0.1',
64
+ PORT: '3005',
65
+ },
66
+ stdio: 'inherit',
67
+ })
68
+ : spawn('pnpm', ['start'], {
69
+ cwd: repoRoot,
70
+ env: baseEnv,
71
+ stdio: 'inherit',
72
+ })
73
+ children.push(app)
74
+
75
+ function shutdown(signal = 'SIGTERM') {
76
+ for (const child of children) {
77
+ if (!child.killed) {
78
+ try {
79
+ child.kill(signal)
80
+ } catch {
81
+ // noop
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ process.on('SIGINT', () => {
88
+ shutdown('SIGINT')
89
+ process.exit(130)
90
+ })
91
+ process.on('SIGTERM', () => {
92
+ shutdown('SIGTERM')
93
+ process.exit(143)
94
+ })
95
+
96
+ app.on('exit', (code) => {
97
+ shutdown('SIGTERM')
98
+ process.exit(code ?? 0)
99
+ })
src/app/[[...panel]]/page.tsx CHANGED
@@ -261,6 +261,8 @@ function ContentRouter({ tab }: { tab: string }) {
261
  return <OfficePanel />
262
  case 'super-admin':
263
  return <SuperAdminPanel />
 
 
264
  default:
265
  return <Dashboard />
266
  }
 
261
  return <OfficePanel />
262
  case 'super-admin':
263
  return <SuperAdminPanel />
264
+ case 'workspaces':
265
+ return <SuperAdminPanel />
266
  default:
267
  return <Dashboard />
268
  }
src/app/api/agents/[id]/memory/route.ts CHANGED
@@ -6,8 +6,8 @@ import { logger } from '@/lib/logger';
6
  /**
7
  * GET /api/agents/[id]/memory - Get agent's working memory
8
  *
9
- * Working memory is stored as WORKING.md content in the database
10
- * Each agent has their own working memory space for temporary notes
11
  */
12
  export async function GET(
13
  request: NextRequest,
 
6
  /**
7
  * GET /api/agents/[id]/memory - Get agent's working memory
8
  *
9
+ * Working memory is stored in the agents.working_memory DB column.
10
+ * This endpoint is per-agent scratchpad memory (not the global Memory Browser filesystem view).
11
  */
12
  export async function GET(
13
  request: NextRequest,
src/app/api/agents/[id]/soul/route.ts CHANGED
@@ -1,12 +1,18 @@
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
4
- import { join, dirname } from 'path';
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
  import { requireRole } from '@/lib/auth';
8
  import { logger } from '@/lib/logger';
9
 
 
 
 
 
 
 
10
  /**
11
  * GET /api/agents/[id]/soul - Get agent's SOUL content
12
  */
@@ -41,8 +47,8 @@ export async function GET(
41
 
42
  try {
43
  const agentConfig = agent.config ? JSON.parse(agent.config) : {}
44
- if (agentConfig.workspace && config.openclawHome) {
45
- const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
46
  const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
47
  if (existsSync(safeSoulPath)) {
48
  soulContent = readFileSync(safeSoulPath, 'utf-8')
@@ -157,8 +163,8 @@ export async function PUT(
157
  let savedToWorkspace = false
158
  try {
159
  const agentConfig = agent.config ? JSON.parse(agent.config) : {}
160
- if (agentConfig.workspace && config.openclawHome) {
161
- const safeWorkspace = resolveWithin(config.openclawHome, agentConfig.workspace)
162
  const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
163
  mkdirSync(dirname(safeSoulPath), { recursive: true })
164
  writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
 
1
  import { NextRequest, NextResponse } from 'next/server';
2
  import { getDatabase, db_helpers } from '@/lib/db';
3
  import { readFileSync, existsSync, readdirSync, writeFileSync, mkdirSync } from 'fs';
4
+ import { join, dirname, isAbsolute, resolve } from 'path';
5
  import { config } from '@/lib/config';
6
  import { resolveWithin } from '@/lib/paths';
7
  import { requireRole } from '@/lib/auth';
8
  import { logger } from '@/lib/logger';
9
 
10
+ function resolveAgentWorkspacePath(workspace: string): string {
11
+ if (isAbsolute(workspace)) return resolve(workspace)
12
+ if (!config.openclawStateDir) throw new Error('OPENCLAW_STATE_DIR not configured')
13
+ return resolveWithin(config.openclawStateDir, workspace)
14
+ }
15
+
16
  /**
17
  * GET /api/agents/[id]/soul - Get agent's SOUL content
18
  */
 
47
 
48
  try {
49
  const agentConfig = agent.config ? JSON.parse(agent.config) : {}
50
+ if (agentConfig.workspace) {
51
+ const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace)
52
  const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
53
  if (existsSync(safeSoulPath)) {
54
  soulContent = readFileSync(safeSoulPath, 'utf-8')
 
163
  let savedToWorkspace = false
164
  try {
165
  const agentConfig = agent.config ? JSON.parse(agent.config) : {}
166
+ if (agentConfig.workspace) {
167
+ const safeWorkspace = resolveAgentWorkspacePath(agentConfig.workspace)
168
  const safeSoulPath = resolveWithin(safeWorkspace, 'soul.md')
169
  mkdirSync(dirname(safeSoulPath), { recursive: true })
170
  writeFileSync(safeSoulPath, newSoulContent || '', 'utf-8')
src/app/api/cron/route.ts CHANGED
@@ -67,9 +67,9 @@ interface OpenClawCronFile {
67
  }
68
 
69
  function getCronFilePath(): string {
70
- const openclawHome = config.openclawHome
71
- if (!openclawHome) return ''
72
- return path.join(openclawHome, 'cron', 'jobs.json')
73
  }
74
 
75
  async function loadCronFile(): Promise<OpenClawCronFile | null> {
 
67
  }
68
 
69
  function getCronFilePath(): string {
70
+ const openclawStateDir = config.openclawStateDir
71
+ if (!openclawStateDir) return ''
72
+ return path.join(openclawStateDir, 'cron', 'jobs.json')
73
  }
74
 
75
  async function loadCronFile(): Promise<OpenClawCronFile | null> {
src/app/api/gateway-config/route.ts CHANGED
@@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from 'next/server'
2
  import { requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
- import { join } from 'path'
6
  import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
7
  import { mutationLimiter } from '@/lib/rate-limit'
8
 
9
  function getConfigPath(): string | null {
10
- if (!config.openclawHome) return null
11
- return join(config.openclawHome, 'openclaw.json')
12
  }
13
 
14
  /**
@@ -20,7 +18,7 @@ export async function GET(request: NextRequest) {
20
 
21
  const configPath = getConfigPath()
22
  if (!configPath) {
23
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
24
  }
25
 
26
  try {
@@ -60,7 +58,7 @@ export async function PUT(request: NextRequest) {
60
 
61
  const configPath = getConfigPath()
62
  if (!configPath) {
63
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
64
  }
65
 
66
  const result = await validateBody(request, gatewayConfigUpdateSchema)
 
2
  import { requireRole } from '@/lib/auth'
3
  import { logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
 
5
  import { validateBody, gatewayConfigUpdateSchema } from '@/lib/validation'
6
  import { mutationLimiter } from '@/lib/rate-limit'
7
 
8
  function getConfigPath(): string | null {
9
+ return config.openclawConfigPath || null
 
10
  }
11
 
12
  /**
 
18
 
19
  const configPath = getConfigPath()
20
  if (!configPath) {
21
+ return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 })
22
  }
23
 
24
  try {
 
58
 
59
  const configPath = getConfigPath()
60
  if (!configPath) {
61
+ return NextResponse.json({ error: 'OPENCLAW_CONFIG_PATH not configured' }, { status: 404 })
62
  }
63
 
64
  const result = await validateBody(request, gatewayConfigUpdateSchema)
src/app/api/integrations/route.ts CHANGED
@@ -108,8 +108,8 @@ function serializeEnv(lines: EnvLine[]): string {
108
  }
109
 
110
  function getEnvPath(): string | null {
111
- if (!config.openclawHome) return null
112
- return join(config.openclawHome, '.env')
113
  }
114
 
115
  async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> {
@@ -184,7 +184,7 @@ export async function GET(request: NextRequest) {
184
 
185
  const envData = await readEnvFile()
186
  if (!envData) {
187
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
188
  }
189
 
190
  const envMap = new Map<string, string>()
@@ -261,7 +261,7 @@ export async function PUT(request: NextRequest) {
261
 
262
  const envData = await readEnvFile()
263
  if (!envData) {
264
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
265
  }
266
 
267
  const { lines } = envData
@@ -324,7 +324,7 @@ export async function DELETE(request: NextRequest) {
324
 
325
  const envData = await readEnvFile()
326
  if (!envData) {
327
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
328
  }
329
 
330
  const removed: string[] = []
@@ -408,7 +408,7 @@ async function handleTest(
408
 
409
  const envData = await readEnvFile()
410
  if (!envData) {
411
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
412
  }
413
 
414
  const envMap = new Map<string, string>()
@@ -552,7 +552,7 @@ async function handlePull(
552
  // Write to .env
553
  const envData = await readEnvFile()
554
  if (!envData) {
555
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
556
  }
557
 
558
  const { lines } = envData
@@ -621,7 +621,7 @@ async function handlePullAll(
621
 
622
  const envData = await readEnvFile()
623
  if (!envData) {
624
- return NextResponse.json({ error: 'OPENCLAW_HOME not configured' }, { status: 404 })
625
  }
626
 
627
  const { lines } = envData
 
108
  }
109
 
110
  function getEnvPath(): string | null {
111
+ if (!config.openclawStateDir) return null
112
+ return join(config.openclawStateDir, '.env')
113
  }
114
 
115
  async function readEnvFile(): Promise<{ lines: EnvLine[]; raw: string } | null> {
 
184
 
185
  const envData = await readEnvFile()
186
  if (!envData) {
187
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
188
  }
189
 
190
  const envMap = new Map<string, string>()
 
261
 
262
  const envData = await readEnvFile()
263
  if (!envData) {
264
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
265
  }
266
 
267
  const { lines } = envData
 
324
 
325
  const envData = await readEnvFile()
326
  if (!envData) {
327
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
328
  }
329
 
330
  const removed: string[] = []
 
408
 
409
  const envData = await readEnvFile()
410
  if (!envData) {
411
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
412
  }
413
 
414
  const envMap = new Map<string, string>()
 
552
  // Write to .env
553
  const envData = await readEnvFile()
554
  if (!envData) {
555
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
556
  }
557
 
558
  const { lines } = envData
 
621
 
622
  const envData = await readEnvFile()
623
  if (!envData) {
624
+ return NextResponse.json({ error: 'OPENCLAW_STATE_DIR not configured' }, { status: 404 })
625
  }
626
 
627
  const { lines } = envData
src/app/api/projects/[id]/route.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { mutationLimiter } from '@/lib/rate-limit'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ function normalizePrefix(input: string): string {
8
+ const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
9
+ return normalized.slice(0, 12)
10
+ }
11
+
12
+ function toProjectId(raw: string): number {
13
+ const id = Number.parseInt(raw, 10)
14
+ return Number.isFinite(id) ? id : NaN
15
+ }
16
+
17
+ export async function GET(
18
+ request: NextRequest,
19
+ { params }: { params: Promise<{ id: string }> }
20
+ ) {
21
+ const auth = requireRole(request, 'viewer')
22
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
23
+
24
+ try {
25
+ const db = getDatabase()
26
+ const workspaceId = auth.user.workspace_id ?? 1
27
+ const { id } = await params
28
+ const projectId = toProjectId(id)
29
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
30
+
31
+ const project = db.prepare(`
32
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
33
+ FROM projects
34
+ WHERE id = ? AND workspace_id = ?
35
+ `).get(projectId, workspaceId)
36
+ if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
37
+
38
+ return NextResponse.json({ project })
39
+ } catch (error) {
40
+ logger.error({ err: error }, 'GET /api/projects/[id] error')
41
+ return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 })
42
+ }
43
+ }
44
+
45
+ export async function PATCH(
46
+ request: NextRequest,
47
+ { params }: { params: Promise<{ id: string }> }
48
+ ) {
49
+ const auth = requireRole(request, 'operator')
50
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
51
+
52
+ const rateCheck = mutationLimiter(request)
53
+ if (rateCheck) return rateCheck
54
+
55
+ try {
56
+ const db = getDatabase()
57
+ const workspaceId = auth.user.workspace_id ?? 1
58
+ const { id } = await params
59
+ const projectId = toProjectId(id)
60
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
61
+
62
+ const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
63
+ if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
64
+ if (current.slug === 'general' && current.workspace_id === workspaceId && current.id === projectId) {
65
+ const body = await request.json()
66
+ if (body?.status === 'archived') {
67
+ return NextResponse.json({ error: 'Default project cannot be archived' }, { status: 400 })
68
+ }
69
+ }
70
+
71
+ const body = await request.json()
72
+ const updates: string[] = []
73
+ const paramsList: Array<string | number | null> = []
74
+
75
+ if (typeof body?.name === 'string') {
76
+ const name = body.name.trim()
77
+ if (!name) return NextResponse.json({ error: 'Project name cannot be empty' }, { status: 400 })
78
+ updates.push('name = ?')
79
+ paramsList.push(name)
80
+ }
81
+ if (typeof body?.description === 'string') {
82
+ updates.push('description = ?')
83
+ paramsList.push(body.description.trim() || null)
84
+ }
85
+ if (typeof body?.ticket_prefix === 'string' || typeof body?.ticketPrefix === 'string') {
86
+ const raw = String(body.ticket_prefix ?? body.ticketPrefix)
87
+ const prefix = normalizePrefix(raw)
88
+ if (!prefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 })
89
+ const conflict = db.prepare(`
90
+ SELECT id FROM projects
91
+ WHERE workspace_id = ? AND ticket_prefix = ? AND id != ?
92
+ `).get(workspaceId, prefix, projectId)
93
+ if (conflict) return NextResponse.json({ error: 'Ticket prefix already in use' }, { status: 409 })
94
+ updates.push('ticket_prefix = ?')
95
+ paramsList.push(prefix)
96
+ }
97
+ if (typeof body?.status === 'string') {
98
+ const status = body.status === 'archived' ? 'archived' : 'active'
99
+ updates.push('status = ?')
100
+ paramsList.push(status)
101
+ }
102
+
103
+ if (updates.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
104
+
105
+ updates.push('updated_at = unixepoch()')
106
+ db.prepare(`
107
+ UPDATE projects
108
+ SET ${updates.join(', ')}
109
+ WHERE id = ? AND workspace_id = ?
110
+ `).run(...paramsList, projectId, workspaceId)
111
+
112
+ const project = db.prepare(`
113
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
114
+ FROM projects
115
+ WHERE id = ? AND workspace_id = ?
116
+ `).get(projectId, workspaceId)
117
+
118
+ return NextResponse.json({ project })
119
+ } catch (error) {
120
+ logger.error({ err: error }, 'PATCH /api/projects/[id] error')
121
+ return NextResponse.json({ error: 'Failed to update project' }, { status: 500 })
122
+ }
123
+ }
124
+
125
+ export async function DELETE(
126
+ request: NextRequest,
127
+ { params }: { params: Promise<{ id: string }> }
128
+ ) {
129
+ const auth = requireRole(request, 'admin')
130
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
131
+
132
+ const rateCheck = mutationLimiter(request)
133
+ if (rateCheck) return rateCheck
134
+
135
+ try {
136
+ const db = getDatabase()
137
+ const workspaceId = auth.user.workspace_id ?? 1
138
+ const { id } = await params
139
+ const projectId = toProjectId(id)
140
+ if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
141
+
142
+ const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any
143
+ if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
144
+ if (current.slug === 'general') {
145
+ return NextResponse.json({ error: 'Default project cannot be deleted' }, { status: 400 })
146
+ }
147
+
148
+ const mode = new URL(request.url).searchParams.get('mode') || 'archive'
149
+ if (mode !== 'delete') {
150
+ db.prepare(`UPDATE projects SET status = 'archived', updated_at = unixepoch() WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
151
+ return NextResponse.json({ success: true, mode: 'archive' })
152
+ }
153
+
154
+ const fallback = db.prepare(`
155
+ SELECT id FROM projects
156
+ WHERE workspace_id = ? AND slug = 'general'
157
+ LIMIT 1
158
+ `).get(workspaceId) as { id: number } | undefined
159
+ if (!fallback) return NextResponse.json({ error: 'Default project missing' }, { status: 500 })
160
+
161
+ const tx = db.transaction(() => {
162
+ db.prepare(`
163
+ UPDATE tasks
164
+ SET project_id = ?
165
+ WHERE workspace_id = ? AND project_id = ?
166
+ `).run(fallback.id, workspaceId, projectId)
167
+
168
+ db.prepare(`DELETE FROM projects WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId)
169
+ })
170
+ tx()
171
+
172
+ return NextResponse.json({ success: true, mode: 'delete' })
173
+ } catch (error) {
174
+ logger.error({ err: error }, 'DELETE /api/projects/[id] error')
175
+ return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 })
176
+ }
177
+ }
src/app/api/projects/[id]/tasks/route.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { logger } from '@/lib/logger'
5
+
6
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
7
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
8
+ return `${prefix}-${String(num).padStart(3, '0')}`
9
+ }
10
+
11
+ export async function GET(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const auth = requireRole(request, 'viewer')
16
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
17
+
18
+ try {
19
+ const db = getDatabase()
20
+ const workspaceId = auth.user.workspace_id ?? 1
21
+ const { id } = await params
22
+ const projectId = Number.parseInt(id, 10)
23
+ if (!Number.isFinite(projectId)) {
24
+ return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 })
25
+ }
26
+
27
+ const project = db.prepare(`
28
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
29
+ FROM projects
30
+ WHERE id = ? AND workspace_id = ?
31
+ `).get(projectId, workspaceId)
32
+ if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 })
33
+
34
+ const tasks = db.prepare(`
35
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
36
+ FROM tasks t
37
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
38
+ WHERE t.workspace_id = ? AND t.project_id = ?
39
+ ORDER BY t.created_at DESC
40
+ `).all(workspaceId, projectId)
41
+
42
+ return NextResponse.json({
43
+ project,
44
+ tasks: tasks.map((task: any) => ({
45
+ ...task,
46
+ tags: task.tags ? JSON.parse(task.tags) : [],
47
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
48
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
49
+ }))
50
+ })
51
+ } catch (error) {
52
+ logger.error({ err: error }, 'GET /api/projects/[id]/tasks error')
53
+ return NextResponse.json({ error: 'Failed to fetch project tasks' }, { status: 500 })
54
+ }
55
+ }
src/app/api/projects/route.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { getDatabase } from '@/lib/db'
3
+ import { requireRole } from '@/lib/auth'
4
+ import { mutationLimiter } from '@/lib/rate-limit'
5
+ import { logger } from '@/lib/logger'
6
+
7
+ function slugify(input: string): string {
8
+ return input
9
+ .trim()
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, '-')
12
+ .replace(/^-+|-+$/g, '')
13
+ .slice(0, 64)
14
+ }
15
+
16
+ function normalizePrefix(input: string): string {
17
+ const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
18
+ return normalized.slice(0, 12)
19
+ }
20
+
21
+ export async function GET(request: NextRequest) {
22
+ const auth = requireRole(request, 'viewer')
23
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
24
+
25
+ try {
26
+ const db = getDatabase()
27
+ const workspaceId = auth.user.workspace_id ?? 1
28
+ const includeArchived = new URL(request.url).searchParams.get('includeArchived') === '1'
29
+
30
+ const projects = db.prepare(`
31
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
32
+ FROM projects
33
+ WHERE workspace_id = ?
34
+ ${includeArchived ? '' : "AND status = 'active'"}
35
+ ORDER BY name COLLATE NOCASE ASC
36
+ `).all(workspaceId)
37
+
38
+ return NextResponse.json({ projects })
39
+ } catch (error) {
40
+ logger.error({ err: error }, 'GET /api/projects error')
41
+ return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 })
42
+ }
43
+ }
44
+
45
+ export async function POST(request: NextRequest) {
46
+ const auth = requireRole(request, 'operator')
47
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
48
+
49
+ const rateCheck = mutationLimiter(request)
50
+ if (rateCheck) return rateCheck
51
+
52
+ try {
53
+ const db = getDatabase()
54
+ const workspaceId = auth.user.workspace_id ?? 1
55
+ const body = await request.json()
56
+
57
+ const name = String(body?.name || '').trim()
58
+ const description = typeof body?.description === 'string' ? body.description.trim() : ''
59
+ const prefixInput = String(body?.ticket_prefix || body?.ticketPrefix || '').trim()
60
+ const slugInput = String(body?.slug || '').trim()
61
+
62
+ if (!name) return NextResponse.json({ error: 'Project name is required' }, { status: 400 })
63
+
64
+ const slug = slugInput ? slugify(slugInput) : slugify(name)
65
+ const ticketPrefix = normalizePrefix(prefixInput || name.slice(0, 5))
66
+ if (!slug) return NextResponse.json({ error: 'Invalid project slug' }, { status: 400 })
67
+ if (!ticketPrefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 })
68
+
69
+ const exists = db.prepare(`
70
+ SELECT id FROM projects
71
+ WHERE workspace_id = ? AND (slug = ? OR ticket_prefix = ?)
72
+ LIMIT 1
73
+ `).get(workspaceId, slug, ticketPrefix) as { id: number } | undefined
74
+ if (exists) {
75
+ return NextResponse.json({ error: 'Project slug or ticket prefix already exists' }, { status: 409 })
76
+ }
77
+
78
+ const result = db.prepare(`
79
+ INSERT INTO projects (workspace_id, name, slug, description, ticket_prefix, status, created_at, updated_at)
80
+ VALUES (?, ?, ?, ?, ?, 'active', unixepoch(), unixepoch())
81
+ `).run(workspaceId, name, slug, description || null, ticketPrefix)
82
+
83
+ const project = db.prepare(`
84
+ SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at
85
+ FROM projects
86
+ WHERE id = ?
87
+ `).get(Number(result.lastInsertRowid))
88
+
89
+ return NextResponse.json({ project }, { status: 201 })
90
+ } catch (error) {
91
+ logger.error({ err: error }, 'POST /api/projects error')
92
+ return NextResponse.json({ error: 'Failed to create project' }, { status: 500 })
93
+ }
94
+ }
src/app/api/status/route.ts CHANGED
@@ -475,7 +475,10 @@ async function performHealthCheck() {
475
  async function getCapabilities() {
476
  const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort)
477
 
478
- const openclawHome = !!(config.openclawHome && existsSync(config.openclawHome))
 
 
 
479
 
480
  const claudeProjectsPath = path.join(config.claudeHome, 'projects')
481
  const claudeHome = existsSync(claudeProjectsPath)
 
475
  async function getCapabilities() {
476
  const gateway = await isPortOpen(config.gatewayHost, config.gatewayPort)
477
 
478
+ const openclawHome = Boolean(
479
+ (config.openclawStateDir && existsSync(config.openclawStateDir)) ||
480
+ (config.openclawConfigPath && existsSync(config.openclawConfigPath))
481
+ )
482
 
483
  const claudeProjectsPath = path.join(config.claudeHome, 'projects')
484
  const claudeHome = existsSync(claudeProjectsPath)
src/app/api/tasks/[id]/route.ts CHANGED
@@ -6,6 +6,20 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  function hasAegisApproval(
10
  db: ReturnType<typeof getDatabase>,
11
  taskId: number,
@@ -40,7 +54,12 @@ export async function GET(
40
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
41
  }
42
 
43
- const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?');
 
 
 
 
 
44
  const task = stmt.get(taskId, workspaceId) as Task;
45
 
46
  if (!task) {
@@ -48,11 +67,7 @@ export async function GET(
48
  }
49
 
50
  // Parse JSON fields
51
- const taskWithParsedData = {
52
- ...task,
53
- tags: task.tags ? JSON.parse(task.tags) : [],
54
- metadata: task.metadata ? JSON.parse(task.metadata) : {}
55
- };
56
 
57
  return NextResponse.json({ task: taskWithParsedData });
58
  } catch (error) {
@@ -101,6 +116,7 @@ export async function PUT(
101
  description,
102
  status,
103
  priority,
 
104
  assigned_to,
105
  due_date,
106
  estimated_hours,
@@ -114,6 +130,7 @@ export async function PUT(
114
  // Build dynamic update query
115
  const fieldsToUpdate = [];
116
  const updateParams: any[] = [];
 
117
 
118
  if (title !== undefined) {
119
  fieldsToUpdate.push('title = ?');
@@ -137,6 +154,36 @@ export async function PUT(
137
  fieldsToUpdate.push('priority = ?');
138
  updateParams.push(priority);
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  if (assigned_to !== undefined) {
141
  fieldsToUpdate.push('assigned_to = ?');
142
  updateParams.push(assigned_to);
@@ -223,6 +270,10 @@ export async function PUT(
223
  if (priority && priority !== currentTask.priority) {
224
  changes.push(`priority: ${currentTask.priority} → ${priority}`);
225
  }
 
 
 
 
226
 
227
  // Log activity if there were meaningful changes
228
  if (changes.length > 0) {
@@ -247,14 +298,13 @@ export async function PUT(
247
  }
248
 
249
  // Fetch updated task
250
- const updatedTask = db
251
- .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?')
252
- .get(taskId, workspaceId) as Task;
253
- const parsedTask = {
254
- ...updatedTask,
255
- tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [],
256
- metadata: updatedTask.metadata ? JSON.parse(updatedTask.metadata) : {}
257
- };
258
 
259
  // Broadcast to SSE clients
260
  eventBus.broadcast('task.updated', parsedTask);
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
 
9
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
11
+ return `${prefix}-${String(num).padStart(3, '0')}`
12
+ }
13
+
14
+ function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record<string, unknown> } {
15
+ return {
16
+ ...task,
17
+ tags: task.tags ? JSON.parse(task.tags) : [],
18
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
19
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
20
+ }
21
+ }
22
+
23
  function hasAegisApproval(
24
  db: ReturnType<typeof getDatabase>,
25
  taskId: number,
 
54
  return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 });
55
  }
56
 
57
+ const stmt = db.prepare(`
58
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
59
+ FROM tasks t
60
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
61
+ WHERE t.id = ? AND t.workspace_id = ?
62
+ `);
63
  const task = stmt.get(taskId, workspaceId) as Task;
64
 
65
  if (!task) {
 
67
  }
68
 
69
  // Parse JSON fields
70
+ const taskWithParsedData = mapTaskRow(task);
 
 
 
 
71
 
72
  return NextResponse.json({ task: taskWithParsedData });
73
  } catch (error) {
 
116
  description,
117
  status,
118
  priority,
119
+ project_id,
120
  assigned_to,
121
  due_date,
122
  estimated_hours,
 
130
  // Build dynamic update query
131
  const fieldsToUpdate = [];
132
  const updateParams: any[] = [];
133
+ let nextProjectTicketNo: number | null = null;
134
 
135
  if (title !== undefined) {
136
  fieldsToUpdate.push('title = ?');
 
154
  fieldsToUpdate.push('priority = ?');
155
  updateParams.push(priority);
156
  }
157
+ if (project_id !== undefined) {
158
+ const project = db.prepare(`
159
+ SELECT id FROM projects
160
+ WHERE id = ? AND workspace_id = ? AND status = 'active'
161
+ `).get(project_id, workspaceId) as { id: number } | undefined
162
+ if (!project) {
163
+ return NextResponse.json({ error: 'Project not found or archived' }, { status: 400 })
164
+ }
165
+ if (project_id !== currentTask.project_id) {
166
+ db.prepare(`
167
+ UPDATE projects
168
+ SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
169
+ WHERE id = ? AND workspace_id = ?
170
+ `).run(project_id, workspaceId)
171
+ const row = db.prepare(`
172
+ SELECT ticket_counter FROM projects
173
+ WHERE id = ? AND workspace_id = ?
174
+ `).get(project_id, workspaceId) as { ticket_counter: number } | undefined
175
+ if (!row || !row.ticket_counter) {
176
+ return NextResponse.json({ error: 'Failed to allocate project ticket number' }, { status: 500 })
177
+ }
178
+ nextProjectTicketNo = row.ticket_counter
179
+ }
180
+ fieldsToUpdate.push('project_id = ?');
181
+ updateParams.push(project_id);
182
+ if (nextProjectTicketNo !== null) {
183
+ fieldsToUpdate.push('project_ticket_no = ?');
184
+ updateParams.push(nextProjectTicketNo);
185
+ }
186
+ }
187
  if (assigned_to !== undefined) {
188
  fieldsToUpdate.push('assigned_to = ?');
189
  updateParams.push(assigned_to);
 
270
  if (priority && priority !== currentTask.priority) {
271
  changes.push(`priority: ${currentTask.priority} → ${priority}`);
272
  }
273
+
274
+ if (project_id !== undefined && project_id !== currentTask.project_id) {
275
+ changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`);
276
+ }
277
 
278
  // Log activity if there were meaningful changes
279
  if (changes.length > 0) {
 
298
  }
299
 
300
  // Fetch updated task
301
+ const updatedTask = db.prepare(`
302
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
303
+ FROM tasks t
304
+ LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id
305
+ WHERE t.id = ? AND t.workspace_id = ?
306
+ `).get(taskId, workspaceId) as Task;
307
+ const parsedTask = mapTaskRow(updatedTask);
 
308
 
309
  // Broadcast to SSE clients
310
  eventBus.broadcast('task.updated', parsedTask);
src/app/api/tasks/route.ts CHANGED
@@ -6,6 +6,43 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
10
  const review = db.prepare(`
11
  SELECT status FROM quality_reviews
@@ -18,7 +55,7 @@ function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, wo
18
 
19
  /**
20
  * GET /api/tasks - List all tasks with optional filtering
21
- * Query params: status, assigned_to, priority, limit, offset
22
  */
23
  export async function GET(request: NextRequest) {
24
  const auth = requireRole(request, 'viewer');
@@ -33,40 +70,48 @@ export async function GET(request: NextRequest) {
33
  const status = searchParams.get('status');
34
  const assigned_to = searchParams.get('assigned_to');
35
  const priority = searchParams.get('priority');
 
36
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
37
  const offset = parseInt(searchParams.get('offset') || '0');
38
 
39
  // Build dynamic query
40
- let query = 'SELECT * FROM tasks WHERE workspace_id = ?';
 
 
 
 
 
 
41
  const params: any[] = [workspaceId];
42
 
43
  if (status) {
44
- query += ' AND status = ?';
45
  params.push(status);
46
  }
47
 
48
  if (assigned_to) {
49
- query += ' AND assigned_to = ?';
50
  params.push(assigned_to);
51
  }
52
 
53
  if (priority) {
54
- query += ' AND priority = ?';
55
  params.push(priority);
56
  }
 
 
 
 
 
57
 
58
- query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
59
  params.push(limit, offset);
60
 
61
  const stmt = db.prepare(query);
62
  const tasks = stmt.all(...params) as Task[];
63
 
64
  // Parse JSON fields
65
- const tasksWithParsedData = tasks.map(task => ({
66
- ...task,
67
- tags: task.tags ? JSON.parse(task.tags) : [],
68
- metadata: task.metadata ? JSON.parse(task.metadata) : {}
69
- }));
70
 
71
  // Get total count for pagination
72
  let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
@@ -83,6 +128,10 @@ export async function GET(request: NextRequest) {
83
  countQuery += ' AND priority = ?';
84
  countParams.push(priority);
85
  }
 
 
 
 
86
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
87
 
88
  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) {
115
  description,
116
  status = 'inbox',
117
  priority = 'medium',
 
118
  assigned_to,
119
  created_by = user?.username || 'system',
120
  due_date,
@@ -130,31 +180,47 @@ export async function POST(request: NextRequest) {
130
  }
131
 
132
  const now = Math.floor(Date.now() / 1000);
133
-
134
- const stmt = db.prepare(`
135
- INSERT INTO tasks (
136
- title, description, status, priority, assigned_to, created_by,
137
- created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
138
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
139
- `);
140
-
141
- const dbResult = stmt.run(
142
- title,
143
- description,
144
- status,
145
- priority,
146
- assigned_to,
147
- created_by,
148
- now,
149
- now,
150
- due_date,
151
- estimated_hours,
152
- JSON.stringify(tags),
153
- JSON.stringify(metadata),
154
- workspaceId
155
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
- const taskId = dbResult.lastInsertRowid as number;
158
 
159
  // Log activity
160
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
@@ -183,12 +249,14 @@ export async function POST(request: NextRequest) {
183
  }
184
 
185
  // Fetch the created task
186
- const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task;
187
- const parsedTask = {
188
- ...createdTask,
189
- tags: JSON.parse(createdTask.tags || '[]'),
190
- metadata: JSON.parse(createdTask.metadata || '{}')
191
- };
 
 
192
 
193
  // Broadcast to SSE clients
194
  eventBus.broadcast('task.created', parsedTask);
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
 
9
+ function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
10
+ if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
11
+ return `${prefix}-${String(num).padStart(3, '0')}`
12
+ }
13
+
14
+ function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record<string, unknown> } {
15
+ return {
16
+ ...task,
17
+ tags: task.tags ? JSON.parse(task.tags) : [],
18
+ metadata: task.metadata ? JSON.parse(task.metadata) : {},
19
+ ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no),
20
+ }
21
+ }
22
+
23
+ function resolveProjectId(db: ReturnType<typeof getDatabase>, workspaceId: number, requestedProjectId?: number): number {
24
+ if (typeof requestedProjectId === 'number' && Number.isFinite(requestedProjectId)) {
25
+ const project = db.prepare(`
26
+ SELECT id FROM projects
27
+ WHERE id = ? AND workspace_id = ? AND status = 'active'
28
+ LIMIT 1
29
+ `).get(requestedProjectId, workspaceId) as { id: number } | undefined
30
+ if (project) return project.id
31
+ }
32
+
33
+ const fallback = db.prepare(`
34
+ SELECT id FROM projects
35
+ WHERE workspace_id = ? AND status = 'active'
36
+ ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, id ASC
37
+ LIMIT 1
38
+ `).get(workspaceId) as { id: number } | undefined
39
+
40
+ if (!fallback) {
41
+ throw new Error('No active project available in workspace')
42
+ }
43
+ return fallback.id
44
+ }
45
+
46
  function hasAegisApproval(db: ReturnType<typeof getDatabase>, taskId: number, workspaceId: number): boolean {
47
  const review = db.prepare(`
48
  SELECT status FROM quality_reviews
 
55
 
56
  /**
57
  * GET /api/tasks - List all tasks with optional filtering
58
+ * Query params: status, assigned_to, priority, project_id, limit, offset
59
  */
60
  export async function GET(request: NextRequest) {
61
  const auth = requireRole(request, 'viewer');
 
70
  const status = searchParams.get('status');
71
  const assigned_to = searchParams.get('assigned_to');
72
  const priority = searchParams.get('priority');
73
+ const projectIdParam = Number.parseInt(searchParams.get('project_id') || '', 10);
74
  const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200);
75
  const offset = parseInt(searchParams.get('offset') || '0');
76
 
77
  // Build dynamic query
78
+ let query = `
79
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
80
+ FROM tasks t
81
+ LEFT JOIN projects p
82
+ ON p.id = t.project_id AND p.workspace_id = t.workspace_id
83
+ WHERE t.workspace_id = ?
84
+ `;
85
  const params: any[] = [workspaceId];
86
 
87
  if (status) {
88
+ query += ' AND t.status = ?';
89
  params.push(status);
90
  }
91
 
92
  if (assigned_to) {
93
+ query += ' AND t.assigned_to = ?';
94
  params.push(assigned_to);
95
  }
96
 
97
  if (priority) {
98
+ query += ' AND t.priority = ?';
99
  params.push(priority);
100
  }
101
+
102
+ if (Number.isFinite(projectIdParam)) {
103
+ query += ' AND t.project_id = ?';
104
+ params.push(projectIdParam);
105
+ }
106
 
107
+ query += ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?';
108
  params.push(limit, offset);
109
 
110
  const stmt = db.prepare(query);
111
  const tasks = stmt.all(...params) as Task[];
112
 
113
  // Parse JSON fields
114
+ const tasksWithParsedData = tasks.map(mapTaskRow);
 
 
 
 
115
 
116
  // Get total count for pagination
117
  let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?';
 
128
  countQuery += ' AND priority = ?';
129
  countParams.push(priority);
130
  }
131
+ if (Number.isFinite(projectIdParam)) {
132
+ countQuery += ' AND project_id = ?';
133
+ countParams.push(projectIdParam);
134
+ }
135
  const countRow = db.prepare(countQuery).get(...countParams) as { total: number };
136
 
137
  return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit });
 
164
  description,
165
  status = 'inbox',
166
  priority = 'medium',
167
+ project_id,
168
  assigned_to,
169
  created_by = user?.username || 'system',
170
  due_date,
 
180
  }
181
 
182
  const now = Math.floor(Date.now() / 1000);
183
+ const createTaskTx = db.transaction(() => {
184
+ const resolvedProjectId = resolveProjectId(db, workspaceId, project_id)
185
+ db.prepare(`
186
+ UPDATE projects
187
+ SET ticket_counter = ticket_counter + 1, updated_at = unixepoch()
188
+ WHERE id = ? AND workspace_id = ?
189
+ `).run(resolvedProjectId, workspaceId)
190
+ const row = db.prepare(`
191
+ SELECT ticket_counter FROM projects
192
+ WHERE id = ? AND workspace_id = ?
193
+ `).get(resolvedProjectId, workspaceId) as { ticket_counter: number } | undefined
194
+ if (!row || !row.ticket_counter) throw new Error('Failed to allocate project ticket number')
195
+
196
+ const insertStmt = db.prepare(`
197
+ INSERT INTO tasks (
198
+ title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by,
199
+ created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id
200
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
201
+ `)
202
+
203
+ const dbResult = insertStmt.run(
204
+ title,
205
+ description,
206
+ status,
207
+ priority,
208
+ resolvedProjectId,
209
+ row.ticket_counter,
210
+ assigned_to,
211
+ created_by,
212
+ now,
213
+ now,
214
+ due_date,
215
+ estimated_hours,
216
+ JSON.stringify(tags),
217
+ JSON.stringify(metadata),
218
+ workspaceId
219
+ )
220
+ return Number(dbResult.lastInsertRowid)
221
+ })
222
 
223
+ const taskId = createTaskTx()
224
 
225
  // Log activity
226
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
 
249
  }
250
 
251
  // Fetch the created task
252
+ const createdTask = db.prepare(`
253
+ SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix
254
+ FROM tasks t
255
+ LEFT JOIN projects p
256
+ ON p.id = t.project_id AND p.workspace_id = t.workspace_id
257
+ WHERE t.id = ? AND t.workspace_id = ?
258
+ `).get(taskId, workspaceId) as Task;
259
+ const parsedTask = mapTaskRow(createdTask);
260
 
261
  // Broadcast to SSE clients
262
  eventBus.broadcast('task.created', parsedTask);
src/components/layout/header-bar.tsx CHANGED
@@ -43,6 +43,7 @@ export function HeaderBar() {
43
  alerts: 'Alert Rules',
44
  gateways: 'Gateway Manager',
45
  users: 'Users',
 
46
  'gateway-config': 'Gateway Config',
47
  settings: 'Settings',
48
  }
 
43
  alerts: 'Alert Rules',
44
  gateways: 'Gateway Manager',
45
  users: 'Users',
46
+ workspaces: 'Workspaces',
47
  'gateway-config': 'Gateway Config',
48
  settings: 'Settings',
49
  }
src/components/layout/nav-rail.tsx CHANGED
@@ -61,6 +61,7 @@ const navGroups: NavGroup[] = [
61
  { id: 'gateways', label: 'Gateways', icon: <GatewaysIcon />, priority: false },
62
  { id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false, requiresGateway: true },
63
  { id: 'integrations', label: 'Integrations', icon: <IntegrationsIcon />, priority: false },
 
64
  { id: 'super-admin', label: 'Super Admin', icon: <SuperAdminIcon />, priority: false },
65
  { id: 'settings', label: 'Settings', icon: <SettingsIcon />, priority: false },
66
  ],
 
61
  { id: 'gateways', label: 'Gateways', icon: <GatewaysIcon />, priority: false },
62
  { id: 'gateway-config', label: 'Config', icon: <GatewayConfigIcon />, priority: false, requiresGateway: true },
63
  { id: 'integrations', label: 'Integrations', icon: <IntegrationsIcon />, priority: false },
64
+ { id: 'workspaces', label: 'Workspaces', icon: <SuperAdminIcon />, priority: false },
65
  { id: 'super-admin', label: 'Super Admin', icon: <SuperAdminIcon />, priority: false },
66
  { id: 'settings', label: 'Settings', icon: <SettingsIcon />, priority: false },
67
  ],
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -2,9 +2,9 @@
2
 
3
  import { useState, useEffect } from 'react'
4
  import { createClientLogger } from '@/lib/client-logger'
5
- const log = createClientLogger('AgentDetailTabs')
6
  import Link from 'next/link'
7
 
 
8
 
9
  interface Agent {
10
  id: number
@@ -672,7 +672,10 @@ export function TasksTab({ agent }: { agent: Agent }) {
672
  <Link href={`/tasks?taskId=${task.id}`} className="font-medium text-foreground hover:text-primary transition-colors">
673
  {task.title}
674
  </Link>
675
- <div className="text-xs text-muted-foreground mt-1">Task #{task.id}</div>
 
 
 
676
  {task.description && (
677
  <p className="text-foreground/80 text-sm mt-1">{task.description}</p>
678
  )}
 
2
 
3
  import { useState, useEffect } from 'react'
4
  import { createClientLogger } from '@/lib/client-logger'
 
5
  import Link from 'next/link'
6
 
7
+ const log = createClientLogger('AgentDetailTabs')
8
 
9
  interface Agent {
10
  id: number
 
672
  <Link href={`/tasks?taskId=${task.id}`} className="font-medium text-foreground hover:text-primary transition-colors">
673
  {task.title}
674
  </Link>
675
+ <div className="text-xs text-muted-foreground mt-1">
676
+ {task.ticket_ref || `Task #${task.id}`}
677
+ {task.project_name ? ` · ${task.project_name}` : ''}
678
+ </div>
679
  {task.description && (
680
  <p className="text-foreground/80 text-sm mt-1">{task.description}</p>
681
  )}
src/components/panels/gateway-config-panel.tsx CHANGED
@@ -113,7 +113,7 @@ export function GatewayConfigPanel() {
113
  <div className="p-6">
114
  <div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div>
115
  <p className="text-xs text-muted-foreground mt-2">
116
- Ensure OPENCLAW_HOME is set and openclaw.json exists at the expected path.
117
  </p>
118
  </div>
119
  )
 
113
  <div className="p-6">
114
  <div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div>
115
  <p className="text-xs text-muted-foreground mt-2">
116
+ Ensure `OPENCLAW_CONFIG_PATH` (or `OPENCLAW_STATE_DIR`) is set and the config file exists.
117
  </p>
118
  </div>
119
  )
src/components/panels/task-board-panel.tsx CHANGED
@@ -30,6 +30,11 @@ interface Task {
30
  tags?: string[]
31
  metadata?: any
32
  aegisApproved?: boolean
 
 
 
 
 
33
  }
34
 
35
  interface Agent {
@@ -56,6 +61,14 @@ interface Comment {
56
  replies?: Comment[]
57
  }
58
 
 
 
 
 
 
 
 
 
59
  const statusColumns = [
60
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
61
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
@@ -78,11 +91,14 @@ export function TaskBoardPanel() {
78
  const pathname = usePathname()
79
  const searchParams = useSearchParams()
80
  const [agents, setAgents] = useState<Agent[]>([])
 
 
81
  const [loading, setLoading] = useState(true)
82
  const [error, setError] = useState<string | null>(null)
83
  const [aegisMap, setAegisMap] = useState<Record<number, boolean>>({})
84
  const [draggedTask, setDraggedTask] = useState<Task | null>(null)
85
  const [showCreateModal, setShowCreateModal] = useState(false)
 
86
  const [editingTask, setEditingTask] = useState<Task | null>(null)
87
  const dragCounter = useRef(0)
88
  const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
@@ -109,23 +125,31 @@ export function TaskBoardPanel() {
109
  aegisApproved: Boolean(aegisMap[t.id])
110
  }))
111
 
112
- // Fetch tasks and agents
113
  const fetchData = useCallback(async () => {
114
  try {
115
  setLoading(true)
116
  setError(null)
117
 
118
- const [tasksResponse, agentsResponse] = await Promise.all([
119
- fetch('/api/tasks'),
120
- fetch('/api/agents')
 
 
 
 
 
 
 
121
  ])
122
 
123
- if (!tasksResponse.ok || !agentsResponse.ok) {
124
  throw new Error('Failed to fetch data')
125
  }
126
 
127
  const tasksData = await tasksResponse.json()
128
  const agentsData = await agentsResponse.json()
 
129
 
130
  const tasksList = tasksData.tasks || []
131
  const taskIds = tasksList.map((task: Task) => task.id)
@@ -152,12 +176,13 @@ export function TaskBoardPanel() {
152
  storeSetTasks(tasksList)
153
  setAegisMap(newAegisMap)
154
  setAgents(agentsData.agents || [])
 
155
  } catch (err) {
156
  setError(err instanceof Error ? err.message : 'An error occurred')
157
  } finally {
158
  setLoading(false)
159
  }
160
- }, [storeSetTasks])
161
 
162
  useEffect(() => {
163
  fetchData()
@@ -327,8 +352,28 @@ export function TaskBoardPanel() {
327
  <div className="h-full flex flex-col">
328
  {/* Header */}
329
  <div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
330
- <h2 className="text-xl font-bold text-foreground">Task Board</h2>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  <div className="flex gap-2">
 
 
 
 
 
 
332
  <button
333
  onClick={() => setShowCreateModal(true)}
334
  className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm font-medium"
@@ -409,6 +454,11 @@ export function TaskBoardPanel() {
409
  {task.title}
410
  </h4>
411
  <div className="flex items-center gap-2">
 
 
 
 
 
412
  {task.aegisApproved && (
413
  <span className="text-[10px] px-2 py-0.5 rounded bg-emerald-700 text-emerald-100">
414
  Aegis Approved
@@ -445,6 +495,12 @@ export function TaskBoardPanel() {
445
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
446
  </div>
447
 
 
 
 
 
 
 
448
  {task.tags && task.tags.length > 0 && (
449
  <div className="flex flex-wrap gap-1 mt-2">
450
  {task.tags.slice(0, 3).map((tag, index) => (
@@ -496,6 +552,7 @@ export function TaskBoardPanel() {
496
  <TaskDetailModal
497
  task={selectedTask}
498
  agents={agents}
 
499
  onClose={() => {
500
  setSelectedTask(null)
501
  updateTaskUrl(null)
@@ -513,6 +570,7 @@ export function TaskBoardPanel() {
513
  {showCreateModal && (
514
  <CreateTaskModal
515
  agents={agents}
 
516
  onClose={() => setShowCreateModal(false)}
517
  onCreated={fetchData}
518
  />
@@ -523,10 +581,18 @@ export function TaskBoardPanel() {
523
  <EditTaskModal
524
  task={editingTask}
525
  agents={agents}
 
526
  onClose={() => setEditingTask(null)}
527
  onUpdated={() => { fetchData(); setEditingTask(null) }}
528
  />
529
  )}
 
 
 
 
 
 
 
530
  </div>
531
  )
532
  }
@@ -535,16 +601,21 @@ export function TaskBoardPanel() {
535
  function TaskDetailModal({
536
  task,
537
  agents,
 
538
  onClose,
539
  onUpdate,
540
  onEdit
541
  }: {
542
  task: Task
543
  agents: Agent[]
 
544
  onClose: () => void
545
  onUpdate: () => void
546
  onEdit: (task: Task) => void
547
  }) {
 
 
 
548
  const [comments, setComments] = useState<Comment[]>([])
549
  const [loadingComments, setLoadingComments] = useState(false)
550
  const [commentText, setCommentText] = useState('')
@@ -728,6 +799,18 @@ function TaskDetailModal({
728
 
729
  {activeTab === 'details' && (
730
  <div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
 
 
 
 
 
 
 
 
 
 
 
 
731
  <div>
732
  <span className="text-muted-foreground">Status:</span>
733
  <span className="text-foreground ml-2">{task.status}</span>
@@ -903,10 +986,12 @@ function TaskDetailModal({
903
  // Create Task Modal Component (placeholder)
904
  function CreateTaskModal({
905
  agents,
 
906
  onClose,
907
  onCreated
908
  }: {
909
  agents: Agent[]
 
910
  onClose: () => void
911
  onCreated: () => void
912
  }) {
@@ -914,6 +999,7 @@ function CreateTaskModal({
914
  title: '',
915
  description: '',
916
  priority: 'medium' as Task['priority'],
 
917
  assigned_to: '',
918
  tags: '',
919
  })
@@ -929,6 +1015,7 @@ function CreateTaskModal({
929
  headers: { 'Content-Type': 'application/json' },
930
  body: JSON.stringify({
931
  ...formData,
 
932
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
933
  assigned_to: formData.assigned_to || undefined
934
  })
@@ -994,25 +1081,41 @@ function CreateTaskModal({
994
  <option value="critical">Critical</option>
995
  </select>
996
  </div>
997
-
998
  <div>
999
- <label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1000
  <select
1001
- id="create-assignee"
1002
- value={formData.assigned_to}
1003
- onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
1004
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1005
  >
1006
- <option value="">Unassigned</option>
1007
- {agents.map(agent => (
1008
- <option key={agent.name} value={agent.name}>
1009
- {agent.name} ({agent.role})
1010
  </option>
1011
  ))}
1012
  </select>
1013
  </div>
1014
  </div>
1015
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
  <div>
1017
  <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
1018
  <input
@@ -1051,11 +1154,13 @@ function CreateTaskModal({
1051
  function EditTaskModal({
1052
  task,
1053
  agents,
 
1054
  onClose,
1055
  onUpdated
1056
  }: {
1057
  task: Task
1058
  agents: Agent[]
 
1059
  onClose: () => void
1060
  onUpdated: () => void
1061
  }) {
@@ -1064,6 +1169,7 @@ function EditTaskModal({
1064
  description: task.description || '',
1065
  priority: task.priority,
1066
  status: task.status,
 
1067
  assigned_to: task.assigned_to || '',
1068
  tags: task.tags ? task.tags.join(', ') : '',
1069
  })
@@ -1079,6 +1185,7 @@ function EditTaskModal({
1079
  headers: { 'Content-Type': 'application/json' },
1080
  body: JSON.stringify({
1081
  ...formData,
 
1082
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1083
  assigned_to: formData.assigned_to || undefined
1084
  })
@@ -1162,6 +1269,22 @@ function EditTaskModal({
1162
  </div>
1163
  </div>
1164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
  <div>
1166
  <label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1167
  <select
@@ -1212,3 +1335,165 @@ function EditTaskModal({
1212
  </div>
1213
  )
1214
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  tags?: string[]
31
  metadata?: any
32
  aegisApproved?: boolean
33
+ project_id?: number
34
+ project_ticket_no?: number
35
+ project_name?: string
36
+ project_prefix?: string
37
+ ticket_ref?: string
38
  }
39
 
40
  interface Agent {
 
61
  replies?: Comment[]
62
  }
63
 
64
+ interface Project {
65
+ id: number
66
+ name: string
67
+ slug: string
68
+ ticket_prefix: string
69
+ status: 'active' | 'archived'
70
+ }
71
+
72
  const statusColumns = [
73
  { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' },
74
  { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' },
 
91
  const pathname = usePathname()
92
  const searchParams = useSearchParams()
93
  const [agents, setAgents] = useState<Agent[]>([])
94
+ const [projects, setProjects] = useState<Project[]>([])
95
+ const [projectFilter, setProjectFilter] = useState<string>('all')
96
  const [loading, setLoading] = useState(true)
97
  const [error, setError] = useState<string | null>(null)
98
  const [aegisMap, setAegisMap] = useState<Record<number, boolean>>({})
99
  const [draggedTask, setDraggedTask] = useState<Task | null>(null)
100
  const [showCreateModal, setShowCreateModal] = useState(false)
101
+ const [showProjectManager, setShowProjectManager] = useState(false)
102
  const [editingTask, setEditingTask] = useState<Task | null>(null)
103
  const dragCounter = useRef(0)
104
  const selectedTaskIdFromUrl = Number.parseInt(searchParams.get('taskId') || '', 10)
 
125
  aegisApproved: Boolean(aegisMap[t.id])
126
  }))
127
 
128
+ // Fetch tasks, agents, and projects
129
  const fetchData = useCallback(async () => {
130
  try {
131
  setLoading(true)
132
  setError(null)
133
 
134
+ const tasksQuery = new URLSearchParams()
135
+ if (projectFilter !== 'all') {
136
+ tasksQuery.set('project_id', projectFilter)
137
+ }
138
+ const tasksUrl = tasksQuery.toString() ? `/api/tasks?${tasksQuery.toString()}` : '/api/tasks'
139
+
140
+ const [tasksResponse, agentsResponse, projectsResponse] = await Promise.all([
141
+ fetch(tasksUrl),
142
+ fetch('/api/agents'),
143
+ fetch('/api/projects')
144
  ])
145
 
146
+ if (!tasksResponse.ok || !agentsResponse.ok || !projectsResponse.ok) {
147
  throw new Error('Failed to fetch data')
148
  }
149
 
150
  const tasksData = await tasksResponse.json()
151
  const agentsData = await agentsResponse.json()
152
+ const projectsData = await projectsResponse.json()
153
 
154
  const tasksList = tasksData.tasks || []
155
  const taskIds = tasksList.map((task: Task) => task.id)
 
176
  storeSetTasks(tasksList)
177
  setAegisMap(newAegisMap)
178
  setAgents(agentsData.agents || [])
179
+ setProjects(projectsData.projects || [])
180
  } catch (err) {
181
  setError(err instanceof Error ? err.message : 'An error occurred')
182
  } finally {
183
  setLoading(false)
184
  }
185
+ }, [projectFilter, storeSetTasks])
186
 
187
  useEffect(() => {
188
  fetchData()
 
352
  <div className="h-full flex flex-col">
353
  {/* Header */}
354
  <div className="flex justify-between items-center p-4 border-b border-border flex-shrink-0">
355
+ <div className="flex items-center gap-3">
356
+ <h2 className="text-xl font-bold text-foreground">Task Board</h2>
357
+ <select
358
+ value={projectFilter}
359
+ onChange={(e) => setProjectFilter(e.target.value)}
360
+ className="h-9 px-3 bg-surface-1 text-foreground border border-border rounded-md text-sm"
361
+ >
362
+ <option value="all">All Projects</option>
363
+ {projects.map((project) => (
364
+ <option key={project.id} value={String(project.id)}>
365
+ {project.name} ({project.ticket_prefix})
366
+ </option>
367
+ ))}
368
+ </select>
369
+ </div>
370
  <div className="flex gap-2">
371
+ <button
372
+ onClick={() => setShowProjectManager(true)}
373
+ className="px-4 py-2 bg-secondary text-muted-foreground rounded-md hover:bg-surface-2 transition-smooth text-sm font-medium"
374
+ >
375
+ Projects
376
+ </button>
377
  <button
378
  onClick={() => setShowCreateModal(true)}
379
  className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-smooth text-sm font-medium"
 
454
  {task.title}
455
  </h4>
456
  <div className="flex items-center gap-2">
457
+ {task.ticket_ref && (
458
+ <span className="text-[10px] px-2 py-0.5 rounded bg-primary/20 text-primary">
459
+ {task.ticket_ref}
460
+ </span>
461
+ )}
462
  {task.aegisApproved && (
463
  <span className="text-[10px] px-2 py-0.5 rounded bg-emerald-700 text-emerald-100">
464
  Aegis Approved
 
495
  <span className="font-medium">{formatTaskTimestamp(task.created_at)}</span>
496
  </div>
497
 
498
+ {task.project_name && (
499
+ <div className="text-xs text-muted-foreground mt-1">
500
+ Project: {task.project_name}
501
+ </div>
502
+ )}
503
+
504
  {task.tags && task.tags.length > 0 && (
505
  <div className="flex flex-wrap gap-1 mt-2">
506
  {task.tags.slice(0, 3).map((tag, index) => (
 
552
  <TaskDetailModal
553
  task={selectedTask}
554
  agents={agents}
555
+ projects={projects}
556
  onClose={() => {
557
  setSelectedTask(null)
558
  updateTaskUrl(null)
 
570
  {showCreateModal && (
571
  <CreateTaskModal
572
  agents={agents}
573
+ projects={projects}
574
  onClose={() => setShowCreateModal(false)}
575
  onCreated={fetchData}
576
  />
 
581
  <EditTaskModal
582
  task={editingTask}
583
  agents={agents}
584
+ projects={projects}
585
  onClose={() => setEditingTask(null)}
586
  onUpdated={() => { fetchData(); setEditingTask(null) }}
587
  />
588
  )}
589
+
590
+ {showProjectManager && (
591
+ <ProjectManagerModal
592
+ onClose={() => setShowProjectManager(false)}
593
+ onChanged={fetchData}
594
+ />
595
+ )}
596
  </div>
597
  )
598
  }
 
601
  function TaskDetailModal({
602
  task,
603
  agents,
604
+ projects,
605
  onClose,
606
  onUpdate,
607
  onEdit
608
  }: {
609
  task: Task
610
  agents: Agent[]
611
+ projects: Project[]
612
  onClose: () => void
613
  onUpdate: () => void
614
  onEdit: (task: Task) => void
615
  }) {
616
+ const resolvedProjectName =
617
+ task.project_name ||
618
+ projects.find((project) => project.id === task.project_id)?.name
619
  const [comments, setComments] = useState<Comment[]>([])
620
  const [loadingComments, setLoadingComments] = useState(false)
621
  const [commentText, setCommentText] = useState('')
 
799
 
800
  {activeTab === 'details' && (
801
  <div id="tabpanel-details" role="tabpanel" aria-label="Details" className="grid grid-cols-2 gap-4 text-sm mt-4">
802
+ {task.ticket_ref && (
803
+ <div>
804
+ <span className="text-muted-foreground">Ticket:</span>
805
+ <span className="text-foreground ml-2 font-mono">{task.ticket_ref}</span>
806
+ </div>
807
+ )}
808
+ {resolvedProjectName && (
809
+ <div>
810
+ <span className="text-muted-foreground">Project:</span>
811
+ <span className="text-foreground ml-2">{resolvedProjectName}</span>
812
+ </div>
813
+ )}
814
  <div>
815
  <span className="text-muted-foreground">Status:</span>
816
  <span className="text-foreground ml-2">{task.status}</span>
 
986
  // Create Task Modal Component (placeholder)
987
  function CreateTaskModal({
988
  agents,
989
+ projects,
990
  onClose,
991
  onCreated
992
  }: {
993
  agents: Agent[]
994
+ projects: Project[]
995
  onClose: () => void
996
  onCreated: () => void
997
  }) {
 
999
  title: '',
1000
  description: '',
1001
  priority: 'medium' as Task['priority'],
1002
+ project_id: projects[0]?.id ? String(projects[0].id) : '',
1003
  assigned_to: '',
1004
  tags: '',
1005
  })
 
1015
  headers: { 'Content-Type': 'application/json' },
1016
  body: JSON.stringify({
1017
  ...formData,
1018
+ project_id: formData.project_id ? Number(formData.project_id) : undefined,
1019
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1020
  assigned_to: formData.assigned_to || undefined
1021
  })
 
1081
  <option value="critical">Critical</option>
1082
  </select>
1083
  </div>
1084
+
1085
  <div>
1086
+ <label htmlFor="create-project" className="block text-sm text-muted-foreground mb-1">Project</label>
1087
  <select
1088
+ id="create-project"
1089
+ value={formData.project_id}
1090
+ onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
1091
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1092
  >
1093
+ {projects.map(project => (
1094
+ <option key={project.id} value={String(project.id)}>
1095
+ {project.name} ({project.ticket_prefix})
 
1096
  </option>
1097
  ))}
1098
  </select>
1099
  </div>
1100
  </div>
1101
+
1102
+ <div>
1103
+ <label htmlFor="create-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1104
+ <select
1105
+ id="create-assignee"
1106
+ value={formData.assigned_to}
1107
+ onChange={(e) => setFormData(prev => ({ ...prev, assigned_to: e.target.value }))}
1108
+ className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1109
+ >
1110
+ <option value="">Unassigned</option>
1111
+ {agents.map(agent => (
1112
+ <option key={agent.name} value={agent.name}>
1113
+ {agent.name} ({agent.role})
1114
+ </option>
1115
+ ))}
1116
+ </select>
1117
+ </div>
1118
+
1119
  <div>
1120
  <label htmlFor="create-tags" className="block text-sm text-muted-foreground mb-1">Tags (comma-separated)</label>
1121
  <input
 
1154
  function EditTaskModal({
1155
  task,
1156
  agents,
1157
+ projects,
1158
  onClose,
1159
  onUpdated
1160
  }: {
1161
  task: Task
1162
  agents: Agent[]
1163
+ projects: Project[]
1164
  onClose: () => void
1165
  onUpdated: () => void
1166
  }) {
 
1169
  description: task.description || '',
1170
  priority: task.priority,
1171
  status: task.status,
1172
+ project_id: task.project_id ? String(task.project_id) : (projects[0]?.id ? String(projects[0].id) : ''),
1173
  assigned_to: task.assigned_to || '',
1174
  tags: task.tags ? task.tags.join(', ') : '',
1175
  })
 
1185
  headers: { 'Content-Type': 'application/json' },
1186
  body: JSON.stringify({
1187
  ...formData,
1188
+ project_id: formData.project_id ? Number(formData.project_id) : undefined,
1189
  tags: formData.tags ? formData.tags.split(',').map(t => t.trim()) : [],
1190
  assigned_to: formData.assigned_to || undefined
1191
  })
 
1269
  </div>
1270
  </div>
1271
 
1272
+ <div>
1273
+ <label htmlFor="edit-project" className="block text-sm text-muted-foreground mb-1">Project</label>
1274
+ <select
1275
+ id="edit-project"
1276
+ value={formData.project_id}
1277
+ onChange={(e) => setFormData(prev => ({ ...prev, project_id: e.target.value }))}
1278
+ className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary/50"
1279
+ >
1280
+ {projects.map(project => (
1281
+ <option key={project.id} value={String(project.id)}>
1282
+ {project.name} ({project.ticket_prefix})
1283
+ </option>
1284
+ ))}
1285
+ </select>
1286
+ </div>
1287
+
1288
  <div>
1289
  <label htmlFor="edit-assignee" className="block text-sm text-muted-foreground mb-1">Assign to</label>
1290
  <select
 
1335
  </div>
1336
  )
1337
  }
1338
+
1339
+ function ProjectManagerModal({
1340
+ onClose,
1341
+ onChanged
1342
+ }: {
1343
+ onClose: () => void
1344
+ onChanged: () => Promise<void>
1345
+ }) {
1346
+ const [projects, setProjects] = useState<Project[]>([])
1347
+ const [loading, setLoading] = useState(true)
1348
+ const [error, setError] = useState<string | null>(null)
1349
+ const [form, setForm] = useState({ name: '', ticket_prefix: '', description: '' })
1350
+
1351
+ const load = useCallback(async () => {
1352
+ try {
1353
+ setLoading(true)
1354
+ const response = await fetch('/api/projects?includeArchived=1')
1355
+ const data = await response.json()
1356
+ if (!response.ok) throw new Error(data.error || 'Failed to load projects')
1357
+ setProjects(data.projects || [])
1358
+ setError(null)
1359
+ } catch (err) {
1360
+ setError(err instanceof Error ? err.message : 'Failed to load projects')
1361
+ } finally {
1362
+ setLoading(false)
1363
+ }
1364
+ }, [])
1365
+
1366
+ useEffect(() => {
1367
+ load()
1368
+ }, [load])
1369
+
1370
+ const createProject = async (e: React.FormEvent) => {
1371
+ e.preventDefault()
1372
+ if (!form.name.trim()) return
1373
+ try {
1374
+ const response = await fetch('/api/projects', {
1375
+ method: 'POST',
1376
+ headers: { 'Content-Type': 'application/json' },
1377
+ body: JSON.stringify({
1378
+ name: form.name,
1379
+ ticket_prefix: form.ticket_prefix,
1380
+ description: form.description
1381
+ })
1382
+ })
1383
+ const data = await response.json()
1384
+ if (!response.ok) throw new Error(data.error || 'Failed to create project')
1385
+ setForm({ name: '', ticket_prefix: '', description: '' })
1386
+ await load()
1387
+ await onChanged()
1388
+ } catch (err) {
1389
+ setError(err instanceof Error ? err.message : 'Failed to create project')
1390
+ }
1391
+ }
1392
+
1393
+ const archiveProject = async (project: Project) => {
1394
+ try {
1395
+ const response = await fetch(`/api/projects/${project.id}`, {
1396
+ method: 'PATCH',
1397
+ headers: { 'Content-Type': 'application/json' },
1398
+ body: JSON.stringify({ status: project.status === 'active' ? 'archived' : 'active' })
1399
+ })
1400
+ const data = await response.json()
1401
+ if (!response.ok) throw new Error(data.error || 'Failed to update project')
1402
+ await load()
1403
+ await onChanged()
1404
+ } catch (err) {
1405
+ setError(err instanceof Error ? err.message : 'Failed to update project')
1406
+ }
1407
+ }
1408
+
1409
+ const deleteProject = async (project: Project) => {
1410
+ if (!confirm(`Delete project "${project.name}"? Existing tasks will be moved to General.`)) return
1411
+ try {
1412
+ const response = await fetch(`/api/projects/${project.id}?mode=delete`, { method: 'DELETE' })
1413
+ const data = await response.json()
1414
+ if (!response.ok) throw new Error(data.error || 'Failed to delete project')
1415
+ await load()
1416
+ await onChanged()
1417
+ } catch (err) {
1418
+ setError(err instanceof Error ? err.message : 'Failed to delete project')
1419
+ }
1420
+ }
1421
+
1422
+ const dialogRef = useFocusTrap(onClose)
1423
+
1424
+ return (
1425
+ <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
1426
+ <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="projects-title" className="bg-card border border-border rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
1427
+ <div className="p-6 space-y-4">
1428
+ <div className="flex items-center justify-between">
1429
+ <h3 id="projects-title" className="text-xl font-bold text-foreground">Project Management</h3>
1430
+ <button onClick={onClose} className="text-muted-foreground hover:text-foreground text-2xl">×</button>
1431
+ </div>
1432
+
1433
+ {error && <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/20 rounded p-2">{error}</div>}
1434
+
1435
+ <form onSubmit={createProject} className="grid grid-cols-1 md:grid-cols-3 gap-3">
1436
+ <input
1437
+ type="text"
1438
+ value={form.name}
1439
+ onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
1440
+ placeholder="Project name"
1441
+ className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1442
+ required
1443
+ />
1444
+ <input
1445
+ type="text"
1446
+ value={form.ticket_prefix}
1447
+ onChange={(e) => setForm((prev) => ({ ...prev, ticket_prefix: e.target.value }))}
1448
+ placeholder="Ticket prefix (e.g. PA)"
1449
+ className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1450
+ />
1451
+ <button type="submit" className="bg-primary text-primary-foreground rounded-md px-3 py-2 hover:bg-primary/90">
1452
+ Add Project
1453
+ </button>
1454
+ <input
1455
+ type="text"
1456
+ value={form.description}
1457
+ onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
1458
+ placeholder="Description (optional)"
1459
+ className="md:col-span-3 bg-surface-1 text-foreground border border-border rounded-md px-3 py-2"
1460
+ />
1461
+ </form>
1462
+
1463
+ {loading ? (
1464
+ <div className="text-sm text-muted-foreground">Loading projects...</div>
1465
+ ) : (
1466
+ <div className="space-y-2">
1467
+ {projects.map((project) => (
1468
+ <div key={project.id} className="flex items-center justify-between border border-border rounded-md p-3">
1469
+ <div>
1470
+ <div className="text-sm font-medium text-foreground">{project.name}</div>
1471
+ <div className="text-xs text-muted-foreground">{project.ticket_prefix} · {project.slug} · {project.status}</div>
1472
+ </div>
1473
+ <div className="flex gap-2">
1474
+ {project.slug !== 'general' && (
1475
+ <>
1476
+ <button
1477
+ onClick={() => archiveProject(project)}
1478
+ className="px-3 py-1 text-xs rounded border border-border hover:bg-secondary"
1479
+ >
1480
+ {project.status === 'active' ? 'Archive' : 'Activate'}
1481
+ </button>
1482
+ <button
1483
+ onClick={() => deleteProject(project)}
1484
+ className="px-3 py-1 text-xs rounded border border-red-500/30 text-red-400 hover:bg-red-500/10"
1485
+ >
1486
+ Delete
1487
+ </button>
1488
+ </>
1489
+ )}
1490
+ </div>
1491
+ </div>
1492
+ ))}
1493
+ </div>
1494
+ )}
1495
+ </div>
1496
+ </div>
1497
+ </div>
1498
+ )
1499
+ }
src/index.ts CHANGED
@@ -87,6 +87,11 @@ export interface Task {
87
  description?: string
88
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
89
  priority: 'low' | 'medium' | 'high' | 'urgent'
 
 
 
 
 
90
  assigned_to?: string
91
  created_by: string
92
  created_at: number
 
87
  description?: string
88
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
89
  priority: 'low' | 'medium' | 'high' | 'urgent'
90
+ project_id?: number
91
+ project_ticket_no?: number
92
+ project_name?: string
93
+ project_prefix?: string
94
+ ticket_ref?: string
95
  assigned_to?: string
96
  created_by: string
97
  created_at: number
src/lib/agent-sync.ts CHANGED
@@ -8,7 +8,7 @@
8
  import { config } from './config'
9
  import { getDatabase, db_helpers, logAuditEvent } from './db'
10
  import { eventBus } from './event-bus'
11
- import { join } from 'path'
12
  import { existsSync, readFileSync } from 'fs'
13
  import { resolveWithin } from './paths'
14
  import { logger } from './logger'
@@ -127,15 +127,22 @@ function parseToolsFromFile(content: string): { allow?: string[]; raw?: string }
127
  }
128
 
129
  function getConfigPath(): string | null {
130
- if (!config.openclawHome) return null
131
- return join(config.openclawHome, 'openclaw.json')
 
 
 
 
 
 
 
132
  }
133
 
134
  /** Safely read a file from an agent's workspace directory */
135
  function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
136
- if (!workspace || !config.openclawHome) return null
137
  try {
138
- const safeWorkspace = resolveWithin(config.openclawHome, workspace)
139
  const safePath = resolveWithin(safeWorkspace, filename)
140
  if (existsSync(safePath)) {
141
  return readFileSync(safePath, 'utf-8')
@@ -173,7 +180,7 @@ export function enrichAgentConfigFromWorkspace(configData: any): any {
173
  /** Read and parse openclaw.json agents list */
174
  async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
175
  const configPath = getConfigPath()
176
- if (!configPath) throw new Error('OPENCLAW_HOME not configured')
177
 
178
  const { readFile } = require('fs/promises')
179
  const raw = await readFile(configPath, 'utf-8')
@@ -334,7 +341,7 @@ export async function previewSyncDiff(): Promise<SyncDiff> {
334
  /** Write an agent config back to openclaw.json agents.list */
335
  export async function writeAgentToConfig(agentConfig: any): Promise<void> {
336
  const configPath = getConfigPath()
337
- if (!configPath) throw new Error('OPENCLAW_HOME not configured')
338
 
339
  const { readFile, writeFile } = require('fs/promises')
340
  const raw = await readFile(configPath, 'utf-8')
 
8
  import { config } from './config'
9
  import { getDatabase, db_helpers, logAuditEvent } from './db'
10
  import { eventBus } from './event-bus'
11
+ import { join, isAbsolute, resolve } from 'path'
12
  import { existsSync, readFileSync } from 'fs'
13
  import { resolveWithin } from './paths'
14
  import { logger } from './logger'
 
127
  }
128
 
129
  function getConfigPath(): string | null {
130
+ return config.openclawConfigPath || null
131
+ }
132
+
133
+ function resolveAgentWorkspacePath(workspace: string): string {
134
+ if (isAbsolute(workspace)) return resolve(workspace)
135
+ if (!config.openclawStateDir) {
136
+ throw new Error('OPENCLAW_STATE_DIR not configured')
137
+ }
138
+ return resolveWithin(config.openclawStateDir, workspace)
139
  }
140
 
141
  /** Safely read a file from an agent's workspace directory */
142
  function readWorkspaceFile(workspace: string | undefined, filename: string): string | null {
143
+ if (!workspace) return null
144
  try {
145
+ const safeWorkspace = resolveAgentWorkspacePath(workspace)
146
  const safePath = resolveWithin(safeWorkspace, filename)
147
  if (existsSync(safePath)) {
148
  return readFileSync(safePath, 'utf-8')
 
180
  /** Read and parse openclaw.json agents list */
181
  async function readOpenClawAgents(): Promise<OpenClawAgent[]> {
182
  const configPath = getConfigPath()
183
+ if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured')
184
 
185
  const { readFile } = require('fs/promises')
186
  const raw = await readFile(configPath, 'utf-8')
 
341
  /** Write an agent config back to openclaw.json agents.list */
342
  export async function writeAgentToConfig(agentConfig: any): Promise<void> {
343
  const configPath = getConfigPath()
344
+ if (!configPath) throw new Error('OPENCLAW_CONFIG_PATH not configured')
345
 
346
  const { readFile, writeFile } = require('fs/promises')
347
  const raw = await readFile(configPath, 'utf-8')
src/lib/command.ts CHANGED
@@ -74,13 +74,13 @@ export function runCommand(
74
  export function runOpenClaw(args: string[], options: CommandOptions = {}) {
75
  return runCommand(config.openclawBin, args, {
76
  ...options,
77
- cwd: options.cwd || config.openclawHome || process.cwd()
78
  })
79
  }
80
 
81
  export function runClawdbot(args: string[], options: CommandOptions = {}) {
82
  return runCommand(config.clawdbotBin, args, {
83
  ...options,
84
- cwd: options.cwd || config.openclawHome || process.cwd()
85
  })
86
  }
 
74
  export function runOpenClaw(args: string[], options: CommandOptions = {}) {
75
  return runCommand(config.openclawBin, args, {
76
  ...options,
77
+ cwd: options.cwd || config.openclawStateDir || process.cwd()
78
  })
79
  }
80
 
81
  export function runClawdbot(args: string[], options: CommandOptions = {}) {
82
  return runCommand(config.clawdbotBin, args, {
83
  ...options,
84
+ cwd: options.cwd || config.openclawStateDir || process.cwd()
85
  })
86
  }
src/lib/config.ts CHANGED
@@ -3,11 +3,24 @@ import os from 'node:os'
3
  import path from 'node:path'
4
 
5
  const defaultDataDir = path.join(process.cwd(), '.data')
6
- const openclawHome =
 
 
 
 
 
7
  process.env.OPENCLAW_HOME ||
8
  process.env.CLAWDBOT_HOME ||
9
  process.env.MISSION_CONTROL_OPENCLAW_HOME ||
10
  ''
 
 
 
 
 
 
 
 
11
 
12
  export const config = {
13
  claudeHome:
@@ -20,22 +33,25 @@ export const config = {
20
  tokensPath:
21
  process.env.MISSION_CONTROL_TOKENS_PATH ||
22
  path.join(defaultDataDir, 'mission-control-tokens.json'),
23
- openclawHome,
 
 
 
24
  openclawBin: process.env.OPENCLAW_BIN || 'openclaw',
25
  clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot',
26
  gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1',
27
  gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'),
28
  logsDir:
29
  process.env.OPENCLAW_LOG_DIR ||
30
- (openclawHome ? path.join(openclawHome, 'logs') : ''),
31
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
32
  memoryDir:
33
  process.env.OPENCLAW_MEMORY_DIR ||
34
- (openclawHome ? path.join(openclawHome, 'memory') : '') ||
35
  path.join(defaultDataDir, 'memory'),
36
  soulTemplatesDir:
37
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
38
- (openclawHome ? path.join(openclawHome, 'templates', 'souls') : ''),
39
  homeDir: os.homedir(),
40
  // Data retention (days). 0 = keep forever.
41
  retention: {
 
3
  import path from 'node:path'
4
 
5
  const defaultDataDir = path.join(process.cwd(), '.data')
6
+ const defaultOpenClawStateDir = path.join(os.homedir(), '.openclaw')
7
+ const explicitOpenClawConfigPath =
8
+ process.env.OPENCLAW_CONFIG_PATH ||
9
+ process.env.MISSION_CONTROL_OPENCLAW_CONFIG_PATH ||
10
+ ''
11
+ const legacyOpenClawHome =
12
  process.env.OPENCLAW_HOME ||
13
  process.env.CLAWDBOT_HOME ||
14
  process.env.MISSION_CONTROL_OPENCLAW_HOME ||
15
  ''
16
+ const openclawStateDir =
17
+ process.env.OPENCLAW_STATE_DIR ||
18
+ process.env.CLAWDBOT_STATE_DIR ||
19
+ legacyOpenClawHome ||
20
+ (explicitOpenClawConfigPath ? path.dirname(explicitOpenClawConfigPath) : defaultOpenClawStateDir)
21
+ const openclawConfigPath =
22
+ explicitOpenClawConfigPath ||
23
+ path.join(openclawStateDir, 'openclaw.json')
24
 
25
  export const config = {
26
  claudeHome:
 
33
  tokensPath:
34
  process.env.MISSION_CONTROL_TOKENS_PATH ||
35
  path.join(defaultDataDir, 'mission-control-tokens.json'),
36
+ // Keep openclawHome as a legacy alias for existing code paths.
37
+ openclawHome: openclawStateDir,
38
+ openclawStateDir,
39
+ openclawConfigPath,
40
  openclawBin: process.env.OPENCLAW_BIN || 'openclaw',
41
  clawdbotBin: process.env.CLAWDBOT_BIN || 'clawdbot',
42
  gatewayHost: process.env.OPENCLAW_GATEWAY_HOST || '127.0.0.1',
43
  gatewayPort: Number(process.env.OPENCLAW_GATEWAY_PORT || '18789'),
44
  logsDir:
45
  process.env.OPENCLAW_LOG_DIR ||
46
+ (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
47
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
48
  memoryDir:
49
  process.env.OPENCLAW_MEMORY_DIR ||
50
+ (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
51
  path.join(defaultDataDir, 'memory'),
52
  soulTemplatesDir:
53
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
54
+ (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
55
  homeDir: os.homedir(),
56
  // Data retention (days). 0 = keep forever.
57
  retention: {
src/lib/db.ts CHANGED
@@ -164,6 +164,11 @@ export interface Task {
164
  description?: string;
165
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done';
166
  priority: 'low' | 'medium' | 'high' | 'urgent';
 
 
 
 
 
167
  assigned_to?: string;
168
  created_by: string;
169
  created_at: number;
 
164
  description?: string;
165
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done';
166
  priority: 'low' | 'medium' | 'high' | 'urgent';
167
+ project_id?: number;
168
+ project_ticket_no?: number;
169
+ project_name?: string;
170
+ project_prefix?: string;
171
+ ticket_ref?: string;
172
  assigned_to?: string;
173
  created_by: string;
174
  created_at: number;
src/lib/migrations.ts CHANGED
@@ -676,6 +676,83 @@ const migrations: Migration[] = [
676
  db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`)
677
  db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`)
678
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  }
680
  ]
681
 
 
676
  db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`)
677
  db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`)
678
  }
679
+ },
680
+ {
681
+ id: '024_projects_support',
682
+ up: (db) => {
683
+ db.exec(`
684
+ CREATE TABLE IF NOT EXISTS projects (
685
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
686
+ workspace_id INTEGER NOT NULL DEFAULT 1,
687
+ name TEXT NOT NULL,
688
+ slug TEXT NOT NULL,
689
+ description TEXT,
690
+ ticket_prefix TEXT NOT NULL,
691
+ ticket_counter INTEGER NOT NULL DEFAULT 0,
692
+ status TEXT NOT NULL DEFAULT 'active',
693
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
694
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
695
+ UNIQUE(workspace_id, slug),
696
+ UNIQUE(workspace_id, ticket_prefix)
697
+ )
698
+ `)
699
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_workspace_status ON projects(workspace_id, status)`)
700
+
701
+ const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>
702
+ if (!taskCols.some((c) => c.name === 'project_id')) {
703
+ db.exec(`ALTER TABLE tasks ADD COLUMN project_id INTEGER`)
704
+ }
705
+ if (!taskCols.some((c) => c.name === 'project_ticket_no')) {
706
+ db.exec(`ALTER TABLE tasks ADD COLUMN project_ticket_no INTEGER`)
707
+ }
708
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_project ON tasks(workspace_id, project_id)`)
709
+
710
+ const workspaceRows = db.prepare(`SELECT id FROM workspaces ORDER BY id ASC`).all() as Array<{ id: number }>
711
+ const ensureDefaultProject = db.prepare(`
712
+ INSERT OR IGNORE INTO projects (workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at)
713
+ VALUES (?, 'General', 'general', 'Default project for uncategorized tasks', 'TASK', 0, 'active', unixepoch(), unixepoch())
714
+ `)
715
+ const getDefaultProject = db.prepare(`
716
+ SELECT id, ticket_counter FROM projects
717
+ WHERE workspace_id = ? AND slug = 'general'
718
+ LIMIT 1
719
+ `)
720
+ const setTaskProject = db.prepare(`
721
+ UPDATE tasks SET project_id = ?
722
+ WHERE workspace_id = ? AND (project_id IS NULL OR project_id = 0)
723
+ `)
724
+ const listProjectTasks = db.prepare(`
725
+ SELECT id FROM tasks
726
+ WHERE workspace_id = ? AND project_id = ?
727
+ ORDER BY created_at ASC, id ASC
728
+ `)
729
+ const setTaskNo = db.prepare(`UPDATE tasks SET project_ticket_no = ? WHERE id = ?`)
730
+ const setProjectCounter = db.prepare(`UPDATE projects SET ticket_counter = ?, updated_at = unixepoch() WHERE id = ?`)
731
+
732
+ for (const workspace of workspaceRows) {
733
+ ensureDefaultProject.run(workspace.id)
734
+ const defaultProject = getDefaultProject.get(workspace.id) as { id: number; ticket_counter: number } | undefined
735
+ if (!defaultProject) continue
736
+
737
+ setTaskProject.run(defaultProject.id, workspace.id)
738
+
739
+ const projectRows = db.prepare(`
740
+ SELECT id FROM projects
741
+ WHERE workspace_id = ?
742
+ ORDER BY id ASC
743
+ `).all(workspace.id) as Array<{ id: number }>
744
+
745
+ for (const project of projectRows) {
746
+ const tasks = listProjectTasks.all(workspace.id, project.id) as Array<{ id: number }>
747
+ let counter = 0
748
+ for (const task of tasks) {
749
+ counter += 1
750
+ setTaskNo.run(counter, task.id)
751
+ }
752
+ setProjectCounter.run(counter, project.id)
753
+ }
754
+ }
755
+ }
756
  }
757
  ]
758
 
src/lib/sessions.ts CHANGED
@@ -23,16 +23,16 @@ export interface GatewaySession {
23
  * Read all sessions from OpenClaw agent session stores on disk.
24
  *
25
  * OpenClaw stores sessions per-agent at:
26
- * {OPENCLAW_HOME}/agents/{agentName}/sessions/sessions.json
27
  *
28
  * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
29
  * with session metadata as values.
30
  */
31
  export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
32
- const openclawHome = config.openclawHome
33
- if (!openclawHome) return []
34
 
35
- const agentsDir = path.join(openclawHome, 'agents')
36
  if (!fs.existsSync(agentsDir)) return []
37
 
38
  const sessions: GatewaySession[] = []
 
23
  * Read all sessions from OpenClaw agent session stores on disk.
24
  *
25
  * OpenClaw stores sessions per-agent at:
26
+ * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
27
  *
28
  * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
29
  * with session metadata as values.
30
  */
31
  export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
32
+ const openclawStateDir = config.openclawStateDir
33
+ if (!openclawStateDir) return []
34
 
35
+ const agentsDir = path.join(openclawStateDir, 'agents')
36
  if (!fs.existsSync(agentsDir)) return []
37
 
38
  const sessions: GatewaySession[] = []
src/lib/validation.ts CHANGED
@@ -31,6 +31,7 @@ export const createTaskSchema = z.object({
31
  description: z.string().max(5000).optional(),
32
  status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'),
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
 
34
  assigned_to: z.string().max(100).optional(),
35
  created_by: z.string().max(100).optional(),
36
  due_date: z.number().optional(),
 
31
  description: z.string().max(5000).optional(),
32
  status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'),
33
  priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
34
+ project_id: z.number().int().positive().optional(),
35
  assigned_to: z.string().max(100).optional(),
36
  created_by: z.string().max(100).optional(),
37
  due_date: z.number().optional(),
src/store/index.ts CHANGED
@@ -93,6 +93,11 @@ export interface Task {
93
  description?: string
94
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
95
  priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent'
 
 
 
 
 
96
  assigned_to?: string
97
  created_by: string
98
  created_at: number
 
93
  description?: string
94
  status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'
95
  priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent'
96
+ project_id?: number
97
+ project_ticket_no?: number
98
+ project_name?: string
99
+ project_prefix?: string
100
+ ticket_ref?: string
101
  assigned_to?: string
102
  created_by: string
103
  created_at: number
tests/README.md CHANGED
@@ -11,6 +11,9 @@ pnpm dev --hostname 127.0.0.1 --port 3005
11
  # Run all tests
12
  pnpm test:e2e
13
 
 
 
 
14
  # Run a specific spec
15
  pnpm exec playwright test tests/tasks-crud.spec.ts
16
  ```
@@ -21,6 +24,17 @@ Tests require `.env.local` with:
21
  - `API_KEY=test-api-key-e2e-12345`
22
  - `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active)
23
 
 
 
 
 
 
 
 
 
 
 
 
24
  ## Spec Files
25
 
26
  ### Security & Auth
 
11
  # Run all tests
12
  pnpm test:e2e
13
 
14
+ # Run offline OpenClaw harness (no OpenClaw install required)
15
+ pnpm test:e2e:openclaw
16
+
17
  # Run a specific spec
18
  pnpm exec playwright test tests/tasks-crud.spec.ts
19
  ```
 
24
  - `API_KEY=test-api-key-e2e-12345`
25
  - `MC_DISABLE_RATE_LIMIT=1` (bypasses mutation/read rate limits, keeps login rate limit active)
26
 
27
+ ## OpenClaw Offline Harness
28
+
29
+ The harness runs Mission Control against fixture data and mock binaries/gateway:
30
+ - fixtures: `tests/fixtures/openclaw/`
31
+ - mock CLI: `scripts/e2e-openclaw/bin/{openclaw,clawdbot}`
32
+ - mock gateway: `scripts/e2e-openclaw/mock-gateway.mjs`
33
+
34
+ Profiles:
35
+ - `pnpm test:e2e:openclaw:local` - local mode (gateway not running)
36
+ - `pnpm test:e2e:openclaw:gateway` - gateway mode (mock gateway running)
37
+
38
  ## Spec Files
39
 
40
  ### Security & Auth
tests/fixtures/openclaw/agents/engineering-bot/sessions/sessions.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "agent:engineering-bot:main": {
3
+ "sessionId": "sess-eng-main",
4
+ "updatedAt": 4102444800000,
5
+ "chatType": "dm",
6
+ "lastChannel": "engineering",
7
+ "model": {
8
+ "primary": "openai/gpt-5"
9
+ },
10
+ "totalTokens": 24500,
11
+ "inputTokens": 16100,
12
+ "outputTokens": 8400,
13
+ "contextTokens": 120000
14
+ }
15
+ }
tests/fixtures/openclaw/agents/research-bot/sessions/sessions.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "agent:research-bot:main": {
3
+ "sessionId": "sess-research-main",
4
+ "updatedAt": 4102444700000,
5
+ "chatType": "dm",
6
+ "lastChannel": "research",
7
+ "model": {
8
+ "primary": "anthropic/claude-sonnet-4-5"
9
+ },
10
+ "totalTokens": 12800,
11
+ "inputTokens": 8100,
12
+ "outputTokens": 4700,
13
+ "contextTokens": 64000
14
+ }
15
+ }
tests/fixtures/openclaw/cron/jobs.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "jobs": [
4
+ {
5
+ "id": "job-eng-daily",
6
+ "agentId": "engineering-bot",
7
+ "name": "daily engineering standup",
8
+ "enabled": true,
9
+ "createdAtMs": 1762550000000,
10
+ "updatedAtMs": 1762550600000,
11
+ "schedule": {
12
+ "kind": "cron",
13
+ "expr": "0 9 * * 1-5",
14
+ "tz": "UTC"
15
+ },
16
+ "payload": {
17
+ "kind": "message",
18
+ "message": "Post daily engineering summary",
19
+ "model": "openai/gpt-5"
20
+ },
21
+ "delivery": {
22
+ "mode": "dm",
23
+ "channel": "engineering"
24
+ },
25
+ "state": {
26
+ "nextRunAtMs": 1762602000000,
27
+ "lastRunAtMs": 1762515600000,
28
+ "lastStatus": "success"
29
+ }
30
+ },
31
+ {
32
+ "id": "job-research-hourly",
33
+ "agentId": "research-bot",
34
+ "name": "hourly trend scan",
35
+ "enabled": true,
36
+ "createdAtMs": 1762551000000,
37
+ "updatedAtMs": 1762551000000,
38
+ "schedule": {
39
+ "kind": "cron",
40
+ "expr": "0 * * * *",
41
+ "tz": "UTC"
42
+ },
43
+ "payload": {
44
+ "kind": "message",
45
+ "message": "Scan latest market/AI trends",
46
+ "model": "anthropic/claude-sonnet-4-5"
47
+ },
48
+ "delivery": {
49
+ "mode": "dm",
50
+ "channel": "research"
51
+ },
52
+ "state": {
53
+ "nextRunAtMs": 1762594800000,
54
+ "lastRunAtMs": 1762591200000,
55
+ "lastStatus": "success"
56
+ }
57
+ }
58
+ ]
59
+ }
tests/fixtures/openclaw/openclaw.json ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "gateway": {
4
+ "host": "127.0.0.1",
5
+ "port": 18789
6
+ },
7
+ "agents": {
8
+ "defaults": {
9
+ "workspace": "workspaces"
10
+ },
11
+ "list": [
12
+ {
13
+ "id": "engineering-bot",
14
+ "name": "engineering-bot",
15
+ "default": true,
16
+ "workspace": "workspaces/engineering-bot",
17
+ "agentDir": "agents/engineering-bot",
18
+ "identity": {
19
+ "name": "Engineering Bot",
20
+ "theme": "software-engineer",
21
+ "emoji": ":gear:"
22
+ },
23
+ "model": {
24
+ "primary": "openai/gpt-5"
25
+ },
26
+ "tools": {
27
+ "allow": ["sessions_send", "sessions_history", "shell"]
28
+ }
29
+ },
30
+ {
31
+ "id": "research-bot",
32
+ "name": "research-bot",
33
+ "workspace": "workspaces/research-bot",
34
+ "agentDir": "agents/research-bot",
35
+ "identity": {
36
+ "name": "Research Bot",
37
+ "theme": "analyst",
38
+ "emoji": ":book:"
39
+ },
40
+ "model": {
41
+ "primary": "anthropic/claude-sonnet-4-5"
42
+ },
43
+ "tools": {
44
+ "allow": ["web", "sessions_send"]
45
+ }
46
+ }
47
+ ]
48
+ }
49
+ }
tests/fixtures/openclaw/workspaces/engineering-bot/TOOLS.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ - `shell`
2
+ - `sessions_send`
3
+ - `sessions_history`
tests/fixtures/openclaw/workspaces/engineering-bot/identity.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Engineering Bot
2
+ theme: software-engineer
3
+ emoji: ⚙️
tests/fixtures/openclaw/workspaces/engineering-bot/soul.md ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Engineering Bot
2
+ Focus on implementation quality, safe migrations, and test coverage.
tests/fixtures/openclaw/workspaces/research-bot/TOOLS.md ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ - `web`
2
+ - `sessions_send`
tests/fixtures/openclaw/workspaces/research-bot/identity.md ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Research Bot
2
+ theme: analyst
3
+ emoji: 📚
tests/fixtures/openclaw/workspaces/research-bot/soul.md ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Research Bot
2
+ Focus on evidence, citations, and concise recommendations.
tests/openclaw-harness.spec.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER } from './helpers'
3
+
4
+ const EXPECT_GATEWAY = process.env.E2E_GATEWAY_EXPECTED === '1'
5
+
6
+ test.describe('OpenClaw Offline Harness', () => {
7
+ test('capabilities expose OpenClaw state dir/config in offline test mode', async ({ request }) => {
8
+ const res = await request.get('/api/status?action=capabilities', {
9
+ headers: API_KEY_HEADER,
10
+ })
11
+ expect(res.status()).toBe(200)
12
+
13
+ const body = await res.json()
14
+ expect(body.openclawHome).toBe(true)
15
+ expect(Boolean(body.claudeHome)).toBeTruthy()
16
+ expect(Boolean(body.gateway)).toBe(EXPECT_GATEWAY)
17
+ })
18
+
19
+ test('sessions API reads fixture sessions without OpenClaw install', async ({ request }) => {
20
+ const res = await request.get('/api/sessions', {
21
+ headers: API_KEY_HEADER,
22
+ })
23
+ expect(res.status()).toBe(200)
24
+
25
+ const body = await res.json()
26
+ expect(Array.isArray(body.sessions)).toBe(true)
27
+ expect(body.sessions.length).toBeGreaterThan(0)
28
+ expect(body.sessions[0]).toHaveProperty('agent')
29
+ expect(body.sessions[0]).toHaveProperty('tokens')
30
+ })
31
+
32
+ test('cron API reads fixture jobs', async ({ request }) => {
33
+ const res = await request.get('/api/cron?action=list', {
34
+ headers: API_KEY_HEADER,
35
+ })
36
+ expect(res.status()).toBe(200)
37
+
38
+ const body = await res.json()
39
+ expect(Array.isArray(body.jobs)).toBe(true)
40
+ expect(body.jobs.length).toBeGreaterThan(0)
41
+ expect(body.jobs[0]).toHaveProperty('name')
42
+ expect(body.jobs[0]).toHaveProperty('schedule')
43
+ })
44
+
45
+ test('gateway config API reads fixture config', async ({ request }) => {
46
+ const res = await request.get('/api/gateway-config', {
47
+ headers: API_KEY_HEADER,
48
+ })
49
+ expect(res.status()).toBe(200)
50
+
51
+ const body = await res.json()
52
+ expect(typeof body.path).toBe('string')
53
+ expect(body.path.endsWith('openclaw.json')).toBe(true)
54
+ expect(body.config).toHaveProperty('agents')
55
+ })
56
+ })