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 processes = require('./internal/processes.js');
|
||||||
const maintenance = require('./internal/maintenance.js');
|
const maintenance = require('./internal/maintenance.js');
|
||||||
const crossTeam = require('./internal/crossTeam.js');
|
const crossTeam = require('./internal/crossTeam.js');
|
||||||
|
const runtime = require('./internal/runtime.js');
|
||||||
|
|
||||||
function bindModule(context, moduleApi) {
|
function bindModule(context, moduleApi) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|
@ -28,6 +29,7 @@ function createController(options) {
|
||||||
processes: bindModule(context, processes),
|
processes: bindModule(context, processes),
|
||||||
maintenance: bindModule(context, maintenance),
|
maintenance: bindModule(context, maintenance),
|
||||||
crossTeam: bindModule(context, crossTeam),
|
crossTeam: bindModule(context, crossTeam),
|
||||||
|
runtime: bindModule(context, runtime),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,4 +43,5 @@ module.exports = {
|
||||||
processes,
|
processes,
|
||||||
maintenance,
|
maintenance,
|
||||||
crossTeam,
|
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 fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
|
|
@ -27,6 +28,36 @@ describe('agent-teams-controller API', () => {
|
||||||
return dir;
|
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', () => {
|
it('creates tasks and exposes grouped controller modules', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -560,4 +591,93 @@ describe('agent-teams-controller API', () => {
|
||||||
'This should persist despite notification failure.'
|
'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;
|
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 {
|
export interface AgentTeamsController {
|
||||||
tasks: ControllerTaskApi;
|
tasks: ControllerTaskApi;
|
||||||
kanban: ControllerKanbanApi;
|
kanban: ControllerKanbanApi;
|
||||||
|
|
@ -74,6 +80,7 @@ declare module 'agent-teams-controller' {
|
||||||
processes: ControllerProcessApi;
|
processes: ControllerProcessApi;
|
||||||
maintenance: ControllerMaintenanceApi;
|
maintenance: ControllerMaintenanceApi;
|
||||||
crossTeam: ControllerCrossTeamApi;
|
crossTeam: ControllerCrossTeamApi;
|
||||||
|
runtime: ControllerRuntimeApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { registerKanbanTools } from './kanbanTools';
|
||||||
import { registerMessageTools } from './messageTools';
|
import { registerMessageTools } from './messageTools';
|
||||||
import { registerProcessTools } from './processTools';
|
import { registerProcessTools } from './processTools';
|
||||||
import { registerReviewTools } from './reviewTools';
|
import { registerReviewTools } from './reviewTools';
|
||||||
|
import { registerRuntimeTools } from './runtimeTools';
|
||||||
import { registerTaskTools } from './taskTools';
|
import { registerTaskTools } from './taskTools';
|
||||||
|
|
||||||
export function registerTools(server: FastMCP) {
|
export function registerTools(server: FastMCP) {
|
||||||
|
|
@ -13,5 +14,6 @@ export function registerTools(server: FastMCP) {
|
||||||
registerReviewTools(server);
|
registerReviewTools(server);
|
||||||
registerMessageTools(server);
|
registerMessageTools(server);
|
||||||
registerProcessTools(server);
|
registerProcessTools(server);
|
||||||
|
registerRuntimeTools(server);
|
||||||
registerCrossTeamTools(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 fs from 'fs';
|
||||||
|
import http from 'http';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
@ -61,6 +62,8 @@ describe('agent-teams-mcp tools', () => {
|
||||||
'task_set_status',
|
'task_set_status',
|
||||||
'task_start',
|
'task_start',
|
||||||
'task_unlink',
|
'task_unlink',
|
||||||
|
'team_launch',
|
||||||
|
'team_stop',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function getTool(name: string) {
|
function getTool(name: string) {
|
||||||
|
|
@ -73,6 +76,45 @@ describe('agent-teams-mcp tools', () => {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
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', () => {
|
it('registers the full expected MCP tool surface', () => {
|
||||||
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
expect([...tools.keys()].sort()).toEqual([...expectedToolNames]);
|
||||||
});
|
});
|
||||||
|
|
@ -89,6 +131,97 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(parsed?.success).toBe(true);
|
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 () => {
|
it('covers task lifecycle, attachments, relationships, kanban, and review flows', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const teamName = 'alpha';
|
const teamName = 'alpha';
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { registerSearchRoutes } from './search';
|
||||||
import { registerSessionRoutes } from './sessions';
|
import { registerSessionRoutes } from './sessions';
|
||||||
import { registerSshRoutes } from './ssh';
|
import { registerSshRoutes } from './ssh';
|
||||||
import { registerSubagentRoutes } from './subagents';
|
import { registerSubagentRoutes } from './subagents';
|
||||||
|
import { registerTeamRoutes } from './teams';
|
||||||
import { registerUpdaterRoutes } from './updater';
|
import { registerUpdaterRoutes } from './updater';
|
||||||
import { registerUtilityRoutes } from './utility';
|
import { registerUtilityRoutes } from './utility';
|
||||||
import { registerValidationRoutes } from './validation';
|
import { registerValidationRoutes } from './validation';
|
||||||
|
|
@ -28,6 +29,7 @@ import type {
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
|
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
|
||||||
|
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
const logger = createLogger('HTTP:routes');
|
const logger = createLogger('HTTP:routes');
|
||||||
|
|
@ -40,6 +42,7 @@ export interface HttpServices {
|
||||||
dataCache: DataCache;
|
dataCache: DataCache;
|
||||||
updaterService: UpdaterService;
|
updaterService: UpdaterService;
|
||||||
sshConnectionManager: SshConnectionManager;
|
sshConnectionManager: SshConnectionManager;
|
||||||
|
teamProvisioningService?: TeamProvisioningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerHttpRoutes(
|
export function registerHttpRoutes(
|
||||||
|
|
@ -51,6 +54,7 @@ export function registerHttpRoutes(
|
||||||
registerSessionRoutes(app, services);
|
registerSessionRoutes(app, services);
|
||||||
registerSearchRoutes(app, services);
|
registerSearchRoutes(app, services);
|
||||||
registerSubagentRoutes(app, services);
|
registerSubagentRoutes(app, services);
|
||||||
|
registerTeamRoutes(app, services);
|
||||||
registerNotificationRoutes(app);
|
registerNotificationRoutes(app);
|
||||||
registerConfigRoutes(app);
|
registerConfigRoutes(app);
|
||||||
registerValidationRoutes(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 { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||||
|
import {
|
||||||
|
buildTeamControlApiBaseUrl,
|
||||||
|
clearTeamControlApiState,
|
||||||
|
writeTeamControlApiState,
|
||||||
|
} from './services/team/TeamControlApiState';
|
||||||
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore';
|
||||||
import { getAppIconPath } from './utils/appIcon';
|
import { getAppIconPath } from './utils/appIcon';
|
||||||
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
import { getProjectsBasePath, getTeamsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
||||||
|
|
@ -362,6 +367,24 @@ function getRendererIndexPath(): string {
|
||||||
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
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.
|
* Wires file watcher events from a ServiceContext to the renderer and HTTP SSE clients.
|
||||||
* Cleans up previous listeners before adding new ones.
|
* Cleans up previous listeners before adding new ones.
|
||||||
|
|
@ -735,6 +758,13 @@ function initializeServices(): void {
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
httpServer = new HttpServer();
|
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).
|
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||||
|
|
@ -841,6 +871,11 @@ async function startHttpServer(
|
||||||
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
|
modeSwitchHandler: (mode: 'local' | 'ssh') => Promise<void>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (httpServer.isRunning()) {
|
||||||
|
await syncTeamControlApiState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
const activeContext = contextRegistry.getActive();
|
const activeContext = contextRegistry.getActive();
|
||||||
const port = await httpServer.start(
|
const port = await httpServer.start(
|
||||||
|
|
@ -852,12 +887,15 @@ async function startHttpServer(
|
||||||
dataCache: activeContext.dataCache,
|
dataCache: activeContext.dataCache,
|
||||||
updaterService,
|
updaterService,
|
||||||
sshConnectionManager,
|
sshConnectionManager,
|
||||||
|
teamProvisioningService,
|
||||||
},
|
},
|
||||||
modeSwitchHandler,
|
modeSwitchHandler,
|
||||||
config.httpServer?.port ?? 3456
|
config.httpServer?.port ?? 3456
|
||||||
);
|
);
|
||||||
|
await syncTeamControlApiState();
|
||||||
logger.info(`HTTP sidecar server running on port ${port}`);
|
logger.info(`HTTP sidecar server running on port ${port}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await clearTeamControlApiState().catch(() => undefined);
|
||||||
logger.error('Failed to start HTTP server:', error);
|
logger.error('Failed to start HTTP server:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -872,6 +910,7 @@ function shutdownServices(): void {
|
||||||
if (httpServer?.isRunning()) {
|
if (httpServer?.isRunning()) {
|
||||||
void httpServer.stop();
|
void httpServer.stop();
|
||||||
}
|
}
|
||||||
|
void clearTeamControlApiState();
|
||||||
|
|
||||||
// Clean up file watcher event listeners
|
// Clean up file watcher event listeners
|
||||||
if (fileChangeCleanup) {
|
if (fileChangeCleanup) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { type IpcMain } from 'electron';
|
import { type IpcMain } from 'electron';
|
||||||
|
|
||||||
import { configManager } from '../services';
|
import { configManager } from '../services';
|
||||||
|
import { clearTeamControlApiState } from '../services/team/TeamControlApiState';
|
||||||
|
|
||||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||||
|
|
||||||
|
|
@ -84,6 +85,7 @@ async function handleStop(): Promise<{
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
await httpServer.stop();
|
await httpServer.stop();
|
||||||
|
await clearTeamControlApiState();
|
||||||
configManager.updateConfig('httpServer', { enabled: false });
|
configManager.updateConfig('httpServer', { enabled: false });
|
||||||
return { success: true, data: { running: false, port: httpServer.getPort() } };
|
return { success: true, data: { running: false, port: httpServer.getPort() } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import type {
|
||||||
FileChangeSummary,
|
FileChangeSummary,
|
||||||
FileEditEvent,
|
FileEditEvent,
|
||||||
FileEditTimeline,
|
FileEditTimeline,
|
||||||
MemberLogSummary,
|
|
||||||
SnippetDiff,
|
SnippetDiff,
|
||||||
TaskChangeScope,
|
TaskChangeScope,
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
|
|
@ -277,8 +276,11 @@ export class ChangeExtractorService {
|
||||||
includeDetails: boolean
|
includeDetails: boolean
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
||||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
|
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
teamName,
|
||||||
|
taskId,
|
||||||
|
effectiveOptions
|
||||||
|
);
|
||||||
if (logRefs.length === 0) {
|
if (logRefs.length === 0) {
|
||||||
return this.emptyTaskChangeSet(teamName, taskId);
|
return this.emptyTaskChangeSet(teamName, taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -883,47 +885,6 @@ export class ChangeExtractorService {
|
||||||
return false;
|
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 */
|
/** Извлечь изменения из JSONL файлов, фильтруя по tool_use IDs */
|
||||||
private async extractFilteredChanges(
|
private async extractFilteredChanges(
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
|
|
@ -1260,8 +1221,11 @@ export class ChangeExtractorService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
|
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
teamName,
|
||||||
|
taskId,
|
||||||
|
effectiveOptions
|
||||||
|
);
|
||||||
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
||||||
if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) {
|
if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) {
|
||||||
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
|
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
|
||||||
|
|
@ -1304,8 +1268,11 @@ export class ChangeExtractorService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions);
|
const logRefs = await this.logsFinder.findLogFileRefsForTask(
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
teamName,
|
||||||
|
taskId,
|
||||||
|
effectiveOptions
|
||||||
|
);
|
||||||
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
|
||||||
const projectFingerprint = await this.computeProjectFingerprint(teamName);
|
const projectFingerprint = await this.computeProjectFingerprint(teamName);
|
||||||
if (!sourceFingerprint || !projectFingerprint) {
|
if (!sourceFingerprint || !projectFingerprint) {
|
||||||
|
|
@ -1346,12 +1313,12 @@ export class ChangeExtractorService {
|
||||||
return null;
|
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> {
|
private async computeProjectFingerprint(teamName: string): Promise<string | null> {
|
||||||
const projectPath = await this.resolveProjectPath(teamName);
|
const projectPath = await this.resolveProjectPath(teamName);
|
||||||
if (!projectPath) return null;
|
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.
|
// Back-compat: single since timestamp -> treat as open interval.
|
||||||
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
|
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 =
|
const effectiveIntervals =
|
||||||
normalizedIntervals.length > 0
|
normalizedIntervals.length > 0
|
||||||
? normalizedIntervals
|
? normalizedIntervals
|
||||||
: sinceMs != null
|
: sinceStartMs != null
|
||||||
? [{ startMs: sinceMs, endMs: 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) => {
|
const filteredOwnerLogs = ownerLogs.filter((log) => {
|
||||||
if (log.isOngoing) return true;
|
if (log.isOngoing) return true;
|
||||||
const startMs = new Date(log.startTime).getTime();
|
const startMs = new Date(log.startTime).getTime();
|
||||||
|
|
@ -238,7 +229,13 @@ export class TeamMemberLogsFinder {
|
||||||
const endMs = startMs + durationMs;
|
const endMs = startMs + durationMs;
|
||||||
|
|
||||||
if (effectiveIntervals.length > 0) {
|
if (effectiveIntervals.length > 0) {
|
||||||
return overlapsAnyInterval(startMs, endMs);
|
return this.logOverlapsIntervals(
|
||||||
|
startMs,
|
||||||
|
endMs,
|
||||||
|
effectiveIntervals,
|
||||||
|
now,
|
||||||
|
TASK_LOG_INTERVAL_GRACE_MS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return startMs >= now - fallbackRecentMs;
|
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.
|
* Returns absolute paths to all JSONL files belonging to the specified member.
|
||||||
* Uses the same discovery logic as findMemberLogs but collects file paths.
|
* Uses the same discovery logic as findMemberLogs but collects file paths.
|
||||||
|
|
@ -508,6 +655,21 @@ export class TeamMemberLogsFinder {
|
||||||
return earliest - TASK_SINCE_GRACE_MS;
|
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(
|
private async fileMentionsTaskIdCached(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -649,8 +811,6 @@ export class TeamMemberLogsFinder {
|
||||||
const b = block as Record<string, unknown>;
|
const b = block as Record<string, unknown>;
|
||||||
if (b.type !== 'tool_use') continue;
|
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;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
||||||
|
|
@ -739,7 +899,8 @@ export class TeamMemberLogsFinder {
|
||||||
// accurate timestamps and message count from the full file.
|
// accurate timestamps and message count from the full file.
|
||||||
const metadata = await this.streamFileMetadata(filePath);
|
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 lastTimestamp = metadata.lastTimestamp ?? firstTimestamp;
|
||||||
|
|
||||||
const startTime = new Date(firstTimestamp);
|
const startTime = new Date(firstTimestamp);
|
||||||
|
|
@ -779,7 +940,11 @@ export class TeamMemberLogsFinder {
|
||||||
private async attributeSubagent(
|
private async attributeSubagent(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
knownMembers: Set<string>
|
knownMembers: Set<string>
|
||||||
): Promise<{ detectedMember: string; description: string } | null> {
|
): Promise<{
|
||||||
|
detectedMember: string;
|
||||||
|
description: string;
|
||||||
|
firstTimestamp: string | null;
|
||||||
|
} | null> {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -806,8 +971,12 @@ export class TeamMemberLogsFinder {
|
||||||
let description = '';
|
let description = '';
|
||||||
let detectedMember: string | null = null;
|
let detectedMember: string | null = null;
|
||||||
let detectionPriority = 0;
|
let detectionPriority = 0;
|
||||||
|
let firstTimestamp: string | null = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
if (!firstTimestamp) {
|
||||||
|
firstTimestamp = this.extractTimestampFromLine(line);
|
||||||
|
}
|
||||||
// Early exit: both objectives met (member detected at max priority + description found)
|
// Early exit: both objectives met (member detected at max priority + description found)
|
||||||
if (detectionPriority >= 3 && description) break;
|
if (detectionPriority >= 3 && description) break;
|
||||||
|
|
||||||
|
|
@ -888,7 +1057,7 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
if (!detectedMember) return null;
|
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.
|
// Fast timestamp extraction without full JSON parse.
|
||||||
// ISO prefix anchor avoids false positives from "timestamp" inside string values.
|
// 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);
|
const ts = this.extractTimestampFromLine(trimmed);
|
||||||
if (tsMatch) {
|
if (ts) {
|
||||||
if (!firstTimestamp) firstTimestamp = tsMatch[1];
|
if (!firstTimestamp) firstTimestamp = ts;
|
||||||
lastTimestamp = tsMatch[1];
|
lastTimestamp = ts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
@ -1031,6 +1200,46 @@ export class TeamMemberLogsFinder {
|
||||||
return { firstTimestamp, lastTimestamp, messageCount };
|
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> {
|
private async getFileMtime(filePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(filePath);
|
const stat = await fs.stat(filePath);
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ import type {
|
||||||
TeamProvisioningPrepareResult,
|
TeamProvisioningPrepareResult,
|
||||||
TeamProvisioningProgress,
|
TeamProvisioningProgress,
|
||||||
TeamProvisioningState,
|
TeamProvisioningState,
|
||||||
|
TeamRuntimeState,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
ToolApprovalAutoResolved,
|
ToolApprovalAutoResolved,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
|
|
@ -761,7 +762,6 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
|
|
||||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const displayName = request.displayName?.trim() || request.teamName;
|
const displayName = request.displayName?.trim() || request.teamName;
|
||||||
const description = request.description?.trim() || 'No description';
|
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
|
|
@ -838,14 +838,11 @@ ${persistentContext}
|
||||||
|
|
||||||
Steps (execute in this exact order):
|
Steps (execute in this exact order):
|
||||||
|
|
||||||
1) TeamCreate — create team “${request.teamName}”:
|
|
||||||
- description: “${description}”
|
|
||||||
|
|
||||||
${step2Block}
|
${step2Block}
|
||||||
|
|
||||||
${step3Block}
|
${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 toolApprovalSettings: ToolApprovalSettings = DEFAULT_TOOL_APPROVAL_SETTINGS;
|
||||||
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
||||||
private inFlightResponses = new Set<string>();
|
private inFlightResponses = new Set<string>();
|
||||||
|
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
||||||
private crossTeamSender:
|
private crossTeamSender:
|
||||||
| ((request: {
|
| ((request: {
|
||||||
fromTeam: string;
|
fromTeam: string;
|
||||||
|
|
@ -1193,6 +1191,10 @@ export class TeamProvisioningService {
|
||||||
this.crossTeamSender = sender;
|
this.crossTeamSender = sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void {
|
||||||
|
this.controlApiBaseUrlResolver = resolver;
|
||||||
|
}
|
||||||
|
|
||||||
getClaudeLogs(
|
getClaudeLogs(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
query?: { offset?: number; limit?: number }
|
query?: { offset?: number; limit?: number }
|
||||||
|
|
@ -3688,6 +3690,18 @@ export class TeamProvisioningService {
|
||||||
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
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();
|
private languageChangeInFlight: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -5686,6 +5700,11 @@ export class TeamProvisioningService {
|
||||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
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.
|
// SHELL is a Unix concept — only set it on non-Windows platforms.
|
||||||
if (!isWindows) {
|
if (!isWindows) {
|
||||||
env.SHELL = shell;
|
env.SHELL = shell;
|
||||||
|
|
@ -5729,6 +5748,23 @@ export class TeamProvisioningService {
|
||||||
return { env, authSource: 'none' };
|
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.
|
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
||||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ export const CodeMirrorDiffView = ({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
const endSentinelRef = useRef<HTMLDivElement>(null);
|
const endSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
// Local ref to hold externalViewRef for syncing via useEffect
|
// Local ref to hold externalViewRef for syncing via useEffect
|
||||||
const externalViewRefHolder = useRef(externalViewRef);
|
const externalViewRefHolder = useRef(externalViewRef);
|
||||||
|
|
||||||
|
|
@ -475,9 +476,10 @@ export const CodeMirrorDiffView = ({
|
||||||
const chunkRect = chunkEl.getBoundingClientRect();
|
const chunkRect = chunkEl.getBoundingClientRect();
|
||||||
const btnWidth = btnContainer.offsetWidth || 200;
|
const btnWidth = btnContainer.offsetWidth || 200;
|
||||||
const margin = 12;
|
const margin = 12;
|
||||||
|
const { style } = btnContainer;
|
||||||
// left is relative to .cm-deletedChunk — so we compute from scroller's right edge
|
// left is relative to .cm-deletedChunk — so we compute from scroller's right edge
|
||||||
btnContainer.style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`;
|
style.left = `${scrollerRect.right - chunkRect.left - btnWidth - margin}px`;
|
||||||
btnContainer.style.right = 'auto';
|
style.right = 'auto';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: position a chunkButtons container so it's below the change block,
|
// Helper: position a chunkButtons container so it's below the change block,
|
||||||
|
|
@ -516,14 +518,48 @@ export const CodeMirrorDiffView = ({
|
||||||
pinToViewportRight(btnContainer, scroller);
|
pinToViewportRight(btnContainer, scroller);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find which chunk index the mouse is directly over, including inline deleted text.
|
interface RenderedChunkControl {
|
||||||
const findHoveredChunkIndex = (event: MouseEvent, view: EditorView): number => {
|
chunkIndex: number;
|
||||||
const el = document.elementFromPoint(event.clientX, event.clientY);
|
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;
|
if (!el) return -1;
|
||||||
const deletedChunk = el.closest('.cm-deletedChunk');
|
const deletedChunk = el.closest('.cm-deletedChunk');
|
||||||
if (deletedChunk) {
|
if (deletedChunk) {
|
||||||
const all = view.dom.querySelectorAll('.cm-deletedChunk');
|
return resolveChunkIndexFromDeletedChunk(deletedChunk, view);
|
||||||
return [...all].indexOf(deletedChunk);
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
el.closest(
|
el.closest(
|
||||||
|
|
@ -532,7 +568,7 @@ export const CodeMirrorDiffView = ({
|
||||||
) {
|
) {
|
||||||
const allChunks = getChunks(view.state);
|
const allChunks = getChunks(view.state);
|
||||||
if (!allChunks) return -1;
|
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) {
|
if (pos !== null) {
|
||||||
for (let i = 0; i < allChunks.chunks.length; i++) {
|
for (let i = 0; i < allChunks.chunks.length; i++) {
|
||||||
const chunk = allChunks.chunks[i];
|
const chunk = allChunks.chunks[i];
|
||||||
|
|
@ -543,55 +579,71 @@ export const CodeMirrorDiffView = ({
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find chunk nearest to cursor Y (for default "below block" display)
|
const findNearestRenderedChunk = (
|
||||||
const findNearestChunkIndex = (clientY: number, view: EditorView): number => {
|
clientY: number,
|
||||||
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
|
renderedControls: RenderedChunkControl[]
|
||||||
let result = -1;
|
): RenderedChunkControl | null => {
|
||||||
if (allChunkEls.length > 0) {
|
let bestMatch: RenderedChunkControl | null = null;
|
||||||
let bestIdx = 0;
|
let bestDist = Infinity;
|
||||||
let bestDist = Infinity;
|
renderedControls.forEach((control) => {
|
||||||
allChunkEls.forEach((el, idx) => {
|
const rect = control.chunkEl.getBoundingClientRect();
|
||||||
const rect = el.getBoundingClientRect();
|
const centerY = (rect.top + rect.bottom) / 2;
|
||||||
const centerY = (rect.top + rect.bottom) / 2;
|
const dist = Math.abs(clientY - centerY);
|
||||||
const dist = Math.abs(clientY - centerY);
|
if (dist < bestDist) {
|
||||||
if (dist < bestDist) {
|
bestDist = dist;
|
||||||
bestDist = dist;
|
bestMatch = control;
|
||||||
bestIdx = idx;
|
}
|
||||||
}
|
});
|
||||||
});
|
return bestMatch;
|
||||||
result = bestIdx;
|
};
|
||||||
|
|
||||||
|
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(
|
extensions.push(
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
mousemove(event, view) {
|
mousemove(event, view) {
|
||||||
const allChunks = getChunks(view.state);
|
lastPointerRef.current = { x: event.clientX, y: event.clientY };
|
||||||
if (allChunks && allChunks.chunks.length > 0) {
|
updateActiveToolbar(view, event.clientY, {
|
||||||
const scroller = view.scrollDOM;
|
clientX: event.clientX,
|
||||||
const allChunkEls = view.dom.querySelectorAll('.cm-deletedChunk');
|
followCursor: true,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
mouseleave(_event, view) {
|
mouseleave(_event, view) {
|
||||||
|
lastPointerRef.current = null;
|
||||||
// Keep active toolbar visible, reposition to "below block"
|
// Keep active toolbar visible, reposition to "below block"
|
||||||
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
|
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
|
||||||
if (activeToolbar) {
|
if (activeToolbar) {
|
||||||
|
|
@ -601,17 +653,22 @@ export const CodeMirrorDiffView = ({
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
scroll(_event, view) {
|
scroll(_event, view) {
|
||||||
// Reposition active toolbar on horizontal scroll so buttons stay at viewport edge
|
const scrollerRect = view.scrollDOM.getBoundingClientRect();
|
||||||
const activeToolbar = view.dom.querySelector('.cm-merge-toolbar-active');
|
const pointer = lastPointerRef.current;
|
||||||
if (activeToolbar) {
|
const pointerInsideScroller =
|
||||||
const chunkEl = activeToolbar.closest('.cm-deletedChunk');
|
pointer &&
|
||||||
if (chunkEl) {
|
pointer.x >= scrollerRect.left &&
|
||||||
const btnContainer = chunkEl.querySelector<HTMLElement>('.cm-chunkButtons');
|
pointer.x <= scrollerRect.right &&
|
||||||
if (btnContainer) {
|
pointer.y >= scrollerRect.top &&
|
||||||
pinToViewportRight(btnContainer, view.scrollDOM);
|
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;
|
return false;
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -624,12 +681,8 @@ export const CodeMirrorDiffView = ({
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const v = update.view;
|
const v = update.view;
|
||||||
if (v.dom.querySelector('.cm-merge-toolbar-active')) return;
|
if (v.dom.querySelector('.cm-merge-toolbar-active')) return;
|
||||||
const first = v.dom.querySelector('.cm-merge-toolbar');
|
const scrollerRect = v.scrollDOM.getBoundingClientRect();
|
||||||
if (first) {
|
updateActiveToolbar(v, (scrollerRect.top + scrollerRect.bottom) / 2);
|
||||||
first.classList.add('cm-merge-toolbar-active');
|
|
||||||
const chunkEl = first.closest('.cm-deletedChunk');
|
|
||||||
if (chunkEl) positionAtBottom(chunkEl, v.scrollDOM);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,13 @@ export interface TeamProvisioningProgress {
|
||||||
assistantOutput?: string;
|
assistantOutput?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamRuntimeState {
|
||||||
|
teamName: string;
|
||||||
|
isAlive: boolean;
|
||||||
|
runId: string | null;
|
||||||
|
progress: TeamProvisioningProgress | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlobalTask extends TeamTaskWithKanban {
|
export interface GlobalTask extends TeamTaskWithKanban {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamDisplayName: 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