/** * Team Routes - REST API for team message visualization & management * * Directory Structure (NEW - session-bound): * .workflow/.team/ * ├── TLS-demo-2026-02-15/ # session-id (root) * │ ├── .msg/ # messages (session-level) * │ │ ├── meta.json * │ │ └── messages.jsonl * │ ├── spec/ # artifacts (siblings of .msg) * │ └── plan/ * * Legacy Support: Also scans .workflow/.team-msg/{team-name}/ * * Endpoints: * - GET /api/teams - List all teams (with ?location filter) * - GET /api/teams/:name/messages - Get messages (with filters) * - GET /api/teams/:name/status - Get member status summary * - GET /api/teams/:name/artifacts - Get artifacts tree structure * - GET /api/teams/:name/artifacts/*path - Get artifact file content * - POST /api/teams/:name/archive - Archive a team * - POST /api/teams/:name/unarchive - Unarchive a team * - DELETE /api/teams/:name - Delete a team */ import { existsSync, readdirSync, rmSync, statSync, readFileSync } from 'fs'; import { join, extname } from 'path'; import type { RouteContext } from './types.js'; import { readAllMessages, getLogDir, getEffectiveTeamMeta, readTeamMeta, writeTeamMeta } from '../../tools/team-msg.js'; import type { TeamMeta } from '../../tools/team-msg.js'; import { getProjectRoot } from '../../utils/path-validator.js'; /** * Artifact node structure for tree representation */ interface ArtifactNode { type: 'file' | 'directory'; name: string; path: string; // Relative to session directory contentType: 'markdown' | 'json' | 'text' | 'unknown'; size?: number; // File size (bytes) modifiedAt?: string; // Last modified time children?: ArtifactNode[]; // Directory children } /** * Detect content type from file extension */ function detectContentType(fileName: string): ArtifactNode['contentType'] { const ext = extname(fileName).toLowerCase(); if (['.md', '.markdown'].includes(ext)) return 'markdown'; if (['.json'].includes(ext)) return 'json'; if (['.txt', '.log', '.tsv', '.csv'].includes(ext)) return 'text'; return 'unknown'; } /** * Recursively scan artifacts directory * Skips .msg directory (message storage) */ function scanArtifactsDirectory(dirPath: string, basePath: string): ArtifactNode[] { const nodes: ArtifactNode[] = []; if (!existsSync(dirPath)) { return nodes; } try { const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { // Skip .msg directory - that's for messages, not artifacts if (entry.name === '.msg') continue; const fullPath = join(dirPath, entry.name); const relativePath = join(basePath, entry.name).replace(/\\/g, '/'); if (entry.isDirectory()) { const children = scanArtifactsDirectory(fullPath, relativePath); const stat = statSync(fullPath); nodes.push({ type: 'directory', name: entry.name, path: relativePath, contentType: 'unknown', modifiedAt: stat.mtime.toISOString(), children, }); } else if (entry.isFile()) { const stat = statSync(fullPath); nodes.push({ type: 'file', name: entry.name, path: relativePath, contentType: detectContentType(entry.name), size: stat.size, modifiedAt: stat.mtime.toISOString(), }); } } // Sort: directories first, then files, both alphabetically nodes.sort((a, b) => { if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); } catch (e) { // Ignore errors (permission, etc.) } return nodes; } /** * Get session directory (artifacts are siblings of .msg/) * NEW: .workflow/.team/{session-id}/ */ function getSessionDir(sessionId: string, root: string): string { return join(root, '.workflow', '.team', sessionId); } /** * Get legacy team directory * OLD: .workflow/.team-msg/{team-name}/ */ function getLegacyTeamDir(teamName: string, root: string): string { return join(root, '.workflow', '.team-msg', teamName); } /** * List all sessions from new .team/ directory * Each subdirectory with .msg/ folder is a valid session */ function listSessions(root: string): Array<{ sessionId: string; path: string }> { const teamDir = join(root, '.workflow', '.team'); const sessions: Array<{ sessionId: string; path: string }> = []; console.log('[team-routes] listSessions - root:', root); console.log('[team-routes] listSessions - teamDir:', teamDir); console.log('[team-routes] listSessions - existsSync(teamDir):', existsSync(teamDir)); if (!existsSync(teamDir)) { return sessions; } try { const entries = readdirSync(teamDir, { withFileTypes: true }); console.log('[team-routes] listSessions - entries:', entries.map(e => e.name)); for (const entry of entries) { if (entry.isDirectory()) { const msgDir = join(teamDir, entry.name, '.msg'); console.log('[team-routes] listSessions - checking msgDir:', msgDir, 'exists:', existsSync(msgDir)); if (existsSync(msgDir)) { sessions.push({ sessionId: entry.name, path: join(teamDir, entry.name) }); } } } } catch (err) { console.error('[team-routes] listSessions error:', err); } console.log('[team-routes] listSessions - found sessions:', sessions.length); return sessions; } /** * List teams from old .team-msg/ directory (backward compatibility) */ function listLegacyTeams(root: string): Array<{ teamName: string; path: string }> { const teamMsgDir = join(root, '.workflow', '.team-msg'); const teams: Array<{ teamName: string; path: string }> = []; if (!existsSync(teamMsgDir)) { return teams; } try { const entries = readdirSync(teamMsgDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { teams.push({ teamName: entry.name, path: join(teamMsgDir, entry.name) }); } } } catch { // Ignore errors } return teams; } function jsonResponse(res: import('http').ServerResponse, status: number, data: unknown): void { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); } /** * Resolve project root from context * Priority: initialPath (from server startup) > getProjectRoot() */ function resolveProjectRoot(ctx: RouteContext): string { return ctx.initialPath || getProjectRoot(); } export async function handleTeamRoutes(ctx: RouteContext): Promise { const { pathname, req, res, url, handlePostRequest } = ctx; if (!pathname.startsWith('/api/teams')) return false; // ====== GET /api/teams/debug - Debug endpoint ====== if (pathname === '/api/teams/debug' && req.method === 'GET') { const root = resolveProjectRoot(ctx); const teamDir = join(root, '.workflow', '.team'); const legacyDir = join(root, '.workflow', '.team-msg'); const debug = { projectRoot: root, teamDir, legacyDir, teamDirExists: existsSync(teamDir), legacyDirExists: existsSync(legacyDir), teamDirContents: [] as string[], sessionMsgDirs: [] as { session: string; msgExists: boolean }[], }; if (existsSync(teamDir)) { try { const entries = readdirSync(teamDir, { withFileTypes: true }); debug.teamDirContents = entries.map(e => `${e.name} (${e.isDirectory() ? 'dir' : 'file'})`); for (const entry of entries) { if (entry.isDirectory()) { const msgDir = join(teamDir, entry.name, '.msg'); debug.sessionMsgDirs.push({ session: entry.name, msgExists: existsSync(msgDir), }); } } } catch (err) { (debug as any).error = String(err); } } jsonResponse(res, 200, debug); return true; } // ====== GET /api/teams - List all teams ====== if (pathname === '/api/teams' && req.method === 'GET') { try { const root = resolveProjectRoot(ctx); const locationFilter = url.searchParams.get('location') || 'active'; // Collect from new session-bound structure const sessions = listSessions(root); // Collect from legacy structure const legacyTeams = listLegacyTeams(root); // Build unified team list const teams: Array<{ name: string; messageCount: number; lastActivity: string; status: string; created_at: string; updated_at: string; archived_at?: string; pipeline_mode?: string; memberCount: number; members: string[]; isLegacy: boolean; }> = []; // Process new sessions for (const session of sessions) { const messages = readAllMessages(session.sessionId); const lastMsg = messages[messages.length - 1]; const meta = getEffectiveTeamMeta(session.sessionId); const memberSet = new Set(); for (const msg of messages) { memberSet.add(msg.from); memberSet.add(msg.to); } teams.push({ name: session.sessionId, messageCount: messages.length, lastActivity: lastMsg?.ts || '', status: meta.status, created_at: meta.created_at, updated_at: meta.updated_at, archived_at: meta.archived_at, pipeline_mode: meta.pipeline_mode, memberCount: memberSet.size, members: Array.from(memberSet), isLegacy: false, }); } // Process legacy teams for (const team of legacyTeams) { // Skip if already found in new structure (same name) if (teams.some(t => t.name === team.teamName)) continue; const messages = readAllMessages(team.teamName); const lastMsg = messages[messages.length - 1]; const meta = getEffectiveTeamMeta(team.teamName); const memberSet = new Set(); for (const msg of messages) { memberSet.add(msg.from); memberSet.add(msg.to); } teams.push({ name: team.teamName, messageCount: messages.length, lastActivity: lastMsg?.ts || '', status: meta.status, created_at: meta.created_at, updated_at: meta.updated_at, archived_at: meta.archived_at, pipeline_mode: meta.pipeline_mode, memberCount: memberSet.size, members: Array.from(memberSet), isLegacy: true, }); } // Apply filters const filteredTeams = teams .filter(t => { if (locationFilter === 'all') return true; if (locationFilter === 'archived') return t.status === 'archived'; return t.status !== 'archived'; }) .sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)); jsonResponse(res, 200, { teams: filteredTeams }); return true; } catch (error) { jsonResponse(res, 500, { error: (error as Error).message }); return true; } } // ====== POST /api/teams/:name/archive ====== const archiveMatch = pathname.match(/^\/api\/teams\/([^/]+)\/archive$/); if (archiveMatch && req.method === 'POST') { const teamName = decodeURIComponent(archiveMatch[1]); handlePostRequest(req, res, async () => { const dir = getLogDir(teamName); if (!existsSync(dir)) { throw new Error(`Team "${teamName}" not found`); } const meta = getEffectiveTeamMeta(teamName); meta.status = 'archived'; meta.archived_at = new Date().toISOString(); meta.updated_at = new Date().toISOString(); writeTeamMeta(teamName, meta); return { success: true, team: teamName, status: 'archived' }; }); return true; } // ====== POST /api/teams/:name/unarchive ====== const unarchiveMatch = pathname.match(/^\/api\/teams\/([^/]+)\/unarchive$/); if (unarchiveMatch && req.method === 'POST') { const teamName = decodeURIComponent(unarchiveMatch[1]); handlePostRequest(req, res, async () => { const dir = getLogDir(teamName); if (!existsSync(dir)) { throw new Error(`Team "${teamName}" not found`); } const meta = getEffectiveTeamMeta(teamName); meta.status = 'active'; delete meta.archived_at; meta.updated_at = new Date().toISOString(); writeTeamMeta(teamName, meta); return { success: true, team: teamName, status: 'active' }; }); return true; } // ====== DELETE /api/teams/:name ====== const deleteMatch = pathname.match(/^\/api\/teams\/([^/]+)$/); if (deleteMatch && req.method === 'DELETE') { const teamName = decodeURIComponent(deleteMatch[1]); const root = resolveProjectRoot(ctx); try { // Try new session-bound location first const sessionDir = getSessionDir(teamName, root); if (existsSync(sessionDir)) { rmSync(sessionDir, { recursive: true, force: true }); jsonResponse(res, 200, { success: true, team: teamName, deleted: true }); return true; } // Fallback to legacy location const legacyDir = getLegacyTeamDir(teamName, root); if (existsSync(legacyDir)) { rmSync(legacyDir, { recursive: true, force: true }); jsonResponse(res, 200, { success: true, team: teamName, deleted: true }); return true; } jsonResponse(res, 404, { error: `Team "${teamName}" not found` }); return true; } catch (error) { jsonResponse(res, 500, { error: (error as Error).message }); return true; } } // ====== GET requests only from here ====== if (req.method !== 'GET') return false; // ====== GET /api/teams/:name/artifacts or /api/teams/:name/artifacts/*path ====== const artifactsMatch = pathname.match(/^\/api\/teams\/([^/]+)\/artifacts(?:\/(.*))?$/); if (artifactsMatch) { const artifactsTeamName = decodeURIComponent(artifactsMatch[1]); const artifactPath = artifactsMatch[2] ? decodeURIComponent(artifactsMatch[2]) : null; const root = resolveProjectRoot(ctx); try { // NEW: Session directory contains both .msg/ and artifacts // The team name IS the session ID now const sessionDir = getSessionDir(artifactsTeamName, root); if (!existsSync(sessionDir)) { // Check if it's a legacy team with session_id const meta = getEffectiveTeamMeta(artifactsTeamName); if (meta.session_id) { // Legacy team with session_id - redirect to session directory const legacySessionDir = getSessionDir(meta.session_id, root); if (existsSync(legacySessionDir)) { serveArtifacts(legacySessionDir, meta.session_id, meta, artifactPath, res); return true; } } jsonResponse(res, 200, { tree: [], sessionId: null, message: 'Session directory not found' }); return true; } // Direct session access - artifacts are siblings of .msg/ const meta = getEffectiveTeamMeta(artifactsTeamName); serveArtifacts(sessionDir, artifactsTeamName, meta, artifactPath, res); return true; } catch (error) { jsonResponse(res, 500, { error: (error as Error).message }); return true; } } // ====== GET /api/teams/:name/messages or /api/teams/:name/status ====== const match = pathname.match(/^\/api\/teams\/([^/]+)\/(messages|status)$/); if (!match) return false; const teamName = decodeURIComponent(match[1]); const action = match[2]; // GET /api/teams/:name/messages if (action === 'messages') { try { let messages = readAllMessages(teamName); // Apply query filters const fromFilter = url.searchParams.get('from'); const toFilter = url.searchParams.get('to'); const typeFilter = url.searchParams.get('type'); const last = parseInt(url.searchParams.get('last') || '50', 10); const offset = parseInt(url.searchParams.get('offset') || '0', 10); if (fromFilter) messages = messages.filter(m => m.from === fromFilter); if (toFilter) messages = messages.filter(m => m.to === toFilter); if (typeFilter) messages = messages.filter(m => m.type === typeFilter); const total = messages.length; const sliced = messages.slice(Math.max(0, total - last - offset), total - offset); jsonResponse(res, 200, { total, showing: sliced.length, messages: sliced }); return true; } catch (error) { jsonResponse(res, 500, { error: (error as Error).message }); return true; } } // GET /api/teams/:name/status if (action === 'status') { try { const messages = readAllMessages(teamName); const memberMap = new Map(); for (const msg of messages) { for (const role of [msg.from, msg.to]) { if (!memberMap.has(role)) { memberMap.set(role, { member: role, lastSeen: msg.ts, lastAction: '', messageCount: 0 }); } } const entry = memberMap.get(msg.from)!; entry.lastSeen = msg.ts; entry.lastAction = `sent ${msg.type} -> ${msg.to}`; entry.messageCount++; } const members = Array.from(memberMap.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen)); jsonResponse(res, 200, { members, total_messages: messages.length }); return true; } catch (error) { jsonResponse(res, 500, { error: (error as Error).message }); return true; } } return false; } /** * Serve artifacts from session directory */ function serveArtifacts( sessionDir: string, sessionId: string, meta: TeamMeta, artifactPath: string | null, res: import('http').ServerResponse ): void { // If specific file path requested if (artifactPath) { const filePath = join(sessionDir, artifactPath); if (!existsSync(filePath)) { jsonResponse(res, 404, { error: 'Artifact not found', path: artifactPath }); return; } const stat = statSync(filePath); if (stat.isDirectory()) { // Return directory listing const children = scanArtifactsDirectory(filePath, artifactPath); jsonResponse(res, 200, { type: 'directory', name: artifactPath.split('/').pop() || '', path: artifactPath, children, modifiedAt: stat.mtime.toISOString() }); return; } // Return file content const content = readFileSync(filePath, 'utf-8'); const contentType = detectContentType(artifactPath.split('/').pop() || ''); jsonResponse(res, 200, { type: 'file', name: artifactPath.split('/').pop() || '', path: artifactPath, contentType, size: stat.size, modifiedAt: stat.mtime.toISOString(), content }); return; } // Return full artifacts tree const tree = scanArtifactsDirectory(sessionDir, ''); jsonResponse(res, 200, { teamName: sessionId, sessionId: sessionId, sessionPath: sessionDir, pipelineMode: meta.pipeline_mode, tree, totalFiles: countFiles(tree), totalDirectories: countDirectories(tree), totalSize: countTotalSize(tree) }); } /** * Count total files in artifact tree */ function countFiles(nodes: ArtifactNode[]): number { let count = 0; for (const node of nodes) { if (node.type === 'file') { count++; } else if (node.children) { count += countFiles(node.children); } } return count; } /** * Count total directories in artifact tree */ function countDirectories(nodes: ArtifactNode[]): number { let count = 0; for (const node of nodes) { if (node.type === 'directory') { count++; if (node.children) { count += countDirectories(node.children); } } } return count; } /** * Count total size of all files in artifact tree */ function countTotalSize(nodes: ArtifactNode[]): number { let totalSize = 0; for (const node of nodes) { if (node.type === 'file' && node.size) { totalSize += node.size; } else if (node.children) { totalSize += countTotalSize(node.children); } } return totalSize; }