From 4a0b1aa69869e0b9858102725a9a66b77449dc69 Mon Sep 17 00:00:00 2001 From: iliya Date: Thu, 12 Mar 2026 17:01:44 +0200 Subject: [PATCH] feat: implement runtime control API for team management - Added runtime control API to manage team lifecycle, including launch and stop functionalities. - Integrated runtime state retrieval to provide current status of teams. - Enhanced tests to validate the new runtime control features, ensuring proper functionality and error handling. - Updated type definitions to include runtime API methods for better type safety and clarity in team management. --- agent-teams-controller/src/controller.js | 3 + .../src/internal/runtime.js | 267 ++++++++++++++++++ .../test/controller.test.js | 120 ++++++++ mcp-server/src/agent-teams-controller.d.ts | 7 + mcp-server/src/tools/index.ts | 2 + mcp-server/src/tools/runtimeTools.ts | 78 +++++ mcp-server/test/tools.test.ts | 133 +++++++++ src/main/http/index.ts | 4 + src/main/http/teams.ts | 216 ++++++++++++++ src/main/index.ts | 39 +++ src/main/ipc/httpServer.ts | 2 + .../services/team/ChangeExtractorService.ts | 67 ++--- src/main/services/team/TeamControlApiState.ts | 47 +++ .../services/team/TeamMemberLogsFinder.ts | 253 +++++++++++++++-- .../services/team/TeamProvisioningService.ts | 46 ++- .../team/review/CodeMirrorDiffView.tsx | 183 +++++++----- src/shared/types/team.ts | 7 + test/main/http/teams.test.ts | 208 ++++++++++++++ 18 files changed, 1540 insertions(+), 142 deletions(-) create mode 100644 agent-teams-controller/src/internal/runtime.js create mode 100644 mcp-server/src/tools/runtimeTools.ts create mode 100644 src/main/http/teams.ts create mode 100644 src/main/services/team/TeamControlApiState.ts create mode 100644 test/main/http/teams.test.ts diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index 262c33e0..4a4ba19c 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -6,6 +6,7 @@ const messages = require('./internal/messages.js'); const processes = require('./internal/processes.js'); const maintenance = require('./internal/maintenance.js'); const crossTeam = require('./internal/crossTeam.js'); +const runtime = require('./internal/runtime.js'); function bindModule(context, moduleApi) { return Object.fromEntries( @@ -28,6 +29,7 @@ function createController(options) { processes: bindModule(context, processes), maintenance: bindModule(context, maintenance), crossTeam: bindModule(context, crossTeam), + runtime: bindModule(context, runtime), }; } @@ -41,4 +43,5 @@ module.exports = { processes, maintenance, crossTeam, + runtime, }; diff --git a/agent-teams-controller/src/internal/runtime.js b/agent-teams-controller/src/internal/runtime.js new file mode 100644 index 00000000..a4a51075 --- /dev/null +++ b/agent-teams-controller/src/internal/runtime.js @@ -0,0 +1,267 @@ +const fs = require('fs'); +const path = require('path'); + +const READY_STATES = new Set(['ready', 'failed', 'disconnected', 'cancelled']); +const DEFAULT_WAIT_TIMEOUT_MS = 120000; +const MIN_WAIT_TIMEOUT_MS = 1000; +const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000; +const POLL_INTERVAL_MS = 1000; +const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeTimeoutMs(rawValue) { + const numeric = + typeof rawValue === 'number' && Number.isFinite(rawValue) + ? Math.floor(rawValue) + : DEFAULT_WAIT_TIMEOUT_MS; + return Math.min(MAX_WAIT_TIMEOUT_MS, Math.max(MIN_WAIT_TIMEOUT_MS, numeric)); +} + +function getControlApiStatePath(context) { + return path.join(context.claudeDir, TEAM_CONTROL_API_STATE_FILE); +} + +function readControlApiState(context) { + const filePath = getControlApiStatePath(context); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.baseUrl === 'string' && parsed.baseUrl.trim()) { + return parsed.baseUrl.trim(); + } + return null; + } catch (error) { + if (error && error.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function resolveControlBaseUrl(context, flags = {}) { + const explicit = + (typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) || + (typeof flags['control-url'] === 'string' && flags['control-url'].trim()) || + (typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' && + process.env.CLAUDE_TEAM_CONTROL_URL.trim()) || + readControlApiState(context); + + if (!explicit) { + throw new Error( + 'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.' + ); + } + + return explicit; +} + +async function requestJson(baseUrl, pathname, options = {}) { + const controller = new AbortController(); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs || 10000); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${baseUrl}${pathname}`, { + method: options.method || 'GET', + headers: { + accept: 'application/json', + ...(options.body ? { 'content-type': 'application/json' } : {}), + }, + ...(options.body ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + + let payload = null; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const detail = + payload && typeof payload.error === 'string' && payload.error.trim() + ? payload.error.trim() + : `${response.status} ${response.statusText}`.trim(); + throw new Error(detail || 'Team control API request failed'); + } + + return payload; + } catch (error) { + if (error && error.name === 'AbortError') { + throw new Error(`Timed out calling team control API: ${pathname}`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +function buildLaunchRequest(flags = {}) { + const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : ''; + if (!cwd) { + throw new Error('Missing cwd'); + } + + return { + cwd, + ...(typeof flags.prompt === 'string' && flags.prompt.trim() + ? { prompt: flags.prompt.trim() } + : {}), + ...(typeof flags.model === 'string' && flags.model.trim() + ? { model: flags.model.trim() } + : {}), + ...(typeof flags.effort === 'string' && flags.effort.trim() + ? { effort: flags.effort.trim() } + : {}), + ...(typeof flags.clearContext === 'boolean' ? { clearContext: flags.clearContext } : {}), + ...(typeof flags['clear-context'] === 'boolean' + ? { clearContext: flags['clear-context'] } + : {}), + ...(typeof flags.skipPermissions === 'boolean' + ? { skipPermissions: flags.skipPermissions } + : {}), + ...(typeof flags['skip-permissions'] === 'boolean' + ? { skipPermissions: flags['skip-permissions'] } + : {}), + ...(typeof flags.worktree === 'string' && flags.worktree.trim() + ? { worktree: flags.worktree.trim() } + : {}), + ...(typeof flags.extraCliArgs === 'string' && flags.extraCliArgs.trim() + ? { extraCliArgs: flags.extraCliArgs.trim() } + : {}), + ...(typeof flags['extra-cli-args'] === 'string' && flags['extra-cli-args'].trim() + ? { extraCliArgs: flags['extra-cli-args'].trim() } + : {}), + }; +} + +function shouldWaitForReady(flags = {}) { + if (typeof flags.waitForReady === 'boolean') { + return flags.waitForReady; + } + if (typeof flags['wait-for-ready'] === 'boolean') { + return flags['wait-for-ready']; + } + return true; +} + +function shouldWaitForStop(flags = {}) { + if (typeof flags.waitForStop === 'boolean') { + return flags.waitForStop; + } + if (typeof flags['wait-for-stop'] === 'boolean') { + return flags['wait-for-stop']; + } + return true; +} + +async function waitForProvisioningState(baseUrl, teamName, runId, timeoutMs) { + const startedAt = Date.now(); + let lastProgress = null; + + while (Date.now() - startedAt <= timeoutMs) { + const progress = await requestJson(baseUrl, `/api/teams/provisioning/${encodeURIComponent(runId)}`, { + timeoutMs: Math.min(timeoutMs, 10000), + }); + lastProgress = progress; + + if (progress && READY_STATES.has(progress.state)) { + if (progress.state !== 'ready') { + const suffix = + progress && typeof progress.error === 'string' && progress.error.trim() + ? `: ${progress.error.trim()}` + : ''; + throw new Error(`Team ${teamName} did not become ready (${progress.state})${suffix}`); + } + + return { + teamName, + runId, + isAlive: true, + progress, + }; + } + + await sleep(POLL_INTERVAL_MS); + } + + const stateLabel = + lastProgress && typeof lastProgress.state === 'string' ? ` while in state ${lastProgress.state}` : ''; + throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`); +} + +async function waitForStopped(baseUrl, teamName, timeoutMs) { + const startedAt = Date.now(); + + while (Date.now() - startedAt <= timeoutMs) { + const runtime = await requestJson( + baseUrl, + `/api/teams/${encodeURIComponent(teamName)}/runtime`, + { timeoutMs: Math.min(timeoutMs, 10000) } + ); + + if (!runtime || runtime.isAlive !== true) { + return runtime; + } + + await sleep(POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for team ${teamName} to stop`); +} + +async function launchTeam(context, flags = {}) { + const baseUrl = resolveControlBaseUrl(context, flags); + const request = buildLaunchRequest(flags); + const launch = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/launch`, { + method: 'POST', + body: request, + }); + + if (!shouldWaitForReady(flags)) { + return { + teamName: context.teamName, + waitForReady: false, + ...launch, + }; + } + + return waitForProvisioningState( + baseUrl, + context.teamName, + launch.runId, + normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) + ); +} + +async function stopTeam(context, flags = {}) { + const baseUrl = resolveControlBaseUrl(context, flags); + const stopped = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/stop`, { + method: 'POST', + }); + + if (!shouldWaitForStop(flags)) { + return stopped; + } + + return waitForStopped( + baseUrl, + context.teamName, + normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) + ); +} + +async function getRuntimeState(context, flags = {}) { + const baseUrl = resolveControlBaseUrl(context, flags); + return requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`); +} + +module.exports = { + launchTeam, + stopTeam, + getRuntimeState, +}; diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 6f03a1b5..161e97c3 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const http = require('http'); const os = require('os'); const path = require('path'); @@ -27,6 +28,36 @@ describe('agent-teams-controller API', () => { return dir; } + async function startControlServer(handler) { + const server = http.createServer(async (req, res) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', async () => { + try { + const bodyText = Buffer.concat(chunks).toString('utf8'); + const body = bodyText ? JSON.parse(bodyText) : undefined; + const result = await handler({ + method: req.method, + url: req.url, + body, + }); + res.writeHead(result.statusCode || 200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(result.body)); + } catch (error) { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + } + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + }; + } + it('creates tasks and exposes grouped controller modules', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -560,4 +591,93 @@ describe('agent-teams-controller API', () => { 'This should persist despite notification failure.' ); }); + + it('launches and stops a team through the runtime control API bridge', async () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const calls = []; + + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + + if (method === 'POST' && url === '/api/teams/my-team/launch') { + return { body: { runId: 'run-123' } }; + } + if (method === 'GET' && url === '/api/teams/provisioning/run-123') { + return { + body: { + runId: 'run-123', + teamName: 'my-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:01.000Z', + }, + }; + } + if (method === 'POST' && url === '/api/teams/my-team/stop') { + return { + body: { + teamName: 'my-team', + isAlive: false, + runId: null, + progress: null, + }, + }; + } + if (method === 'GET' && url === '/api/teams/my-team/runtime') { + return { + body: { + teamName: 'my-team', + isAlive: false, + runId: null, + progress: null, + }, + }; + } + + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const launched = await controller.runtime.launchTeam({ + cwd: '/tmp/project', + controlUrl: server.baseUrl, + }); + expect(launched.runId).toBe('run-123'); + expect(launched.isAlive).toBe(true); + expect(launched.progress.state).toBe('ready'); + + const stopped = await controller.runtime.stopTeam({ + controlUrl: server.baseUrl, + }); + expect(stopped.isAlive).toBe(false); + expect(stopped.runId).toBeNull(); + + expect(calls).toEqual([ + { + method: 'POST', + url: '/api/teams/my-team/launch', + body: { cwd: '/tmp/project' }, + }, + { + method: 'GET', + url: '/api/teams/provisioning/run-123', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/my-team/stop', + body: undefined, + }, + { + method: 'GET', + url: '/api/teams/my-team/runtime', + body: undefined, + }, + ]); + } finally { + await server.close(); + } + }); }); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 04043d6f..bccd8f76 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -66,6 +66,12 @@ declare module 'agent-teams-controller' { getCrossTeamOutbox(): unknown; } + export interface ControllerRuntimeApi { + launchTeam(flags: Record): Promise; + stopTeam(flags?: Record): Promise; + getRuntimeState(flags?: Record): Promise; + } + export interface AgentTeamsController { tasks: ControllerTaskApi; kanban: ControllerKanbanApi; @@ -74,6 +80,7 @@ declare module 'agent-teams-controller' { processes: ControllerProcessApi; maintenance: ControllerMaintenanceApi; crossTeam: ControllerCrossTeamApi; + runtime: ControllerRuntimeApi; } export function createController(options: ControllerContextOptions): AgentTeamsController; diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index 755b24f7..ec5b94eb 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -5,6 +5,7 @@ import { registerKanbanTools } from './kanbanTools'; import { registerMessageTools } from './messageTools'; import { registerProcessTools } from './processTools'; import { registerReviewTools } from './reviewTools'; +import { registerRuntimeTools } from './runtimeTools'; import { registerTaskTools } from './taskTools'; export function registerTools(server: FastMCP) { @@ -13,5 +14,6 @@ export function registerTools(server: FastMCP) { registerReviewTools(server); registerMessageTools(server); registerProcessTools(server); + registerRuntimeTools(server); registerCrossTeamTools(server); } diff --git a/mcp-server/src/tools/runtimeTools.ts b/mcp-server/src/tools/runtimeTools.ts new file mode 100644 index 00000000..c0a158ce --- /dev/null +++ b/mcp-server/src/tools/runtimeTools.ts @@ -0,0 +1,78 @@ +import type { FastMCP } from 'fastmcp'; +import { z } from 'zod'; + +import { getController } from '../controller'; +import { jsonTextContent } from '../utils/format'; + +const toolContextSchema = { + teamName: z.string().min(1), + claudeDir: z.string().min(1).optional(), + controlUrl: z.string().url().optional(), + waitTimeoutMs: z.number().int().min(1000).max(600000).optional(), +}; + +export function registerRuntimeTools(server: Pick) { + server.addTool({ + name: 'team_launch', + description: 'Launch a provisioned team via the desktop runtime', + parameters: z.object({ + ...toolContextSchema, + cwd: z.string().min(1), + prompt: z.string().min(1).optional(), + model: z.string().min(1).optional(), + effort: z.enum(['low', 'medium', 'high']).optional(), + clearContext: z.boolean().optional(), + skipPermissions: z.boolean().optional(), + worktree: z.string().min(1).optional(), + extraCliArgs: z.string().min(1).optional(), + waitForReady: z.boolean().optional(), + }), + execute: async ({ + teamName, + claudeDir, + controlUrl, + waitTimeoutMs, + cwd, + prompt, + model, + effort, + clearContext, + skipPermissions, + worktree, + extraCliArgs, + waitForReady, + }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.launchTeam({ + cwd, + ...(prompt ? { prompt } : {}), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + ...(clearContext !== undefined ? { clearContext } : {}), + ...(skipPermissions !== undefined ? { skipPermissions } : {}), + ...(worktree ? { worktree } : {}), + ...(extraCliArgs ? { extraCliArgs } : {}), + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + ...(waitForReady !== undefined ? { waitForReady } : {}), + }) + ), + }); + + server.addTool({ + name: 'team_stop', + description: 'Stop a running team via the desktop runtime', + parameters: z.object({ + ...toolContextSchema, + waitForStop: z.boolean().optional(), + }), + execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => + jsonTextContent( + await getController(teamName, claudeDir).runtime.stopTeam({ + ...(controlUrl ? { controlUrl } : {}), + ...(waitTimeoutMs ? { waitTimeoutMs } : {}), + ...(waitForStop !== undefined ? { waitForStop } : {}), + }) + ), + }); +} diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index bd0e76ba..a1192eb6 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import http from 'http'; import os from 'os'; import path from 'path'; @@ -61,6 +62,8 @@ describe('agent-teams-mcp tools', () => { 'task_set_status', 'task_start', 'task_unlink', + 'team_launch', + 'team_stop', ] as const; function getTool(name: string) { @@ -73,6 +76,45 @@ describe('agent-teams-mcp tools', () => { return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-')); } + async function startControlServer( + handler: (request: { + method?: string; + url?: string; + body?: unknown; + }) => Promise<{ statusCode?: number; body: unknown }> | { statusCode?: number; body: unknown } + ) { + const server = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', async () => { + try { + const bodyText = Buffer.concat(chunks).toString('utf8'); + const body = bodyText ? JSON.parse(bodyText) : undefined; + const result = await handler({ method: req.method, url: req.url, body }); + res.writeHead(result.statusCode ?? 200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(result.body)); + } catch (error) { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + }); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind control server'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: async () => + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ), + }; + } + it('registers the full expected MCP tool surface', () => { expect([...tools.keys()].sort()).toEqual([...expectedToolNames]); }); @@ -89,6 +131,97 @@ describe('agent-teams-mcp tools', () => { expect(parsed?.success).toBe(true); }); + it('launches and stops teams through the runtime MCP tools', async () => { + const calls: Array<{ method?: string; url?: string; body?: unknown }> = []; + const server = await startControlServer(async ({ method, url, body }) => { + calls.push({ method, url, body }); + + if (method === 'POST' && url === '/api/teams/alpha/launch') { + return { body: { runId: 'run-555' } }; + } + if (method === 'GET' && url === '/api/teams/provisioning/run-555') { + return { + body: { + runId: 'run-555', + teamName: 'alpha', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:02.000Z', + }, + }; + } + if (method === 'POST' && url === '/api/teams/alpha/stop') { + return { + body: { + teamName: 'alpha', + isAlive: false, + runId: null, + progress: null, + }, + }; + } + if (method === 'GET' && url === '/api/teams/alpha/runtime') { + return { + body: { + teamName: 'alpha', + isAlive: false, + runId: null, + progress: null, + }, + }; + } + + return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } }; + }); + + try { + const launched = parseJsonToolResult( + await getTool('team_launch').execute({ + teamName: 'alpha', + cwd: '/tmp/project', + controlUrl: server.baseUrl, + }) + ); + expect(launched.runId).toBe('run-555'); + expect(launched.isAlive).toBe(true); + expect(launched.progress.state).toBe('ready'); + + const stopped = parseJsonToolResult( + await getTool('team_stop').execute({ + teamName: 'alpha', + controlUrl: server.baseUrl, + }) + ); + expect(stopped.isAlive).toBe(false); + + expect(calls).toEqual([ + { + method: 'POST', + url: '/api/teams/alpha/launch', + body: { cwd: '/tmp/project' }, + }, + { + method: 'GET', + url: '/api/teams/provisioning/run-555', + body: undefined, + }, + { + method: 'POST', + url: '/api/teams/alpha/stop', + body: undefined, + }, + { + method: 'GET', + url: '/api/teams/alpha/runtime', + body: undefined, + }, + ]); + } finally { + await server.close(); + } + }); + it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => { const claudeDir = makeClaudeDir(); const teamName = 'alpha'; diff --git a/src/main/http/index.ts b/src/main/http/index.ts index e9af8f6f..30ce2adb 100644 --- a/src/main/http/index.ts +++ b/src/main/http/index.ts @@ -15,6 +15,7 @@ import { registerSearchRoutes } from './search'; import { registerSessionRoutes } from './sessions'; import { registerSshRoutes } from './ssh'; import { registerSubagentRoutes } from './subagents'; +import { registerTeamRoutes } from './teams'; import { registerUpdaterRoutes } from './updater'; import { registerUtilityRoutes } from './utility'; import { registerValidationRoutes } from './validation'; @@ -28,6 +29,7 @@ import type { UpdaterService, } from '../services'; import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager'; +import type { TeamProvisioningService } from '../services/team/TeamProvisioningService'; import type { FastifyInstance } from 'fastify'; const logger = createLogger('HTTP:routes'); @@ -40,6 +42,7 @@ export interface HttpServices { dataCache: DataCache; updaterService: UpdaterService; sshConnectionManager: SshConnectionManager; + teamProvisioningService?: TeamProvisioningService; } export function registerHttpRoutes( @@ -51,6 +54,7 @@ export function registerHttpRoutes( registerSessionRoutes(app, services); registerSearchRoutes(app, services); registerSubagentRoutes(app, services); + registerTeamRoutes(app, services); registerNotificationRoutes(app); registerConfigRoutes(app); registerValidationRoutes(app); diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts new file mode 100644 index 00000000..8913e115 --- /dev/null +++ b/src/main/http/teams.ts @@ -0,0 +1,216 @@ +import { validateTeamName } from '@main/ipc/guards'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { isAbsolute } from 'path'; + +import type { HttpServices } from './index'; +import type { EffortLevel, TeamLaunchRequest } from '@shared/types/team'; +import type { FastifyInstance } from 'fastify'; + +const logger = createLogger('HTTP:teams'); + +type LaunchBody = Omit; + +const EFFORT_LEVELS = new Set(['low', 'medium', 'high']); + +class HttpBadRequestError extends Error {} + +function getTeamProvisioningService(services: HttpServices) { + if (!services.teamProvisioningService) { + throw new Error('Team runtime control is not available in this mode'); + } + return services.teamProvisioningService; +} + +function assertAbsoluteCwd(cwd: unknown): string { + if (typeof cwd !== 'string' || cwd.trim().length === 0) { + throw new HttpBadRequestError('cwd must be a non-empty string'); + } + + const normalized = cwd.trim(); + if (!isAbsolute(normalized)) { + throw new HttpBadRequestError('cwd must be an absolute path'); + } + + return normalized; +} + +function assertOptionalString(value: unknown, fieldName: string): string | undefined { + if (value == null) { + return undefined; + } + + if (typeof value !== 'string') { + throw new HttpBadRequestError(`${fieldName} must be a string`); + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function assertOptionalBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value == null) { + return undefined; + } + + if (typeof value !== 'boolean') { + throw new HttpBadRequestError(`${fieldName} must be a boolean`); + } + + return value; +} + +function assertOptionalEffort(value: unknown): EffortLevel | undefined { + if (value == null) { + return undefined; + } + + if (typeof value !== 'string' || !EFFORT_LEVELS.has(value as EffortLevel)) { + throw new HttpBadRequestError('effort must be one of: low, medium, high'); + } + + return value as EffortLevel; +} + +function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest { + const payload = body && typeof body === 'object' ? (body as Record) : {}; + const prompt = assertOptionalString(payload.prompt, 'prompt'); + const model = assertOptionalString(payload.model, 'model'); + const effort = assertOptionalEffort(payload.effort); + const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); + const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); + const worktree = assertOptionalString(payload.worktree, 'worktree'); + const extraCliArgs = assertOptionalString(payload.extraCliArgs, 'extraCliArgs'); + + return { + teamName, + cwd: assertAbsoluteCwd(payload.cwd), + ...(prompt && { + prompt, + }), + ...(model && { + model, + }), + ...(effort && { + effort, + }), + ...(clearContext !== undefined && { + clearContext, + }), + ...(skipPermissions !== undefined && { + skipPermissions, + }), + ...(worktree && { + worktree, + }), + ...(extraCliArgs && { + extraCliArgs, + }), + }; +} + +export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void { + app.post<{ Params: { teamName: string }; Body: LaunchBody }>( + '/api/teams/:teamName/launch', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + + const launchRequest = parseLaunchRequest(validatedTeamName.value!, request.body); + const response = await getTeamProvisioningService(services).launchTeam( + launchRequest, + () => undefined + ); + return reply.send(response); + } catch (error) { + const statusCode = error instanceof HttpBadRequestError ? 400 : 500; + if (!(error instanceof HttpBadRequestError)) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/launch:`, + getErrorMessage(error) + ); + } + return reply.status(statusCode).send({ error: getErrorMessage(error) }); + } + } + ); + + app.post<{ Params: { teamName: string } }>( + '/api/teams/:teamName/stop', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + + const teamProvisioningService = getTeamProvisioningService(services); + teamProvisioningService.stopTeam(validatedTeamName.value!); + return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!)); + } catch (error) { + logger.error( + `Error in POST /api/teams/${request.params.teamName}/stop:`, + getErrorMessage(error) + ); + return reply.status(500).send({ error: getErrorMessage(error) }); + } + } + ); + + app.get<{ Params: { teamName: string } }>( + '/api/teams/:teamName/runtime', + async (request, reply) => { + try { + const validatedTeamName = validateTeamName(request.params.teamName); + if (!validatedTeamName.valid) { + return reply.status(400).send({ error: validatedTeamName.error }); + } + + return reply.send( + getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!) + ); + } catch (error) { + logger.error( + `Error in GET /api/teams/${request.params.teamName}/runtime:`, + getErrorMessage(error) + ); + return reply.status(500).send({ error: getErrorMessage(error) }); + } + } + ); + + app.get<{ Params: { runId: string } }>( + '/api/teams/provisioning/:runId', + async (request, reply) => { + try { + const runId = request.params.runId?.trim(); + if (!runId) { + return reply.status(400).send({ error: 'runId is required' }); + } + + return reply.send(await getTeamProvisioningService(services).getProvisioningStatus(runId)); + } catch (error) { + const message = getErrorMessage(error); + const statusCode = message === 'Unknown runId' ? 404 : 500; + logger.error(`Error in GET /api/teams/provisioning/${request.params.runId}:`, message); + return reply.status(statusCode).send({ error: message }); + } + } + ); + + app.get('/api/teams/runtime/alive', async (_request, reply) => { + try { + const teamProvisioningService = getTeamProvisioningService(services); + const runtimeStates = teamProvisioningService + .getAliveTeams() + .map((teamName) => teamProvisioningService.getRuntimeState(teamName)); + return reply.send(runtimeStates); + } catch (error) { + logger.error('Error in GET /api/teams/runtime/alive:', getErrorMessage(error)); + return reply.status(500).send({ error: getErrorMessage(error) }); + } + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index 62a6c2ae..21012dd9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -54,6 +54,11 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; import { TeamInboxReader } from './services/team/TeamInboxReader'; +import { + buildTeamControlApiBaseUrl, + clearTeamControlApiState, + writeTeamControlApiState, +} from './services/team/TeamControlApiState'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; import { getAppIconPath } from './utils/appIcon'; import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder'; @@ -362,6 +367,24 @@ function getRendererIndexPath(): string { return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0]; } +function getTeamControlApiBaseUrl(): string | null { + if (!httpServer?.isRunning()) { + return null; + } + + return buildTeamControlApiBaseUrl(httpServer.getPort()); +} + +async function syncTeamControlApiState(): Promise { + const baseUrl = getTeamControlApiBaseUrl(); + if (!baseUrl) { + await clearTeamControlApiState(); + return; + } + + await writeTeamControlApiState(baseUrl); +} + /** * Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients. * Cleans up previous listeners before adding new ones. @@ -735,6 +758,13 @@ function initializeServices(): void { // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); + teamProvisioningService.setControlApiBaseUrlResolver(async () => { + if (!httpServer.isRunning()) { + await startHttpServer(handleModeSwitch); + } + + return getTeamControlApiBaseUrl(); + }); // Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies). const teamChangeEmitter = (event: TeamChangeEvent): void => { @@ -841,6 +871,11 @@ async function startHttpServer( modeSwitchHandler: (mode: 'local' | 'ssh') => Promise ): Promise { try { + if (httpServer.isRunning()) { + await syncTeamControlApiState(); + return; + } + const config = configManager.getConfig(); const activeContext = contextRegistry.getActive(); const port = await httpServer.start( @@ -852,12 +887,15 @@ async function startHttpServer( dataCache: activeContext.dataCache, updaterService, sshConnectionManager, + teamProvisioningService, }, modeSwitchHandler, config.httpServer?.port ?? 3456 ); + await syncTeamControlApiState(); logger.info(`HTTP sidecar server running on port ${port}`); } catch (error) { + await clearTeamControlApiState().catch(() => undefined); logger.error('Failed to start HTTP server:', error); } } @@ -872,6 +910,7 @@ function shutdownServices(): void { if (httpServer?.isRunning()) { void httpServer.stop(); } + void clearTeamControlApiState(); // Clean up file watcher event listeners if (fileChangeCleanup) { diff --git a/src/main/ipc/httpServer.ts b/src/main/ipc/httpServer.ts index 28ba0927..0d794e70 100644 --- a/src/main/ipc/httpServer.ts +++ b/src/main/ipc/httpServer.ts @@ -11,6 +11,7 @@ import { createLogger } from '@shared/utils/logger'; import { type IpcMain } from 'electron'; import { configManager } from '../services'; +import { clearTeamControlApiState } from '../services/team/TeamControlApiState'; import type { HttpServer } from '../services/infrastructure/HttpServer'; @@ -84,6 +85,7 @@ async function handleStop(): Promise<{ }> { try { await httpServer.stop(); + await clearTeamControlApiState(); configManager.updateConfig('httpServer', { enabled: false }); return { success: true, data: { running: false, port: httpServer.getPort() } }; } catch (error) { diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 16020b77..ccea20b7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -23,7 +23,6 @@ import type { FileChangeSummary, FileEditEvent, FileEditTimeline, - MemberLogSummary, SnippetDiff, TaskChangeScope, TaskChangeSetV2, @@ -277,8 +276,11 @@ export class ChangeExtractorService { includeDetails: boolean ): Promise { const taskMeta = await this.readTaskMeta(teamName, taskId); - const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); - const logRefs = await this.resolveLogFileRefs(teamName, logs); + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); if (logRefs.length === 0) { return this.emptyTaskChangeSet(teamName, taskId); } @@ -883,47 +885,6 @@ export class ChangeExtractorService { return false; } - /** Конвертировать MemberLogSummary[] в LogFileRef[] */ - private async resolveLogFileRefs( - teamName: string, - logs: MemberLogSummary[] - ): Promise { - const refs: LogFileRef[] = []; - const logsNeedingResolve: MemberLogSummary[] = []; - - for (const log of logs) { - const memberName = log.memberName ?? 'unknown'; - if (log.filePath) { - refs.push({ filePath: log.filePath, memberName }); - } else { - logsNeedingResolve.push(log); - } - } - - if (logsNeedingResolve.length === 0) return refs; - - const byMember = new Map(); - for (const log of logsNeedingResolve) { - const name = log.memberName ?? 'unknown'; - if (!byMember.has(name)) byMember.set(name, []); - byMember.get(name)!.push(log); - } - for (const [memberName, memberLogs] of byMember) { - const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName); - for (const log of memberLogs) { - const matchedPath = paths.find((p) => - log.kind === 'subagent' - ? p.includes(log.sessionId) && p.includes(log.subagentId) - : p.includes(log.sessionId) && p.endsWith('.jsonl') - ); - if (matchedPath) { - refs.push({ filePath: matchedPath, memberName }); - } - } - } - return refs; - } - /** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */ private async extractFilteredChanges( logRefs: LogFileRef[], @@ -1260,8 +1221,11 @@ export class ChangeExtractorService { return; } - const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); - const logRefs = await this.resolveLogFileRefs(teamName, logs); + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); const sourceFingerprint = await this.computeSourceFingerprint(logRefs); if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) { await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); @@ -1304,8 +1268,11 @@ export class ChangeExtractorService { return; } - const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); - const logRefs = await this.resolveLogFileRefs(teamName, logs); + const logRefs = await this.logsFinder.findLogFileRefsForTask( + teamName, + taskId, + effectiveOptions + ); const sourceFingerprint = await this.computeSourceFingerprint(logRefs); const projectFingerprint = await this.computeProjectFingerprint(teamName); if (!sourceFingerprint || !projectFingerprint) { @@ -1346,12 +1313,12 @@ export class ChangeExtractorService { return null; } } - return createHash('sha1').update(parts.join('|')).digest('hex'); + return createHash('sha256').update(parts.join('|')).digest('hex'); } private async computeProjectFingerprint(teamName: string): Promise { const projectPath = await this.resolveProjectPath(teamName); if (!projectPath) return null; - return createHash('sha1').update(this.normalizeFilePathKey(projectPath)).digest('hex'); + return createHash('sha256').update(this.normalizeFilePathKey(projectPath)).digest('hex'); } } diff --git a/src/main/services/team/TeamControlApiState.ts b/src/main/services/team/TeamControlApiState.ts new file mode 100644 index 00000000..d7ee25ce --- /dev/null +++ b/src/main/services/team/TeamControlApiState.ts @@ -0,0 +1,47 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { rm } from 'fs/promises'; +import path from 'path'; + +const logger = createLogger('Service:TeamControlApiState'); + +const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json'; + +function normalizeBaseUrlHost(host: string): string { + if (host === '0.0.0.0' || host === '::') { + return '127.0.0.1'; + } + + return host; +} + +export function buildTeamControlApiBaseUrl(port: number, host: string = '127.0.0.1'): string { + return `http://${normalizeBaseUrlHost(host)}:${port}`; +} + +function getTeamControlApiStatePath(): string { + return path.join(getClaudeBasePath(), TEAM_CONTROL_API_STATE_FILE); +} + +export async function writeTeamControlApiState(baseUrl: string): Promise { + const statePath = getTeamControlApiStatePath(); + await atomicWriteAsync( + statePath, + JSON.stringify( + { + baseUrl, + pid: process.pid, + updatedAt: new Date().toISOString(), + }, + null, + 2 + ) + ); + logger.info(`Published team control API endpoint: ${baseUrl}`); +} + +export async function clearTeamControlApiState(): Promise { + const statePath = getTeamControlApiStatePath(); + await rm(statePath, { force: true }).catch(() => undefined); +} diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 45082319..e84a0aac 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -212,23 +212,14 @@ export class TeamMemberLogsFinder { // Back-compat: single since timestamp -> treat as open interval. const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN; - const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; + const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; const effectiveIntervals = normalizedIntervals.length > 0 ? normalizedIntervals - : sinceMs != null - ? [{ startMs: sinceMs, endMs: null }] + : sinceStartMs != null + ? [{ startMs: sinceStartMs, endMs: null }] : []; - const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => { - for (const it of effectiveIntervals) { - const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS; - const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS; - if (logStartMs <= end && logEndMs >= start) return true; - } - return false; - }; - const filteredOwnerLogs = ownerLogs.filter((log) => { if (log.isOngoing) return true; const startMs = new Date(log.startTime).getTime(); @@ -238,7 +229,13 @@ export class TeamMemberLogsFinder { const endMs = startMs + durationMs; if (effectiveIntervals.length > 0) { - return overlapsAnyInterval(startMs, endMs); + return this.logOverlapsIntervals( + startMs, + endMs, + effectiveIntervals, + now, + TASK_LOG_INTERVAL_GRACE_MS + ); } return startMs >= now - fallbackRecentMs; @@ -268,6 +265,156 @@ export class TeamMemberLogsFinder { ); } + /** + * Fast path for change extraction: returns task-related JSONL file refs directly without + * building full MemberLogSummary metadata for every matched log. + */ + async findLogFileRefsForTask( + teamName: string, + taskId: string, + options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + } + ): Promise<{ filePath: string; memberName: string }[]> { + const discovery = await this.discoverProjectSessions(teamName); + if (!discovery) return []; + + const sinceMs = this.deriveSinceMs(options); + const { projectDir, config, sessionIds, knownMembers } = discovery; + const refs: { filePath: string; memberName: string; sortTime: number }[] = []; + const seen = new Set(); + const leadMemberName = + config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead'; + + const pushRef = (filePath: string, memberName: string, sortTime = 0): void => { + const key = `${memberName.toLowerCase()}:${filePath}`; + if (seen.has(key)) return; + seen.add(key); + refs.push({ filePath, memberName, sortTime }); + }; + + if (config.leadSessionId) { + const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`); + try { + await fs.access(leadJsonl); + if (await this.fileMentionsTaskIdCached(leadJsonl, teamName, taskId, true, sinceMs)) { + const firstTimestamp = await this.probeFirstTimestamp(leadJsonl); + pushRef(leadJsonl, leadMemberName, await this.getSortTime(leadJsonl, firstTimestamp)); + } + } catch { + // file missing or unreadable + } + } + + for (const sessionId of sessionIds) { + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); + let files: string[]; + try { + files = await fs.readdir(subagentsDir); + } catch { + continue; + } + for (const file of files) { + if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue; + if (file.startsWith('agent-acompact')) continue; + + const filePath = path.join(subagentsDir, file); + if (!(await this.fileMentionsTaskIdCached(filePath, teamName, taskId, false, sinceMs))) { + continue; + } + + const attribution = await this.attributeSubagent(filePath, knownMembers); + if (!attribution) continue; + pushRef( + filePath, + attribution.detectedMember, + await this.getSortTime(filePath, attribution.firstTimestamp) + ); + } + } + + const normalizedOwner = + typeof options?.owner === 'string' ? options.owner.trim() : options?.owner; + const isLeadOwner = + typeof normalizedOwner === 'string' && + normalizedOwner.length > 0 && + normalizedOwner.toLowerCase() === leadMemberName.toLowerCase(); + const ownerRelevantStatus = + options?.status === 'in_progress' || options?.status === 'completed'; + const includeOwnerSessions = + ownerRelevantStatus && + typeof normalizedOwner === 'string' && + normalizedOwner.length > 0 && + !isLeadOwner; + + if (includeOwnerSessions) { + const ownerLogs = await this.findMemberLogs(teamName, normalizedOwner); + const TASK_LOG_INTERVAL_GRACE_MS = 10_000; + const fallbackRecentMs = 30 * 60_000; + const now = Date.now(); + + const normalizedIntervals = Array.isArray(options?.intervals) + ? options.intervals + .map((i) => { + const startMs = Date.parse(i.startedAt); + const endMsRaw = + typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; + const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + return Number.isFinite(startMs) ? { startMs, endMs } : null; + }) + .filter((v): v is { startMs: number; endMs: number | null } => v !== null) + : []; + + const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN; + const sinceStartMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null; + const effectiveIntervals = + normalizedIntervals.length > 0 + ? normalizedIntervals + : sinceStartMs != null + ? [{ startMs: sinceStartMs, endMs: null }] + : []; + + for (const log of ownerLogs) { + if (!log.filePath) continue; + if (!log.isOngoing) { + const startMs = new Date(log.startTime).getTime(); + if (!Number.isFinite(startMs)) continue; + const durationMs = + typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; + const endMs = startMs + durationMs; + + if (effectiveIntervals.length > 0) { + if ( + !this.logOverlapsIntervals( + startMs, + endMs, + effectiveIntervals, + now, + TASK_LOG_INTERVAL_GRACE_MS + ) + ) { + continue; + } + } else if (startMs < now - fallbackRecentMs) { + continue; + } + } + + pushRef( + log.filePath, + log.memberName ?? normalizedOwner, + Number.isFinite(new Date(log.startTime).getTime()) ? new Date(log.startTime).getTime() : 0 + ); + } + } + + const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); + return sortedRefs.map(({ filePath, memberName }) => ({ filePath, memberName })); + } + /** * Returns absolute paths to all JSONL files belonging to the specified member. * Uses the same discovery logic as findMemberLogs but collects file paths. @@ -508,6 +655,21 @@ export class TeamMemberLogsFinder { return earliest - TASK_SINCE_GRACE_MS; } + private logOverlapsIntervals( + logStartMs: number, + logEndMs: number, + intervals: { startMs: number; endMs: number | null }[], + now: number, + graceMs: number + ): boolean { + for (const it of intervals) { + const start = it.startMs - graceMs; + const end = (it.endMs ?? now) + graceMs; + if (logStartMs <= end && logEndMs >= start) return true; + } + return false; + } + private async fileMentionsTaskIdCached( filePath: string, teamName: string, @@ -649,8 +811,6 @@ export class TeamMemberLogsFinder { const b = block as Record; if (b.type !== 'tool_use') continue; - const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); const input = b.input as Record | undefined; if (!input) continue; @@ -739,7 +899,8 @@ export class TeamMemberLogsFinder { // accurate timestamps and message count from the full file. const metadata = await this.streamFileMetadata(filePath); - const firstTimestamp = metadata.firstTimestamp ?? (await this.getFileMtime(filePath)); + const firstTimestamp = + metadata.firstTimestamp ?? attribution.firstTimestamp ?? (await this.getFileMtime(filePath)); const lastTimestamp = metadata.lastTimestamp ?? firstTimestamp; const startTime = new Date(firstTimestamp); @@ -779,7 +940,11 @@ export class TeamMemberLogsFinder { private async attributeSubagent( filePath: string, knownMembers: Set - ): Promise<{ detectedMember: string; description: string } | null> { + ): Promise<{ + detectedMember: string; + description: string; + firstTimestamp: string | null; + } | null> { const lines: string[] = []; try { @@ -806,8 +971,12 @@ export class TeamMemberLogsFinder { let description = ''; let detectedMember: string | null = null; let detectionPriority = 0; + let firstTimestamp: string | null = null; for (const line of lines) { + if (!firstTimestamp) { + firstTimestamp = this.extractTimestampFromLine(line); + } // Early exit: both objectives met (member detected at max priority + description found) if (detectionPriority >= 3 && description) break; @@ -888,7 +1057,7 @@ export class TeamMemberLogsFinder { if (!detectedMember) return null; - return { detectedMember, description }; + return { detectedMember, description, firstTimestamp }; } /** @@ -1016,10 +1185,10 @@ export class TeamMemberLogsFinder { // Fast timestamp extraction without full JSON parse. // ISO prefix anchor avoids false positives from "timestamp" inside string values. - const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(trimmed); - if (tsMatch) { - if (!firstTimestamp) firstTimestamp = tsMatch[1]; - lastTimestamp = tsMatch[1]; + const ts = this.extractTimestampFromLine(trimmed); + if (ts) { + if (!firstTimestamp) firstTimestamp = ts; + lastTimestamp = ts; } } rl.close(); @@ -1031,6 +1200,46 @@ export class TeamMemberLogsFinder { return { firstTimestamp, lastTimestamp, messageCount }; } + private extractTimestampFromLine(line: string): string | null { + const tsMatch = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2}T[^"]+)"/.exec(line); + return tsMatch?.[1] ?? null; + } + + private async probeFirstTimestamp( + filePath: string, + maxLines = ATTRIBUTION_SCAN_LINES + ): Promise { + try { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); + let seen = 0; + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) continue; + const ts = this.extractTimestampFromLine(trimmed); + if (ts) { + rl.close(); + stream.destroy(); + return ts; + } + seen++; + if (seen >= maxLines) break; + } + rl.close(); + stream.destroy(); + } catch { + // ignore + } + return null; + } + + private async getSortTime(filePath: string, timestamp: string | null): Promise { + const resolvedTimestamp = timestamp ?? (await this.getFileMtime(filePath)); + const sortTime = Date.parse(resolvedTimestamp); + return Number.isFinite(sortTime) ? sortTime : 0; + } + private async getFileMtime(filePath: string): Promise { try { const stat = await fs.stat(filePath); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 79d26740..dafc28f1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -69,6 +69,7 @@ import type { TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamProvisioningState, + TeamRuntimeState, TeamTask, ToolApprovalAutoResolved, ToolApprovalEvent, @@ -761,7 +762,6 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string { function buildProvisioningPrompt(request: TeamCreateRequest): string { const displayName = request.displayName?.trim() || request.teamName; - const description = request.description?.trim() || 'No description'; const taskProtocol = buildTaskStatusProtocol(request.teamName); const processRegistration = buildProcessRegistrationProtocol(request.teamName); const userPromptBlock = request.prompt?.trim() @@ -838,14 +838,11 @@ ${persistentContext} Steps (execute in this exact order): -1) TeamCreate — create team “${request.teamName}”: - - description: “${description}” - ${step2Block} ${step3Block} -4) After all steps, output a short summary. +3) After all steps, output a short summary. `; } @@ -1153,6 +1150,7 @@ export class TeamProvisioningService { private toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS; private pendingTimeouts = new Map(); private inFlightResponses = new Set(); + private controlApiBaseUrlResolver: (() => Promise) | null = null; private crossTeamSender: | ((request: { fromTeam: string; @@ -1193,6 +1191,10 @@ export class TeamProvisioningService { this.crossTeamSender = sender; } + setControlApiBaseUrlResolver(resolver: (() => Promise) | null): void { + this.controlApiBaseUrlResolver = resolver; + } + getClaudeLogs( teamName: string, query?: { offset?: number; limit?: number } @@ -3688,6 +3690,18 @@ export class TeamProvisioningService { return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name)); } + getRuntimeState(teamName: string): TeamRuntimeState { + const runId = this.getTrackedRunId(teamName); + const run = runId ? (this.runs.get(runId) ?? null) : null; + + return { + teamName, + isAlive: this.isTeamAlive(teamName), + runId: run?.runId ?? runId ?? null, + progress: run?.progress ?? null, + }; + } + private languageChangeInFlight: Promise = Promise.resolve(); /** @@ -5686,6 +5700,11 @@ export class TeamProvisioningService { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + const controlApiBaseUrl = await this.resolveControlApiBaseUrl(); + if (controlApiBaseUrl) { + env.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl; + } + // SHELL is a Unix concept — only set it on non-Windows platforms. if (!isWindows) { env.SHELL = shell; @@ -5729,6 +5748,23 @@ export class TeamProvisioningService { return { env, authSource: 'none' }; } + private async resolveControlApiBaseUrl(): Promise { + if (!this.controlApiBaseUrlResolver) { + return null; + } + + try { + return await this.controlApiBaseUrlResolver(); + } catch (error) { + logger.warn( + `Failed to resolve team control API base URL: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + } + /** * Immediately update projectPath in config.json at launch start, before CLI spawn. * Ensures TeamDetailView shows the correct project path even if provisioning diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx index 60f09fd4..12367e8a 100644 --- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx +++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx @@ -204,6 +204,7 @@ export const CodeMirrorDiffView = ({ const containerRef = useRef(null); const viewRef = useRef(null); const endSentinelRef = useRef(null); + const lastPointerRef = useRef<{ x: number; y: number } | null>(null); // Local ref to hold externalViewRef for syncing via useEffect const externalViewRefHolder = useRef(externalViewRef); @@ -475,9 +476,10 @@ export const CodeMirrorDiffView = ({ const chunkRect = chunkEl.getBoundingClientRect(); const btnWidth = btnContainer.offsetWidth || 200; const margin = 12; + const { style } = btnContainer; // left is relative to .cm-deletedChunk — so we compute from scroller's right edge - btnContainer.style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`; - btnContainer.style.right = 'auto'; + style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`; + style.right = 'auto'; }; // Helper: position a chunkButtons container so it's below the change block, @@ -516,14 +518,48 @@ export const CodeMirrorDiffView = ({ pinToViewportRight(btnContainer, scroller); }; - // Find which chunk index the mouse is directly over, including inline deleted text. - const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => { - const el = document.elementFromPoint(event.clientX, event.clientY); + interface RenderedChunkControl { + chunkIndex: number; + chunkEl: HTMLElement; + toolbar: HTMLElement; + } + + const getRenderedChunkControls = (view: EditorView): RenderedChunkControl[] => { + const toolbars = view.dom.querySelectorAll('.cm-merge-toolbar'); + const controls: RenderedChunkControl[] = []; + toolbars.forEach((toolbar) => { + const chunkEl = toolbar.closest('.cm-deletedChunk'); + if (!chunkEl) return; + const pos = view.posAtDOM(toolbar); + controls.push({ + chunkIndex: computeHunkIndexAtPos(view.state, pos), + chunkEl, + toolbar, + }); + }); + return controls; + }; + + const resolveChunkIndexFromDeletedChunk = ( + deletedChunk: Element, + view: EditorView + ): number => { + const toolbar = deletedChunk.querySelector('.cm-merge-toolbar'); + if (!toolbar) return -1; + return computeHunkIndexAtPos(view.state, view.posAtDOM(toolbar)); + }; + + // Find which chunk index the pointer is directly over, including inline deleted text. + const findHoveredChunkIndex = ( + clientX: number, + clientY: number, + view: EditorView + ): number => { + const el = document.elementFromPoint(clientX, clientY); if (!el) return -1; const deletedChunk = el.closest('.cm-deletedChunk'); if (deletedChunk) { - const all = view.dom.querySelectorAll('.cm-deletedChunk'); - return [...all].indexOf(deletedChunk); + return resolveChunkIndexFromDeletedChunk(deletedChunk, view); } if ( el.closest( @@ -532,7 +568,7 @@ export const CodeMirrorDiffView = ({ ) { const allChunks = getChunks(view.state); if (!allChunks) return -1; - const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + const pos = view.posAtCoords({ x: clientX, y: clientY }); if (pos !== null) { for (let i = 0; i < allChunks.chunks.length; i++) { const chunk = allChunks.chunks[i]; @@ -543,55 +579,71 @@ export const CodeMirrorDiffView = ({ return -1; }; - // Find chunk nearest to cursor Y (for default "below block" display) - const findNearestChunkIndex = (clientY: number, view: EditorView): number => { - const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk'); - let result = -1; - if (allChunkEls.length > 0) { - let bestIdx = 0; - let bestDist = Infinity; - allChunkEls.forEach((el, idx) => { - const rect = el.getBoundingClientRect(); - const centerY = (rect.top + rect.bottom) / 2; - const dist = Math.abs(clientY - centerY); - if (dist < bestDist) { - bestDist = dist; - bestIdx = idx; - } - }); - result = bestIdx; + const findNearestRenderedChunk = ( + clientY: number, + renderedControls: RenderedChunkControl[] + ): RenderedChunkControl | null => { + let bestMatch: RenderedChunkControl | null = null; + let bestDist = Infinity; + renderedControls.forEach((control) => { + const rect = control.chunkEl.getBoundingClientRect(); + const centerY = (rect.top + rect.bottom) / 2; + const dist = Math.abs(clientY - centerY); + if (dist < bestDist) { + bestDist = dist; + bestMatch = control; + } + }); + return bestMatch; + }; + + const updateActiveToolbar = ( + view: EditorView, + clientY: number, + options?: { clientX?: number; followCursor?: boolean } + ): void => { + const allChunks = getChunks(view.state); + if (!allChunks || allChunks.chunks.length === 0) return; + + const renderedControls = getRenderedChunkControls(view); + if (renderedControls.length === 0) return; + + const hoveredChunkIndex = + options?.clientX !== undefined + ? findHoveredChunkIndex(options.clientX, clientY, view) + : -1; + const activeControl = + renderedControls.find((control) => control.chunkIndex === hoveredChunkIndex) ?? + findNearestRenderedChunk(clientY, renderedControls); + + renderedControls.forEach((control) => { + control.toolbar.classList.toggle( + 'cm-merge-toolbar-active', + activeControl?.chunkIndex === control.chunkIndex + ); + }); + + if (!activeControl) return; + + if (options?.followCursor && hoveredChunkIndex >= 0) { + positionAtCursor(activeControl.chunkEl, clientY, view.scrollDOM); + } else { + positionAtBottom(activeControl.chunkEl, view.scrollDOM); } - return result; }; extensions.push( EditorView.domEventHandlers({ mousemove(event, view) { - const allChunks = getChunks(view.state); - if (allChunks && allChunks.chunks.length > 0) { - const scroller = view.scrollDOM; - const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk'); - const hoveredIdx = findHoveredChunkIndex(event, view); - const nearestIdx = - hoveredIdx >= 0 ? hoveredIdx : findNearestChunkIndex(event.clientY, view); - - const toolbars = view.dom.querySelectorAll('.cm-merge-toolbar'); - toolbars.forEach((tb, idx) => { - tb.classList.toggle('cm-merge-toolbar-active', idx === nearestIdx); - }); - - if (nearestIdx >= 0 && nearestIdx < allChunkEls.length) { - const chunkEl = allChunkEls[nearestIdx] as HTMLElement; - if (hoveredIdx >= 0) { - positionAtCursor(chunkEl, event.clientY, scroller); - } else { - positionAtBottom(chunkEl, scroller); - } - } - } + lastPointerRef.current = { x: event.clientX, y: event.clientY }; + updateActiveToolbar(view, event.clientY, { + clientX: event.clientX, + followCursor: true, + }); return false; }, mouseleave(_event, view) { + lastPointerRef.current = null; // Keep active toolbar visible, reposition to "below block" const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active'); if (activeToolbar) { @@ -601,17 +653,22 @@ export const CodeMirrorDiffView = ({ return false; }, scroll(_event, view) { - // Reposition active toolbar on horizontal scroll so buttons stay at viewport edge - const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active'); - if (activeToolbar) { - const chunkEl = activeToolbar.closest('.cm-deletedChunk'); - if (chunkEl) { - const btnContainer = chunkEl.querySelector('.cm-chunkButtons'); - if (btnContainer) { - pinToViewportRight(btnContainer, view.scrollDOM); - } - } - } + const scrollerRect = view.scrollDOM.getBoundingClientRect(); + const pointer = lastPointerRef.current; + const pointerInsideScroller = + pointer && + pointer.x >= scrollerRect.left && + pointer.x <= scrollerRect.right && + pointer.y >= scrollerRect.top && + pointer.y <= scrollerRect.bottom; + const targetY = pointerInsideScroller + ? pointer.y + : (scrollerRect.top + scrollerRect.bottom) / 2; + + updateActiveToolbar(view, targetY, { + clientX: pointerInsideScroller ? pointer.x : undefined, + followCursor: Boolean(pointerInsideScroller), + }); return false; }, }) @@ -624,12 +681,8 @@ export const CodeMirrorDiffView = ({ requestAnimationFrame(() => { const v = update.view; if (v.dom.querySelector('.cm-merge-toolbar-active')) return; - const first = v.dom.querySelector('.cm-merge-toolbar'); - if (first) { - first.classList.add('cm-merge-toolbar-active'); - const chunkEl = first.closest('.cm-deletedChunk'); - if (chunkEl) positionAtBottom(chunkEl, v.scrollDOM); - } + const scrollerRect = v.scrollDOM.getBoundingClientRect(); + updateActiveToolbar(v, (scrollerRect.top + scrollerRect.bottom) / 2); }); }) ); diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c6b78d42..af5c04ea 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -566,6 +566,13 @@ export interface TeamProvisioningProgress { assistantOutput?: string; } +export interface TeamRuntimeState { + teamName: string; + isAlive: boolean; + runId: string | null; + progress: TeamProvisioningProgress | null; +} + export interface GlobalTask extends TeamTaskWithKanban { teamName: string; teamDisplayName: string; diff --git a/test/main/http/teams.test.ts b/test/main/http/teams.test.ts new file mode 100644 index 00000000..ef928c41 --- /dev/null +++ b/test/main/http/teams.test.ts @@ -0,0 +1,208 @@ +import Fastify from 'fastify'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerTeamRoutes } from '@main/http/teams'; +import type { HttpServices } from '@main/http'; +import type { + TeamLaunchRequest, + TeamLaunchResponse, + TeamProvisioningProgress, + TeamRuntimeState, +} from '@shared/types/team'; + +describe('HTTP team runtime routes', () => { + function createServicesMock() { + const launchTeam = vi.fn< + (request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void) => Promise + >(); + const getRuntimeState = vi.fn<(teamName: string) => TeamRuntimeState>(); + const getProvisioningStatus = vi.fn<(runId: string) => Promise>(); + const stopTeam = vi.fn<(teamName: string) => void>(); + const getAliveTeams = vi.fn<() => string[]>(); + + const services = { + projectScanner: {} as HttpServices['projectScanner'], + sessionParser: {} as HttpServices['sessionParser'], + subagentResolver: {} as HttpServices['subagentResolver'], + chunkBuilder: {} as HttpServices['chunkBuilder'], + dataCache: {} as HttpServices['dataCache'], + updaterService: {} as HttpServices['updaterService'], + sshConnectionManager: {} as HttpServices['sshConnectionManager'], + teamProvisioningService: { + launchTeam, + getRuntimeState, + getProvisioningStatus, + stopTeam, + getAliveTeams, + }, + } satisfies HttpServices; + + return { + services, + launchTeam, + getRuntimeState, + getProvisioningStatus, + stopTeam, + getAliveTeams, + }; + } + + async function createApp() { + const app = Fastify(); + const mocks = createServicesMock(); + registerTeamRoutes(app, mocks.services); + await app.ready(); + return { app, ...mocks }; + } + + it('launches a team with validated request payload', async () => { + const { app, launchTeam } = await createApp(); + launchTeam.mockResolvedValue({ runId: 'run-1' }); + + try { + const response = await app.inject({ + method: 'POST', + url: '/api/teams/demo-team/launch', + payload: { + cwd: '/tmp/project', + prompt: 'Resume work', + skipPermissions: false, + clearContext: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ runId: 'run-1' }); + expect(launchTeam).toHaveBeenCalledWith( + { + teamName: 'demo-team', + cwd: '/tmp/project', + prompt: 'Resume work', + skipPermissions: false, + clearContext: true, + }, + expect.any(Function) + ); + } finally { + await app.close(); + } + }); + + it('rejects launch requests with non-absolute cwd', async () => { + const { app, launchTeam } = await createApp(); + + try { + const response = await app.inject({ + method: 'POST', + url: '/api/teams/demo-team/launch', + payload: { + cwd: 'relative/path', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: 'cwd must be an absolute path' }); + expect(launchTeam).not.toHaveBeenCalled(); + } finally { + await app.close(); + } + }); + + it('returns runtime state, provisioning status, and stop results', async () => { + const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } = await createApp(); + getRuntimeState + .mockReturnValueOnce({ + teamName: 'demo-team', + isAlive: true, + runId: 'run-2', + progress: { + runId: 'run-2', + teamName: 'demo-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:01.000Z', + }, + }) + .mockReturnValueOnce({ + teamName: 'demo-team', + isAlive: false, + runId: null, + progress: null, + }) + .mockReturnValueOnce({ + teamName: 'demo-team', + isAlive: true, + runId: 'run-2', + progress: { + runId: 'run-2', + teamName: 'demo-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:01.000Z', + }, + }); + getProvisioningStatus.mockResolvedValue({ + runId: 'run-2', + teamName: 'demo-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:01.000Z', + }); + getAliveTeams.mockReturnValue(['demo-team']); + + try { + const runtimeResponse = await app.inject({ + method: 'GET', + url: '/api/teams/demo-team/runtime', + }); + expect(runtimeResponse.statusCode).toBe(200); + expect(runtimeResponse.json().isAlive).toBe(true); + + const provisioningResponse = await app.inject({ + method: 'GET', + url: '/api/teams/provisioning/run-2', + }); + expect(provisioningResponse.statusCode).toBe(200); + expect(provisioningResponse.json().runId).toBe('run-2'); + + const stopResponse = await app.inject({ + method: 'POST', + url: '/api/teams/demo-team/stop', + }); + expect(stopResponse.statusCode).toBe(200); + expect(stopResponse.json()).toEqual({ + teamName: 'demo-team', + isAlive: false, + runId: null, + progress: null, + }); + expect(stopTeam).toHaveBeenCalledWith('demo-team'); + + const aliveResponse = await app.inject({ + method: 'GET', + url: '/api/teams/runtime/alive', + }); + expect(aliveResponse.statusCode).toBe(200); + expect(aliveResponse.json()).toEqual([ + { + teamName: 'demo-team', + isAlive: true, + runId: 'run-2', + progress: { + runId: 'run-2', + teamName: 'demo-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T00:00:00.000Z', + updatedAt: '2026-03-12T00:00:01.000Z', + }, + }, + ]); + } finally { + await app.close(); + } + }); +});