feat: enhance team control API with retry logic and fallback mechanisms
- Introduced a new method to resolve multiple control base URLs, allowing for better handling of API requests. - Implemented retryable error handling for control API requests, improving robustness against transient failures. - Updated provisioning and runtime state retrieval functions to utilize the new fallback logic. - Enhanced tests to validate the new behavior, ensuring proper functionality under various scenarios. - Added utility functions for managing retryable errors and control API state, improving code clarity and maintainability.
This commit is contained in:
parent
4a0b1aa698
commit
81ac59e46b
10 changed files with 428 additions and 48 deletions
|
|
@ -7,6 +7,7 @@ const MIN_WAIT_TIMEOUT_MS = 1000;
|
||||||
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const MAX_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
|
const TEAM_CONTROL_API_STATE_FILE = 'team-control-api.json';
|
||||||
|
const RETRYABLE_CONTROL_ERROR = 'retryableControlError';
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
@ -41,21 +42,46 @@ function readControlApiState(context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveControlBaseUrl(context, flags = {}) {
|
function uniqueNonEmpty(items) {
|
||||||
|
return [...new Set(items.filter((item) => typeof item === 'string' && item.trim()))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveControlBaseUrls(context, flags = {}) {
|
||||||
const explicit =
|
const explicit =
|
||||||
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
|
(typeof flags.controlUrl === 'string' && flags.controlUrl.trim()) ||
|
||||||
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
|
(typeof flags['control-url'] === 'string' && flags['control-url'].trim()) ||
|
||||||
|
'';
|
||||||
|
const stateFileUrl = readControlApiState(context) || '';
|
||||||
|
const envUrl =
|
||||||
(typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' &&
|
(typeof process.env.CLAUDE_TEAM_CONTROL_URL === 'string' &&
|
||||||
process.env.CLAUDE_TEAM_CONTROL_URL.trim()) ||
|
process.env.CLAUDE_TEAM_CONTROL_URL.trim()) ||
|
||||||
readControlApiState(context);
|
'';
|
||||||
|
const candidates = uniqueNonEmpty([explicit, stateFileUrl, envUrl]);
|
||||||
|
|
||||||
if (!explicit) {
|
if (candidates.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.'
|
'Team control API is unavailable. Start the desktop app team runtime first so it can publish CLAUDE_TEAM_CONTROL_URL.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return explicit;
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRetryableControlError(message, cause) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error[RETRYABLE_CONTROL_ERROR] = true;
|
||||||
|
if (cause) {
|
||||||
|
error.cause = cause;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableControlError(error) {
|
||||||
|
return Boolean(error && error[RETRYABLE_CONTROL_ERROR] === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableStatusCode(statusCode) {
|
||||||
|
return statusCode === 404 || statusCode === 408 || statusCode === 429 || statusCode >= 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(baseUrl, pathname, options = {}) {
|
async function requestJson(baseUrl, pathname, options = {}) {
|
||||||
|
|
@ -86,13 +112,28 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
||||||
payload && typeof payload.error === 'string' && payload.error.trim()
|
payload && typeof payload.error === 'string' && payload.error.trim()
|
||||||
? payload.error.trim()
|
? payload.error.trim()
|
||||||
: `${response.status} ${response.statusText}`.trim();
|
: `${response.status} ${response.statusText}`.trim();
|
||||||
|
if (isRetryableStatusCode(response.status)) {
|
||||||
|
throw makeRetryableControlError(
|
||||||
|
`Team control API ${response.status} at ${baseUrl}${pathname}: ${detail || 'request failed'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new Error(detail || 'Team control API request failed');
|
throw new Error(detail || 'Team control API request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload == null) {
|
||||||
|
throw makeRetryableControlError(`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`);
|
||||||
|
}
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.name === 'AbortError') {
|
if (error && error.name === 'AbortError') {
|
||||||
throw new Error(`Timed out calling team control API: ${pathname}`);
|
throw makeRetryableControlError(`Timed out calling team control API: ${pathname}`, error);
|
||||||
|
}
|
||||||
|
if (error && error.name === 'TypeError') {
|
||||||
|
throw makeRetryableControlError(
|
||||||
|
`Failed to reach team control API at ${baseUrl}: ${error.message || 'fetch failed'}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -100,6 +141,24 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let index = 0; index < baseUrls.length; index += 1) {
|
||||||
|
const baseUrl = baseUrls[index];
|
||||||
|
try {
|
||||||
|
return await requestJson(baseUrl, pathname, options);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!isRetryableControlError(error) || index === baseUrls.length - 1) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error('Team control API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
function buildLaunchRequest(flags = {}) {
|
function buildLaunchRequest(flags = {}) {
|
||||||
const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : '';
|
const cwd = typeof flags.cwd === 'string' ? flags.cwd.trim() : '';
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
|
|
@ -159,14 +218,18 @@ function shouldWaitForStop(flags = {}) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForProvisioningState(baseUrl, teamName, runId, timeoutMs) {
|
async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let lastProgress = null;
|
let lastProgress = null;
|
||||||
|
|
||||||
while (Date.now() - startedAt <= timeoutMs) {
|
while (Date.now() - startedAt <= timeoutMs) {
|
||||||
const progress = await requestJson(baseUrl, `/api/teams/provisioning/${encodeURIComponent(runId)}`, {
|
const progress = await requestJsonWithFallback(
|
||||||
timeoutMs: Math.min(timeoutMs, 10000),
|
baseUrls,
|
||||||
});
|
`/api/teams/provisioning/${encodeURIComponent(runId)}`,
|
||||||
|
{
|
||||||
|
timeoutMs: Math.min(timeoutMs, 10000),
|
||||||
|
}
|
||||||
|
);
|
||||||
lastProgress = progress;
|
lastProgress = progress;
|
||||||
|
|
||||||
if (progress && READY_STATES.has(progress.state)) {
|
if (progress && READY_STATES.has(progress.state)) {
|
||||||
|
|
@ -194,12 +257,12 @@ async function waitForProvisioningState(baseUrl, teamName, runId, timeoutMs) {
|
||||||
throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`);
|
throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForStopped(baseUrl, teamName, timeoutMs) {
|
async function waitForStopped(baseUrls, teamName, timeoutMs) {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
||||||
while (Date.now() - startedAt <= timeoutMs) {
|
while (Date.now() - startedAt <= timeoutMs) {
|
||||||
const runtime = await requestJson(
|
const runtime = await requestJsonWithFallback(
|
||||||
baseUrl,
|
baseUrls,
|
||||||
`/api/teams/${encodeURIComponent(teamName)}/runtime`,
|
`/api/teams/${encodeURIComponent(teamName)}/runtime`,
|
||||||
{ timeoutMs: Math.min(timeoutMs, 10000) }
|
{ timeoutMs: Math.min(timeoutMs, 10000) }
|
||||||
);
|
);
|
||||||
|
|
@ -215,12 +278,16 @@ async function waitForStopped(baseUrl, teamName, timeoutMs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function launchTeam(context, flags = {}) {
|
async function launchTeam(context, flags = {}) {
|
||||||
const baseUrl = resolveControlBaseUrl(context, flags);
|
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||||
const request = buildLaunchRequest(flags);
|
const request = buildLaunchRequest(flags);
|
||||||
const launch = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/launch`, {
|
const launch = await requestJsonWithFallback(
|
||||||
method: 'POST',
|
baseUrls,
|
||||||
body: request,
|
`/api/teams/${encodeURIComponent(context.teamName)}/launch`,
|
||||||
});
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: request,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldWaitForReady(flags)) {
|
if (!shouldWaitForReady(flags)) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -231,7 +298,7 @@ async function launchTeam(context, flags = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return waitForProvisioningState(
|
return waitForProvisioningState(
|
||||||
baseUrl,
|
baseUrls,
|
||||||
context.teamName,
|
context.teamName,
|
||||||
launch.runId,
|
launch.runId,
|
||||||
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
|
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
|
||||||
|
|
@ -239,25 +306,29 @@ async function launchTeam(context, flags = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopTeam(context, flags = {}) {
|
async function stopTeam(context, flags = {}) {
|
||||||
const baseUrl = resolveControlBaseUrl(context, flags);
|
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||||
const stopped = await requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/stop`, {
|
const stopped = await requestJsonWithFallback(
|
||||||
method: 'POST',
|
baseUrls,
|
||||||
});
|
`/api/teams/${encodeURIComponent(context.teamName)}/stop`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldWaitForStop(flags)) {
|
if (!shouldWaitForStop(flags)) {
|
||||||
return stopped;
|
return stopped;
|
||||||
}
|
}
|
||||||
|
|
||||||
return waitForStopped(
|
return waitForStopped(
|
||||||
baseUrl,
|
baseUrls,
|
||||||
context.teamName,
|
context.teamName,
|
||||||
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
|
normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRuntimeState(context, flags = {}) {
|
async function getRuntimeState(context, flags = {}) {
|
||||||
const baseUrl = resolveControlBaseUrl(context, flags);
|
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||||
return requestJson(baseUrl, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
|
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ describe('agent-teams-controller API', () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeControlApiState(claudeDir, baseUrl) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, 'team-control-api.json'),
|
||||||
|
JSON.stringify({ baseUrl, updatedAt: new Date().toISOString() }, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 });
|
||||||
|
|
@ -680,4 +687,140 @@ describe('agent-teams-controller API', () => {
|
||||||
await server.close();
|
await server.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers the published control endpoint over a stale env URL', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
|
||||||
|
const server = await startControlServer(async ({ method, url }) => {
|
||||||
|
if (method === 'POST' && url === '/api/teams/my-team/launch') {
|
||||||
|
return { body: { runId: 'run-fresh' } };
|
||||||
|
}
|
||||||
|
if (method === 'GET' && url === '/api/teams/provisioning/run-fresh') {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
runId: 'run-fresh',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
startedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = 'http://127.0.0.1:1';
|
||||||
|
writeControlApiState(claudeDir, server.baseUrl);
|
||||||
|
|
||||||
|
const launched = await controller.runtime.launchTeam({
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(launched.runId).toBe('run-fresh');
|
||||||
|
expect(launched.progress.state).toBe('ready');
|
||||||
|
} finally {
|
||||||
|
if (previousUrl === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
|
||||||
|
}
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the env endpoint when the published control file is stale', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
|
||||||
|
const server = await startControlServer(async ({ method, url }) => {
|
||||||
|
if (method === 'POST' && url === '/api/teams/my-team/launch') {
|
||||||
|
return { body: { runId: 'run-env' } };
|
||||||
|
}
|
||||||
|
if (method === 'GET' && url === '/api/teams/provisioning/run-env') {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
runId: 'run-env',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
startedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = server.baseUrl;
|
||||||
|
writeControlApiState(claudeDir, 'http://127.0.0.1:1');
|
||||||
|
|
||||||
|
const launched = await controller.runtime.launchTeam({
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(launched.runId).toBe('run-env');
|
||||||
|
expect(launched.progress.state).toBe('ready');
|
||||||
|
} finally {
|
||||||
|
if (previousUrl === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
|
||||||
|
}
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the next control endpoint when the first one responds with 404', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const previousUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
|
||||||
|
const staleServer = await startControlServer(async () => {
|
||||||
|
return { statusCode: 404, body: { error: 'Not found' } };
|
||||||
|
});
|
||||||
|
const liveServer = await startControlServer(async ({ method, url }) => {
|
||||||
|
if (method === 'POST' && url === '/api/teams/my-team/launch') {
|
||||||
|
return { body: { runId: 'run-live' } };
|
||||||
|
}
|
||||||
|
if (method === 'GET' && url === '/api/teams/provisioning/run-live') {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
runId: 'run-live',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
startedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T00:00:01.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeControlApiState(claudeDir, staleServer.baseUrl);
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = liveServer.baseUrl;
|
||||||
|
|
||||||
|
const launched = await controller.runtime.launchTeam({
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(launched.runId).toBe('run-live');
|
||||||
|
expect(launched.progress.state).toBe('ready');
|
||||||
|
} finally {
|
||||||
|
if (previousUrl === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_CONTROL_URL;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_CONTROL_URL = previousUrl;
|
||||||
|
}
|
||||||
|
await staleServer.close();
|
||||||
|
await liveServer.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,50 @@ describe('agent-teams-mcp tools', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('discovers the control endpoint from the published state file', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||||
|
|
||||||
|
const server = await startControlServer(async ({ method, url }) => {
|
||||||
|
if (method === 'POST' && url === '/api/teams/alpha/launch') {
|
||||||
|
return { body: { runId: 'run-state-file' } };
|
||||||
|
}
|
||||||
|
if (method === 'GET' && url === '/api/teams/provisioning/run-state-file') {
|
||||||
|
return {
|
||||||
|
body: {
|
||||||
|
runId: 'run-state-file',
|
||||||
|
teamName: 'alpha',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
startedAt: '2026-03-12T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T00:00:02.000Z',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({ baseUrl: server.baseUrl, updatedAt: new Date().toISOString() }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const launched = parseJsonToolResult(
|
||||||
|
await getTool('team_launch').execute({
|
||||||
|
teamName: 'alpha',
|
||||||
|
claudeDir,
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(launched.runId).toBe('run-state-file');
|
||||||
|
expect(launched.progress.state).toBe('ready');
|
||||||
|
} 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';
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ export function registerHttpRoutes(
|
||||||
registerSessionRoutes(app, services);
|
registerSessionRoutes(app, services);
|
||||||
registerSearchRoutes(app, services);
|
registerSearchRoutes(app, services);
|
||||||
registerSubagentRoutes(app, services);
|
registerSubagentRoutes(app, services);
|
||||||
registerTeamRoutes(app, services);
|
if (services.teamProvisioningService) {
|
||||||
|
registerTeamRoutes(app, services);
|
||||||
|
}
|
||||||
registerNotificationRoutes(app);
|
registerNotificationRoutes(app);
|
||||||
registerConfigRoutes(app);
|
registerConfigRoutes(app);
|
||||||
registerValidationRoutes(app);
|
registerValidationRoutes(app);
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,29 @@ type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
|
||||||
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
|
const EFFORT_LEVELS = new Set<EffortLevel>(['low', 'medium', 'high']);
|
||||||
|
|
||||||
class HttpBadRequestError extends Error {}
|
class HttpBadRequestError extends Error {}
|
||||||
|
class HttpFeatureUnavailableError extends Error {}
|
||||||
|
|
||||||
function getTeamProvisioningService(services: HttpServices) {
|
function getTeamProvisioningService(services: HttpServices) {
|
||||||
if (!services.teamProvisioningService) {
|
if (!services.teamProvisioningService) {
|
||||||
throw new Error('Team runtime control is not available in this mode');
|
throw new HttpFeatureUnavailableError('Team runtime control is not available in this mode');
|
||||||
}
|
}
|
||||||
return services.teamProvisioningService;
|
return services.teamProvisioningService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStatusCode(error: unknown, fallback: number = 500): number {
|
||||||
|
if (error instanceof HttpBadRequestError) {
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
if (error instanceof HttpFeatureUnavailableError) {
|
||||||
|
return 501;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldLogError(error: unknown): boolean {
|
||||||
|
return !(error instanceof HttpBadRequestError) && !(error instanceof HttpFeatureUnavailableError);
|
||||||
|
}
|
||||||
|
|
||||||
function assertAbsoluteCwd(cwd: unknown): string {
|
function assertAbsoluteCwd(cwd: unknown): string {
|
||||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||||
throw new HttpBadRequestError('cwd must be a non-empty string');
|
throw new HttpBadRequestError('cwd must be a non-empty string');
|
||||||
|
|
@ -126,8 +141,8 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
);
|
);
|
||||||
return reply.send(response);
|
return reply.send(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const statusCode = error instanceof HttpBadRequestError ? 400 : 500;
|
const statusCode = getStatusCode(error);
|
||||||
if (!(error instanceof HttpBadRequestError)) {
|
if (shouldLogError(error)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error in POST /api/teams/${request.params.teamName}/launch:`,
|
`Error in POST /api/teams/${request.params.teamName}/launch:`,
|
||||||
getErrorMessage(error)
|
getErrorMessage(error)
|
||||||
|
|
@ -151,11 +166,13 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
teamProvisioningService.stopTeam(validatedTeamName.value!);
|
teamProvisioningService.stopTeam(validatedTeamName.value!);
|
||||||
return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!));
|
return reply.send(teamProvisioningService.getRuntimeState(validatedTeamName.value!));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
if (shouldLogError(error)) {
|
||||||
`Error in POST /api/teams/${request.params.teamName}/stop:`,
|
logger.error(
|
||||||
getErrorMessage(error)
|
`Error in POST /api/teams/${request.params.teamName}/stop:`,
|
||||||
);
|
getErrorMessage(error)
|
||||||
return reply.status(500).send({ error: getErrorMessage(error) });
|
);
|
||||||
|
}
|
||||||
|
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -173,11 +190,13 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!)
|
getTeamProvisioningService(services).getRuntimeState(validatedTeamName.value!)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
if (shouldLogError(error)) {
|
||||||
`Error in GET /api/teams/${request.params.teamName}/runtime:`,
|
logger.error(
|
||||||
getErrorMessage(error)
|
`Error in GET /api/teams/${request.params.teamName}/runtime:`,
|
||||||
);
|
getErrorMessage(error)
|
||||||
return reply.status(500).send({ error: getErrorMessage(error) });
|
);
|
||||||
|
}
|
||||||
|
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -194,8 +213,10 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
return reply.send(await getTeamProvisioningService(services).getProvisioningStatus(runId));
|
return reply.send(await getTeamProvisioningService(services).getProvisioningStatus(runId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
const statusCode = message === 'Unknown runId' ? 404 : 500;
|
const statusCode = message === 'Unknown runId' ? 404 : getStatusCode(error);
|
||||||
logger.error(`Error in GET /api/teams/provisioning/${request.params.runId}:`, message);
|
if (shouldLogError(error) && statusCode !== 404) {
|
||||||
|
logger.error(`Error in GET /api/teams/provisioning/${request.params.runId}:`, message);
|
||||||
|
}
|
||||||
return reply.status(statusCode).send({ error: message });
|
return reply.status(statusCode).send({ error: message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -209,8 +230,10 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
.map((teamName) => teamProvisioningService.getRuntimeState(teamName));
|
.map((teamName) => teamProvisioningService.getRuntimeState(teamName));
|
||||||
return reply.send(runtimeStates);
|
return reply.send(runtimeStates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error in GET /api/teams/runtime/alive:', getErrorMessage(error));
|
if (shouldLogError(error)) {
|
||||||
return reply.status(500).send({ error: getErrorMessage(error) });
|
logger.error('Error in GET /api/teams/runtime/alive:', getErrorMessage(error));
|
||||||
|
}
|
||||||
|
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -812,6 +812,9 @@ function initializeServices(): void {
|
||||||
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||||
reconfigureLocalContextForClaudeRoot();
|
reconfigureLocalContextForClaudeRoot();
|
||||||
void schedulerService?.reloadForClaudeRootChange();
|
void schedulerService?.reloadForClaudeRootChange();
|
||||||
|
if (httpServer?.isRunning()) {
|
||||||
|
void syncTeamControlApiState().catch(() => undefined);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -858,7 +861,7 @@ function initializeServices(): void {
|
||||||
// Start HTTP server if enabled in config
|
// Start HTTP server if enabled in config
|
||||||
const appConfig = configManager.getConfig();
|
const appConfig = configManager.getConfig();
|
||||||
if (appConfig.httpServer?.enabled) {
|
if (appConfig.httpServer?.enabled) {
|
||||||
void startHttpServer(handleModeSwitch);
|
void startHttpServer(handleModeSwitch).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Services initialized successfully');
|
logger.info('Services initialized successfully');
|
||||||
|
|
@ -897,6 +900,7 @@ async function startHttpServer(
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await clearTeamControlApiState().catch(() => undefined);
|
await clearTeamControlApiState().catch(() => undefined);
|
||||||
logger.error('Failed to start HTTP server:', error);
|
logger.error('Failed to start HTTP server:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,6 @@ async function handleStart(): Promise<{
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
if (httpServer.isRunning()) {
|
|
||||||
return { success: true, data: { running: true, port: httpServer.getPort() } };
|
|
||||||
}
|
|
||||||
await startServer();
|
await startServer();
|
||||||
configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() });
|
configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() });
|
||||||
return { success: true, data: { running: true, port: httpServer.getPort() } };
|
return { success: true, data: { running: true, port: httpServer.getPort() } };
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Plus } from 'lucide-react';
|
||||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||||
|
|
||||||
import { MemberDraftRow } from './MemberDraftRow';
|
import { MemberDraftRow } from './MemberDraftRow';
|
||||||
|
import { getNextSuggestedMemberName } from './memberNameSets';
|
||||||
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils';
|
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
|
|
@ -145,7 +146,8 @@ export const MembersEditorSection = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMember = (): void => {
|
const addMember = (): void => {
|
||||||
onChange([...members, createMemberDraft()]);
|
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
|
||||||
|
onChange([...members, createMemberDraft({ name: suggestedName })]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||||
|
|
|
||||||
65
src/renderer/components/team/members/memberNameSets.ts
Normal file
65
src/renderer/components/team/members/memberNameSets.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
const MEMBER_NAME_SETS = [
|
||||||
|
['atlas', 'nova', 'echo', 'vector', 'ember', 'pixel'],
|
||||||
|
['scout', 'forge', 'quill', 'signal', 'patch', 'guard'],
|
||||||
|
['aurora', 'cipher', 'relay', 'kernel', 'beacon', 'sable'],
|
||||||
|
['orbit', 'flux', 'delta', 'prism', 'comet', 'node'],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function normalizeMemberName(name: string): string {
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function belongsToBaseName(name: string, baseName: string): boolean {
|
||||||
|
const normalized = normalizeMemberName(name);
|
||||||
|
return normalized === baseName || normalized.startsWith(`${baseName}-`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredNameSet(existingNames: readonly string[]): readonly string[] {
|
||||||
|
for (const nameSet of MEMBER_NAME_SETS) {
|
||||||
|
if (
|
||||||
|
nameSet.some((candidate) => existingNames.some((name) => belongsToBaseName(name, candidate)))
|
||||||
|
) {
|
||||||
|
return nameSet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MEMBER_NAME_SETS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUniqueName(baseName: string, existingNames: readonly string[]): string {
|
||||||
|
const normalizedExisting = new Set(existingNames.map(normalizeMemberName));
|
||||||
|
if (!normalizedExisting.has(baseName)) {
|
||||||
|
return baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suffix = 2;
|
||||||
|
while (normalizedExisting.has(`${baseName}-${suffix}`)) {
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseName}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextSuggestedMemberName(existingNames: readonly string[]): string {
|
||||||
|
const normalizedExisting = new Set(existingNames.map(normalizeMemberName).filter(Boolean));
|
||||||
|
const preferredSet = getPreferredNameSet(existingNames);
|
||||||
|
|
||||||
|
for (const candidate of preferredSet) {
|
||||||
|
if (!normalizedExisting.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nameSet of MEMBER_NAME_SETS) {
|
||||||
|
for (const candidate of nameSet) {
|
||||||
|
if (!normalizedExisting.has(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackBaseName = preferredSet[existingNames.length % preferredSet.length] ?? 'agent';
|
||||||
|
return createUniqueName(fallbackBaseName, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MEMBER_NAME_SETS };
|
||||||
|
|
@ -205,4 +205,33 @@ describe('HTTP team runtime routes', () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns 501 when team runtime routes are registered without a runtime service', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
registerTeamRoutes(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
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'],
|
||||||
|
} satisfies HttpServices
|
||||||
|
);
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/teams/runtime/alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(501);
|
||||||
|
expect(response.json()).toEqual({ error: 'Team runtime control is not available in this mode' });
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue