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.
This commit is contained in:
parent
19f2fa76d0
commit
4a0b1aa698
18 changed files with 1540 additions and 142 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
267
agent-teams-controller/src/internal/runtime.js
Normal file
267
agent-teams-controller/src/internal/runtime.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
7
mcp-server/src/agent-teams-controller.d.ts
vendored
7
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -66,6 +66,12 @@ declare module 'agent-teams-controller' {
|
|||
getCrossTeamOutbox(): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerRuntimeApi {
|
||||
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
78
mcp-server/src/tools/runtimeTools.ts
Normal file
78
mcp-server/src/tools/runtimeTools.ts
Normal file
|
|
@ -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<FastMCP, 'addTool'>) {
|
||||
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 } : {}),
|
||||
})
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -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<void>((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<void>((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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
216
src/main/http/teams.ts
Normal file
216
src/main/http/teams.ts
Normal file
|
|
@ -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<TeamLaunchRequest, 'teamName'>;
|
||||
|
||||
const EFFORT_LEVELS = new Set<EffortLevel>(['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<string, unknown>) : {};
|
||||
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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void>
|
||||
): Promise<void> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import type {
|
|||
FileChangeSummary,
|
||||
FileEditEvent,
|
||||
FileEditTimeline,
|
||||
MemberLogSummary,
|
||||
SnippetDiff,
|
||||
TaskChangeScope,
|
||||
TaskChangeSetV2,
|
||||
|
|
@ -277,8 +276,11 @@ export class ChangeExtractorService {
|
|||
includeDetails: boolean
|
||||
): Promise<TaskChangeSetV2> {
|
||||
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<LogFileRef[]> {
|
||||
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<string, MemberLogSummary[]>();
|
||||
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<string | null> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/main/services/team/TeamControlApiState.ts
Normal file
47
src/main/services/team/TeamControlApiState.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
const statePath = getTeamControlApiStatePath();
|
||||
await rm(statePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string>
|
||||
): 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<string | null> {
|
||||
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<number> {
|
||||
const resolvedTimestamp = timestamp ?? (await this.getFileMtime(filePath));
|
||||
const sortTime = Date.parse(resolvedTimestamp);
|
||||
return Number.isFinite(sortTime) ? sortTime : 0;
|
||||
}
|
||||
|
||||
private async getFileMtime(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
|
|
|
|||
|
|
@ -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<string, NodeJS.Timeout>();
|
||||
private inFlightResponses = new Set<string>();
|
||||
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
||||
private crossTeamSender:
|
||||
| ((request: {
|
||||
fromTeam: string;
|
||||
|
|
@ -1193,6 +1191,10 @@ export class TeamProvisioningService {
|
|||
this.crossTeamSender = sender;
|
||||
}
|
||||
|
||||
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | 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<void> = 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<string | null> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ export const CodeMirrorDiffView = ({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const endSentinelRef = useRef<HTMLDivElement>(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<HTMLElement>('.cm-merge-toolbar');
|
||||
const controls: RenderedChunkControl[] = [];
|
||||
toolbars.forEach((toolbar) => {
|
||||
const chunkEl = toolbar.closest<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
208
test/main/http/teams.test.ts
Normal file
208
test/main/http/teams.test.ts
Normal file
|
|
@ -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<TeamLaunchResponse>
|
||||
>();
|
||||
const getRuntimeState = vi.fn<(teamName: string) => TeamRuntimeState>();
|
||||
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue