From a9df1a25a5c8822de662aa1d75855839bd9bd582 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 22:44:57 +0700 Subject: [PATCH] feat: add workspace discoverability and multi-project task support --- README.md | 26 ++ src/app/[[...panel]]/page.tsx | 2 + src/app/api/agents/[id]/memory/route.ts | 4 +- src/app/api/projects/[id]/route.ts | 177 +++++++++++ src/app/api/projects/[id]/tasks/route.ts | 55 ++++ src/app/api/projects/route.ts | 94 ++++++ src/app/api/tasks/[id]/route.ts | 78 ++++- src/app/api/tasks/route.ts | 150 ++++++--- src/components/layout/header-bar.tsx | 1 + src/components/layout/nav-rail.tsx | 1 + src/components/panels/agent-detail-tabs.tsx | 5 +- src/components/panels/task-board-panel.tsx | 319 ++++++++++++++++++-- src/index.ts | 5 + src/lib/db.ts | 5 + src/lib/migrations.ts | 77 +++++ src/lib/validation.ts | 1 + src/store/index.ts | 5 + 17 files changed, 930 insertions(+), 75 deletions(-) create mode 100644 src/app/api/projects/[id]/route.ts create mode 100644 src/app/api/projects/[id]/tasks/route.ts create mode 100644 src/app/api/projects/route.ts diff --git a/README.md b/README.md index 5b3cdeb..237d3fd 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,32 @@ See [`.env.example`](.env.example) for the complete list. Key variables: > OPENCLAW_MEMORY_DIR=/home/you/clawd-agents > ``` +### Workspace Creation Flow + +To add a new workspace/client instance in the UI: + +1. Open `Workspaces` from the left navigation. +2. Expand `Show Create Client Instance`. +3. Fill tenant/workspace fields (`slug`, `display_name`, optional ports/gateway owner). +4. Click `Create + Queue`. +5. Approve/run the generated provisioning job in the same panel. + +`Workspaces` and `Super Admin` currently point to the same provisioning control plane. + +### Projects and Ticket Prefixes + +Mission Control supports multi-project task organization per workspace: + +- Create/manage projects via Task Board → `Projects`. +- Each project has its own ticket prefix and counter. +- New tasks receive project-scoped ticket refs like `PA-001`, `PA-002`. +- Task board supports filtering by project. + +### Memory Scope Clarification + +- **Agent profile → Memory tab**: per-agent working memory stored in Mission Control DB (`working_memory`). +- **Memory Browser page**: workspace/local filesystem memory tree under `OPENCLAW_MEMORY_DIR`. + ## Deployment ```bash diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index e6358eb..0b0dcaa 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -261,6 +261,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'super-admin': return + case 'workspaces': + return default: return } diff --git a/src/app/api/agents/[id]/memory/route.ts b/src/app/api/agents/[id]/memory/route.ts index db5ef30..56277b2 100644 --- a/src/app/api/agents/[id]/memory/route.ts +++ b/src/app/api/agents/[id]/memory/route.ts @@ -6,8 +6,8 @@ import { logger } from '@/lib/logger'; /** * GET /api/agents/[id]/memory - Get agent's working memory * - * Working memory is stored as WORKING.md content in the database - * Each agent has their own working memory space for temporary notes + * Working memory is stored in the agents.working_memory DB column. + * This endpoint is per-agent scratchpad memory (not the global Memory Browser filesystem view). */ export async function GET( request: NextRequest, diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..3fe1832 --- /dev/null +++ b/src/app/api/projects/[id]/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { mutationLimiter } from '@/lib/rate-limit' +import { logger } from '@/lib/logger' + +function normalizePrefix(input: string): string { + const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '') + return normalized.slice(0, 12) +} + +function toProjectId(raw: string): number { + const id = Number.parseInt(raw, 10) + return Number.isFinite(id) ? id : NaN +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + + return NextResponse.json({ project }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to fetch project' }, { status: 500 }) + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any + if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + if (current.slug === 'general' && current.workspace_id === workspaceId && current.id === projectId) { + const body = await request.json() + if (body?.status === 'archived') { + return NextResponse.json({ error: 'Default project cannot be archived' }, { status: 400 }) + } + } + + const body = await request.json() + const updates: string[] = [] + const paramsList: Array = [] + + if (typeof body?.name === 'string') { + const name = body.name.trim() + if (!name) return NextResponse.json({ error: 'Project name cannot be empty' }, { status: 400 }) + updates.push('name = ?') + paramsList.push(name) + } + if (typeof body?.description === 'string') { + updates.push('description = ?') + paramsList.push(body.description.trim() || null) + } + if (typeof body?.ticket_prefix === 'string' || typeof body?.ticketPrefix === 'string') { + const raw = String(body.ticket_prefix ?? body.ticketPrefix) + const prefix = normalizePrefix(raw) + if (!prefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 }) + const conflict = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND ticket_prefix = ? AND id != ? + `).get(workspaceId, prefix, projectId) + if (conflict) return NextResponse.json({ error: 'Ticket prefix already in use' }, { status: 409 }) + updates.push('ticket_prefix = ?') + paramsList.push(prefix) + } + if (typeof body?.status === 'string') { + const status = body.status === 'archived' ? 'archived' : 'active' + updates.push('status = ?') + paramsList.push(status) + } + + if (updates.length === 0) return NextResponse.json({ error: 'No fields to update' }, { status: 400 }) + + updates.push('updated_at = unixepoch()') + db.prepare(` + UPDATE projects + SET ${updates.join(', ')} + WHERE id = ? AND workspace_id = ? + `).run(...paramsList, projectId, workspaceId) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + + return NextResponse.json({ project }) + } catch (error) { + logger.error({ err: error }, 'PATCH /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to update project' }, { status: 500 }) + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'admin') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = toProjectId(id) + if (Number.isNaN(projectId)) return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + + const current = db.prepare(`SELECT * FROM projects WHERE id = ? AND workspace_id = ?`).get(projectId, workspaceId) as any + if (!current) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + if (current.slug === 'general') { + return NextResponse.json({ error: 'Default project cannot be deleted' }, { status: 400 }) + } + + const mode = new URL(request.url).searchParams.get('mode') || 'archive' + if (mode !== 'delete') { + db.prepare(`UPDATE projects SET status = 'archived', updated_at = unixepoch() WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId) + return NextResponse.json({ success: true, mode: 'archive' }) + } + + const fallback = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND slug = 'general' + LIMIT 1 + `).get(workspaceId) as { id: number } | undefined + if (!fallback) return NextResponse.json({ error: 'Default project missing' }, { status: 500 }) + + const tx = db.transaction(() => { + db.prepare(` + UPDATE tasks + SET project_id = ? + WHERE workspace_id = ? AND project_id = ? + `).run(fallback.id, workspaceId, projectId) + + db.prepare(`DELETE FROM projects WHERE id = ? AND workspace_id = ?`).run(projectId, workspaceId) + }) + tx() + + return NextResponse.json({ success: true, mode: 'delete' }) + } catch (error) { + logger.error({ err: error }, 'DELETE /api/projects/[id] error') + return NextResponse.json({ error: 'Failed to delete project' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/[id]/tasks/route.ts b/src/app/api/projects/[id]/tasks/route.ts new file mode 100644 index 0000000..ada6891 --- /dev/null +++ b/src/app/api/projects/[id]/tasks/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { logger } from '@/lib/logger' + +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { id } = await params + const projectId = Number.parseInt(id, 10) + if (!Number.isFinite(projectId)) { + return NextResponse.json({ error: 'Invalid project ID' }, { status: 400 }) + } + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? AND workspace_id = ? + `).get(projectId, workspaceId) + if (!project) return NextResponse.json({ error: 'Project not found' }, { status: 404 }) + + const tasks = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.workspace_id = ? AND t.project_id = ? + ORDER BY t.created_at DESC + `).all(workspaceId, projectId) + + return NextResponse.json({ + project, + tasks: tasks.map((task: any) => ({ + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + })) + }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects/[id]/tasks error') + return NextResponse.json({ error: 'Failed to fetch project tasks' }, { status: 500 }) + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..177fb83 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getDatabase } from '@/lib/db' +import { requireRole } from '@/lib/auth' +import { mutationLimiter } from '@/lib/rate-limit' +import { logger } from '@/lib/logger' + +function slugify(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64) +} + +function normalizePrefix(input: string): string { + const normalized = input.trim().toUpperCase().replace(/[^A-Z0-9]/g, '') + return normalized.slice(0, 12) +} + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const includeArchived = new URL(request.url).searchParams.get('includeArchived') === '1' + + const projects = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE workspace_id = ? + ${includeArchived ? '' : "AND status = 'active'"} + ORDER BY name COLLATE NOCASE ASC + `).all(workspaceId) + + return NextResponse.json({ projects }) + } catch (error) { + logger.error({ err: error }, 'GET /api/projects error') + return NextResponse.json({ error: 'Failed to fetch projects' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const auth = requireRole(request, 'operator') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const rateCheck = mutationLimiter(request) + if (rateCheck) return rateCheck + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const body = await request.json() + + const name = String(body?.name || '').trim() + const description = typeof body?.description === 'string' ? body.description.trim() : '' + const prefixInput = String(body?.ticket_prefix || body?.ticketPrefix || '').trim() + const slugInput = String(body?.slug || '').trim() + + if (!name) return NextResponse.json({ error: 'Project name is required' }, { status: 400 }) + + const slug = slugInput ? slugify(slugInput) : slugify(name) + const ticketPrefix = normalizePrefix(prefixInput || name.slice(0, 5)) + if (!slug) return NextResponse.json({ error: 'Invalid project slug' }, { status: 400 }) + if (!ticketPrefix) return NextResponse.json({ error: 'Invalid ticket prefix' }, { status: 400 }) + + const exists = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND (slug = ? OR ticket_prefix = ?) + LIMIT 1 + `).get(workspaceId, slug, ticketPrefix) as { id: number } | undefined + if (exists) { + return NextResponse.json({ error: 'Project slug or ticket prefix already exists' }, { status: 409 }) + } + + const result = db.prepare(` + INSERT INTO projects (workspace_id, name, slug, description, ticket_prefix, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', unixepoch(), unixepoch()) + `).run(workspaceId, name, slug, description || null, ticketPrefix) + + const project = db.prepare(` + SELECT id, workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at + FROM projects + WHERE id = ? + `).get(Number(result.lastInsertRowid)) + + return NextResponse.json({ project }, { status: 201 }) + } catch (error) { + logger.error({ err: error }, 'POST /api/projects error') + return NextResponse.json({ error: 'Failed to create project' }, { status: 500 }) + } +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 1e5fd47..7bd79c3 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -6,6 +6,20 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record } { + return { + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + } +} + function hasAegisApproval( db: ReturnType, taskId: number, @@ -40,7 +54,12 @@ export async function GET( return NextResponse.json({ error: 'Invalid task ID' }, { status: 400 }); } - const stmt = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?'); + const stmt = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `); const task = stmt.get(taskId, workspaceId) as Task; if (!task) { @@ -48,11 +67,7 @@ export async function GET( } // Parse JSON fields - const taskWithParsedData = { - ...task, - tags: task.tags ? JSON.parse(task.tags) : [], - metadata: task.metadata ? JSON.parse(task.metadata) : {} - }; + const taskWithParsedData = mapTaskRow(task); return NextResponse.json({ task: taskWithParsedData }); } catch (error) { @@ -101,6 +116,7 @@ export async function PUT( description, status, priority, + project_id, assigned_to, due_date, estimated_hours, @@ -114,6 +130,7 @@ export async function PUT( // Build dynamic update query const fieldsToUpdate = []; const updateParams: any[] = []; + let nextProjectTicketNo: number | null = null; if (title !== undefined) { fieldsToUpdate.push('title = ?'); @@ -137,6 +154,36 @@ export async function PUT( fieldsToUpdate.push('priority = ?'); updateParams.push(priority); } + if (project_id !== undefined) { + const project = db.prepare(` + SELECT id FROM projects + WHERE id = ? AND workspace_id = ? AND status = 'active' + `).get(project_id, workspaceId) as { id: number } | undefined + if (!project) { + return NextResponse.json({ error: 'Project not found or archived' }, { status: 400 }) + } + if (project_id !== currentTask.project_id) { + db.prepare(` + UPDATE projects + SET ticket_counter = ticket_counter + 1, updated_at = unixepoch() + WHERE id = ? AND workspace_id = ? + `).run(project_id, workspaceId) + const row = db.prepare(` + SELECT ticket_counter FROM projects + WHERE id = ? AND workspace_id = ? + `).get(project_id, workspaceId) as { ticket_counter: number } | undefined + if (!row || !row.ticket_counter) { + return NextResponse.json({ error: 'Failed to allocate project ticket number' }, { status: 500 }) + } + nextProjectTicketNo = row.ticket_counter + } + fieldsToUpdate.push('project_id = ?'); + updateParams.push(project_id); + if (nextProjectTicketNo !== null) { + fieldsToUpdate.push('project_ticket_no = ?'); + updateParams.push(nextProjectTicketNo); + } + } if (assigned_to !== undefined) { fieldsToUpdate.push('assigned_to = ?'); updateParams.push(assigned_to); @@ -223,6 +270,10 @@ export async function PUT( if (priority && priority !== currentTask.priority) { changes.push(`priority: ${currentTask.priority} → ${priority}`); } + + if (project_id !== undefined && project_id !== currentTask.project_id) { + changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`); + } // Log activity if there were meaningful changes if (changes.length > 0) { @@ -247,14 +298,13 @@ export async function PUT( } // Fetch updated task - const updatedTask = db - .prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?') - .get(taskId, workspaceId) as Task; - const parsedTask = { - ...updatedTask, - tags: updatedTask.tags ? JSON.parse(updatedTask.tags) : [], - metadata: updatedTask.metadata ? JSON.parse(updatedTask.metadata) : {} - }; + const updatedTask = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `).get(taskId, workspaceId) as Task; + const parsedTask = mapTaskRow(updatedTask); // Broadcast to SSE clients eventBus.broadcast('task.updated', parsedTask); diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index ec04cc9..e8f20cf 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -6,6 +6,43 @@ import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; +function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { + if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined + return `${prefix}-${String(num).padStart(3, '0')}` +} + +function mapTaskRow(task: any): Task & { tags: string[]; metadata: Record } { + return { + ...task, + tags: task.tags ? JSON.parse(task.tags) : [], + metadata: task.metadata ? JSON.parse(task.metadata) : {}, + ticket_ref: formatTicketRef(task.project_prefix, task.project_ticket_no), + } +} + +function resolveProjectId(db: ReturnType, workspaceId: number, requestedProjectId?: number): number { + if (typeof requestedProjectId === 'number' && Number.isFinite(requestedProjectId)) { + const project = db.prepare(` + SELECT id FROM projects + WHERE id = ? AND workspace_id = ? AND status = 'active' + LIMIT 1 + `).get(requestedProjectId, workspaceId) as { id: number } | undefined + if (project) return project.id + } + + const fallback = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? AND status = 'active' + ORDER BY CASE WHEN slug = 'general' THEN 0 ELSE 1 END, id ASC + LIMIT 1 + `).get(workspaceId) as { id: number } | undefined + + if (!fallback) { + throw new Error('No active project available in workspace') + } + return fallback.id +} + function hasAegisApproval(db: ReturnType, taskId: number, workspaceId: number): boolean { const review = db.prepare(` SELECT status FROM quality_reviews @@ -18,7 +55,7 @@ function hasAegisApproval(db: ReturnType, taskId: number, wo /** * GET /api/tasks - List all tasks with optional filtering - * Query params: status, assigned_to, priority, limit, offset + * Query params: status, assigned_to, priority, project_id, limit, offset */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer'); @@ -33,40 +70,48 @@ export async function GET(request: NextRequest) { const status = searchParams.get('status'); const assigned_to = searchParams.get('assigned_to'); const priority = searchParams.get('priority'); + const projectIdParam = Number.parseInt(searchParams.get('project_id') || '', 10); const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); const offset = parseInt(searchParams.get('offset') || '0'); // Build dynamic query - let query = 'SELECT * FROM tasks WHERE workspace_id = ?'; + let query = ` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p + ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.workspace_id = ? + `; const params: any[] = [workspaceId]; if (status) { - query += ' AND status = ?'; + query += ' AND t.status = ?'; params.push(status); } if (assigned_to) { - query += ' AND assigned_to = ?'; + query += ' AND t.assigned_to = ?'; params.push(assigned_to); } if (priority) { - query += ' AND priority = ?'; + query += ' AND t.priority = ?'; params.push(priority); } + + if (Number.isFinite(projectIdParam)) { + query += ' AND t.project_id = ?'; + params.push(projectIdParam); + } - query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'; + query += ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?'; params.push(limit, offset); const stmt = db.prepare(query); const tasks = stmt.all(...params) as Task[]; // Parse JSON fields - const tasksWithParsedData = tasks.map(task => ({ - ...task, - tags: task.tags ? JSON.parse(task.tags) : [], - metadata: task.metadata ? JSON.parse(task.metadata) : {} - })); + const tasksWithParsedData = tasks.map(mapTaskRow); // Get total count for pagination let countQuery = 'SELECT COUNT(*) as total FROM tasks WHERE workspace_id = ?'; @@ -83,6 +128,10 @@ export async function GET(request: NextRequest) { countQuery += ' AND priority = ?'; countParams.push(priority); } + if (Number.isFinite(projectIdParam)) { + countQuery += ' AND project_id = ?'; + countParams.push(projectIdParam); + } const countRow = db.prepare(countQuery).get(...countParams) as { total: number }; return NextResponse.json({ tasks: tasksWithParsedData, total: countRow.total, page: Math.floor(offset / limit) + 1, limit }); @@ -115,6 +164,7 @@ export async function POST(request: NextRequest) { description, status = 'inbox', priority = 'medium', + project_id, assigned_to, created_by = user?.username || 'system', due_date, @@ -130,31 +180,47 @@ export async function POST(request: NextRequest) { } const now = Math.floor(Date.now() / 1000); - - const stmt = db.prepare(` - INSERT INTO tasks ( - title, description, status, priority, assigned_to, created_by, - created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - - const dbResult = stmt.run( - title, - description, - status, - priority, - assigned_to, - created_by, - now, - now, - due_date, - estimated_hours, - JSON.stringify(tags), - JSON.stringify(metadata), - workspaceId - ); + const createTaskTx = db.transaction(() => { + const resolvedProjectId = resolveProjectId(db, workspaceId, project_id) + db.prepare(` + UPDATE projects + SET ticket_counter = ticket_counter + 1, updated_at = unixepoch() + WHERE id = ? AND workspace_id = ? + `).run(resolvedProjectId, workspaceId) + const row = db.prepare(` + SELECT ticket_counter FROM projects + WHERE id = ? AND workspace_id = ? + `).get(resolvedProjectId, workspaceId) as { ticket_counter: number } | undefined + if (!row || !row.ticket_counter) throw new Error('Failed to allocate project ticket number') - const taskId = dbResult.lastInsertRowid as number; + const insertStmt = db.prepare(` + INSERT INTO tasks ( + title, description, status, priority, project_id, project_ticket_no, assigned_to, created_by, + created_at, updated_at, due_date, estimated_hours, tags, metadata, workspace_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const dbResult = insertStmt.run( + title, + description, + status, + priority, + resolvedProjectId, + row.ticket_counter, + assigned_to, + created_by, + now, + now, + due_date, + estimated_hours, + JSON.stringify(tags), + JSON.stringify(metadata), + workspaceId + ) + return Number(dbResult.lastInsertRowid) + }) + + const taskId = createTaskTx() // Log activity db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, { @@ -183,12 +249,14 @@ export async function POST(request: NextRequest) { } // Fetch the created task - const createdTask = db.prepare('SELECT * FROM tasks WHERE id = ? AND workspace_id = ?').get(taskId, workspaceId) as Task; - const parsedTask = { - ...createdTask, - tags: JSON.parse(createdTask.tags || '[]'), - metadata: JSON.parse(createdTask.metadata || '{}') - }; + const createdTask = db.prepare(` + SELECT t.*, p.name as project_name, p.ticket_prefix as project_prefix + FROM tasks t + LEFT JOIN projects p + ON p.id = t.project_id AND p.workspace_id = t.workspace_id + WHERE t.id = ? AND t.workspace_id = ? + `).get(taskId, workspaceId) as Task; + const parsedTask = mapTaskRow(createdTask); // Broadcast to SSE clients eventBus.broadcast('task.created', parsedTask); diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx index 8d38cfb..e0aa3a3 100644 --- a/src/components/layout/header-bar.tsx +++ b/src/components/layout/header-bar.tsx @@ -43,6 +43,7 @@ export function HeaderBar() { alerts: 'Alert Rules', gateways: 'Gateway Manager', users: 'Users', + workspaces: 'Workspaces', 'gateway-config': 'Gateway Config', settings: 'Settings', } diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index c208078..996e004 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -61,6 +61,7 @@ const navGroups: NavGroup[] = [ { id: 'gateways', label: 'Gateways', icon: , priority: false }, { id: 'gateway-config', label: 'Config', icon: , priority: false, requiresGateway: true }, { id: 'integrations', label: 'Integrations', icon: , priority: false }, + { id: 'workspaces', label: 'Workspaces', icon: , priority: false }, { id: 'super-admin', label: 'Super Admin', icon: , priority: false }, { id: 'settings', label: 'Settings', icon: , priority: false }, ], diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index ea42600..b77354a 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -669,7 +669,10 @@ export function TasksTab({ agent }: { agent: Agent }) { {task.title} -
Task #{task.id}
+
+ {task.ticket_ref || `Task #${task.id}`} + {task.project_name ? ` · ${task.project_name}` : ''} +
{task.description && (

{task.description}

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

Task Board

+
+

Task Board

+ +
+
+
+ + +
+
setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="Project name" + className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + required + /> + setForm((prev) => ({ ...prev, ticket_prefix: e.target.value }))} + placeholder="Ticket prefix (e.g. PA)" + className="bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + /> + + setForm((prev) => ({ ...prev, description: e.target.value }))} + placeholder="Description (optional)" + className="md:col-span-3 bg-surface-1 text-foreground border border-border rounded-md px-3 py-2" + /> + + + {loading ? ( +
Loading projects...
+ ) : ( +
+ {projects.map((project) => ( +
+
+
{project.name}
+
{project.ticket_prefix} · {project.slug} · {project.status}
+
+
+ {project.slug !== 'general' && ( + <> + + + + )} +
+
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/index.ts b/src/index.ts index e9861ad..54c36f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,11 @@ export interface Task { description?: string status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' priority: 'low' | 'medium' | 'high' | 'urgent' + project_id?: number + project_ticket_no?: number + project_name?: string + project_prefix?: string + ticket_ref?: string assigned_to?: string created_by: string created_at: number diff --git a/src/lib/db.ts b/src/lib/db.ts index 931ab43..7a2f234 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -164,6 +164,11 @@ export interface Task { description?: string; status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done'; priority: 'low' | 'medium' | 'high' | 'urgent'; + project_id?: number; + project_ticket_no?: number; + project_name?: string; + project_prefix?: string; + ticket_ref?: string; assigned_to?: string; created_by: string; created_at: number; diff --git a/src/lib/migrations.ts b/src/lib/migrations.ts index b659f6e..0b1fc70 100644 --- a/src/lib/migrations.ts +++ b/src/lib/migrations.ts @@ -676,6 +676,83 @@ const migrations: Migration[] = [ db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`) db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`) } + }, + { + id: '024_projects_support', + up: (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + slug TEXT NOT NULL, + description TEXT, + ticket_prefix TEXT NOT NULL, + ticket_counter INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(workspace_id, slug), + UNIQUE(workspace_id, ticket_prefix) + ) + `) + db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_workspace_status ON projects(workspace_id, status)`) + + const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }> + if (!taskCols.some((c) => c.name === 'project_id')) { + db.exec(`ALTER TABLE tasks ADD COLUMN project_id INTEGER`) + } + if (!taskCols.some((c) => c.name === 'project_ticket_no')) { + db.exec(`ALTER TABLE tasks ADD COLUMN project_ticket_no INTEGER`) + } + db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_project ON tasks(workspace_id, project_id)`) + + const workspaceRows = db.prepare(`SELECT id FROM workspaces ORDER BY id ASC`).all() as Array<{ id: number }> + const ensureDefaultProject = db.prepare(` + INSERT OR IGNORE INTO projects (workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at) + VALUES (?, 'General', 'general', 'Default project for uncategorized tasks', 'TASK', 0, 'active', unixepoch(), unixepoch()) + `) + const getDefaultProject = db.prepare(` + SELECT id, ticket_counter FROM projects + WHERE workspace_id = ? AND slug = 'general' + LIMIT 1 + `) + const setTaskProject = db.prepare(` + UPDATE tasks SET project_id = ? + WHERE workspace_id = ? AND (project_id IS NULL OR project_id = 0) + `) + const listProjectTasks = db.prepare(` + SELECT id FROM tasks + WHERE workspace_id = ? AND project_id = ? + ORDER BY created_at ASC, id ASC + `) + const setTaskNo = db.prepare(`UPDATE tasks SET project_ticket_no = ? WHERE id = ?`) + const setProjectCounter = db.prepare(`UPDATE projects SET ticket_counter = ?, updated_at = unixepoch() WHERE id = ?`) + + for (const workspace of workspaceRows) { + ensureDefaultProject.run(workspace.id) + const defaultProject = getDefaultProject.get(workspace.id) as { id: number; ticket_counter: number } | undefined + if (!defaultProject) continue + + setTaskProject.run(defaultProject.id, workspace.id) + + const projectRows = db.prepare(` + SELECT id FROM projects + WHERE workspace_id = ? + ORDER BY id ASC + `).all(workspace.id) as Array<{ id: number }> + + for (const project of projectRows) { + const tasks = listProjectTasks.all(workspace.id, project.id) as Array<{ id: number }> + let counter = 0 + for (const task of tasks) { + counter += 1 + setTaskNo.run(counter, task.id) + } + setProjectCounter.run(counter, project.id) + } + } + } } ] diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 9108e83..f26e7a5 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -31,6 +31,7 @@ export const createTaskSchema = z.object({ description: z.string().max(5000).optional(), status: z.enum(['inbox', 'assigned', 'in_progress', 'review', 'quality_review', 'done']).default('inbox'), priority: z.enum(['critical', 'high', 'medium', 'low']).default('medium'), + project_id: z.number().int().positive().optional(), assigned_to: z.string().max(100).optional(), created_by: z.string().max(100).optional(), due_date: z.number().optional(), diff --git a/src/store/index.ts b/src/store/index.ts index 4eb0578..e130a16 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -93,6 +93,11 @@ export interface Task { description?: string status: 'inbox' | 'assigned' | 'in_progress' | 'review' | 'quality_review' | 'done' priority: 'low' | 'medium' | 'high' | 'critical' | 'urgent' + project_id?: number + project_ticket_no?: number + project_name?: string + project_prefix?: string + ticket_ref?: string assigned_to?: string created_by: string created_at: number