feat(agent-teams): harden dev bootstrap and task tooling
This commit is contained in:
parent
01e9e8350e
commit
4869bb35da
11 changed files with 758 additions and 72 deletions
|
|
@ -1,4 +1,6 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
|
|
@ -56,6 +58,42 @@ function buildCreateTaskPayload(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -85,6 +123,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
prompt,
|
||||
startImmediately,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const controller = getController(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
|
|
@ -143,23 +182,34 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
prompt,
|
||||
startImmediately,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
const controller = getController(teamName, claudeDir);
|
||||
|
||||
// 1. Lookup message by exact messageId
|
||||
const { message } = controller.messages.lookupMessage(messageId);
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
({ message } = controller.messages.lookupMessage(messageId));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('Message not found:')) {
|
||||
throw new Error(
|
||||
`${error.message}. task_create_from_message only works with the explicit User MessageId shown in the relay prompt for a user_sent message. Do not use teammate inbox ids or guessed ids.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 2. Reject if message source is not user-originated
|
||||
const source = typeof message.source === 'string' ? message.source : '';
|
||||
if (!USER_ORIGINATED_SOURCES.has(source)) {
|
||||
throw new Error(
|
||||
`Message source "${source}" is not user-originated. Only user_sent messages are eligible.`
|
||||
`Message source "${source}" is not user-originated. task_create_from_message only accepts explicit user_sent messages from the relay prompt. For teammate, system, or cross-team messages, use task_create instead.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reject relay copies explicitly
|
||||
if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) {
|
||||
throw new Error(
|
||||
'Cannot create task from a relay copy. Use the original message instead.'
|
||||
'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -665,6 +665,13 @@ describe('agent-teams-mcp tools', () => {
|
|||
it('covers review_request_changes and full process lifecycle tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'beta';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const createdTask = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
|
|
@ -861,6 +868,12 @@ describe('agent-teams-mcp tools', () => {
|
|||
it('task_add_comment succeeds even when owner inbox write fails', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'resilience';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const task = parseJsonToolResult(
|
||||
await getTool('task_create').execute({
|
||||
|
|
@ -1109,7 +1122,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'nonexistent-msg',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('Message not found: nonexistent-msg');
|
||||
).rejects.toThrow(
|
||||
'Message not found: nonexistent-msg. task_create_from_message only works with the explicit User MessageId'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-user-originated message sources', async () => {
|
||||
|
|
@ -1136,7 +1151,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lead_process and cross_team sources explicitly', async () => {
|
||||
|
|
@ -1170,7 +1187,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-lead-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
|
|
@ -1179,7 +1198,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-cross-001',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects messages without an explicit source field (fail closed)', async () => {
|
||||
|
|
@ -1205,7 +1226,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId: 'msg-no-source',
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('not user-originated');
|
||||
).rejects.toThrow(
|
||||
'task_create_from_message only accepts explicit user_sent messages from the relay prompt'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects relay copies', async () => {
|
||||
|
|
@ -1233,7 +1256,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
messageId,
|
||||
subject: 'Should fail',
|
||||
})
|
||||
).rejects.toThrow('relay copy');
|
||||
).rejects.toThrow(
|
||||
'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves attachment metadata without blob copying', async () => {
|
||||
|
|
@ -1430,4 +1455,33 @@ describe('agent-teams-mcp tools', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed for task_create when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
|
||||
await expect(
|
||||
getTool('task_create').execute({
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
subject: 'Phantom task should fail',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for task_create_from_message when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
|
||||
await expect(
|
||||
getTool('task_create_from_message').execute({
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
messageId: 'msg-1',
|
||||
subject: 'Phantom task should fail',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:ui": "electron-vite dev",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||
"build": "electron-vite build",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,25 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import readline from 'node:readline';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uiRepoRoot = path.resolve(scriptDir, '..');
|
||||
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim() ?? '';
|
||||
const explicitRuntimeCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() ?? '';
|
||||
const runtimeLockPath = path.join(uiRepoRoot, 'runtime.lock.json');
|
||||
const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime-cache');
|
||||
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
||||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
|
||||
function runOrExit(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
stdio: 'inherit',
|
||||
|
|
@ -21,6 +36,27 @@ function runOrExit(cmd, args, options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
function runAndCapture(cmd, args, options = {}) {
|
||||
const result = spawnSync(cmd, args, {
|
||||
encoding: 'utf8',
|
||||
...options,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ${cmd}: ${result.error.message}`);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const details = [result.stdout, result.stderr]
|
||||
.map((value) => value?.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
throw new Error(`Command failed: ${cmd} ${args.join(' ')}${details ? `\n${details}` : ''}`);
|
||||
}
|
||||
|
||||
return result.stdout?.trim() ?? '';
|
||||
}
|
||||
|
||||
function readPackageManagerCommand(repoRoot) {
|
||||
const packageJsonPath = path.join(repoRoot, 'package.json');
|
||||
const rawPackageJson = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
|
|
@ -39,53 +75,442 @@ function readPackageManagerCommand(repoRoot) {
|
|||
return packageManagerName;
|
||||
}
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uiRepoRoot = path.resolve(scriptDir, '..');
|
||||
// Keep the dev runtime target explicit. This workspace can contain multiple
|
||||
// sibling repos with the same package name, so auto-discovery is ambiguous and
|
||||
// can silently point the UI at the wrong runtime after branch switches.
|
||||
const runtimeRepoRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
function readRuntimeLock() {
|
||||
return JSON.parse(fs.readFileSync(runtimeLockPath, 'utf8'));
|
||||
}
|
||||
|
||||
if (!runtimeRepoRoot) {
|
||||
console.error(
|
||||
'CLAUDE_DEV_RUNTIME_ROOT is required for pnpm dev. ' +
|
||||
'Point it at the runtime repo root you want the UI to use in dev.'
|
||||
);
|
||||
function getPlatformAssetKey() {
|
||||
const platformKey = `${process.platform}-${process.arch}`;
|
||||
|
||||
switch (platformKey) {
|
||||
case 'darwin-arm64':
|
||||
case 'darwin-x64':
|
||||
case 'linux-x64':
|
||||
case 'win32-x64':
|
||||
return platformKey;
|
||||
default:
|
||||
throw new Error(
|
||||
`Dev runtime bootstrap does not support this platform yet: ${process.platform}/${process.arch}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${runtimeLock.sourceRef}/${encodeURIComponent(asset.file)}`;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function truncateMiddle(value, maxLength) {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (maxLength <= 3) {
|
||||
return value.slice(0, maxLength);
|
||||
}
|
||||
|
||||
const visibleChars = maxLength - 3;
|
||||
const headLength = Math.ceil(visibleChars / 2);
|
||||
const tailLength = Math.floor(visibleChars / 2);
|
||||
return `${value.slice(0, headLength)}...${value.slice(value.length - tailLength)}`;
|
||||
}
|
||||
|
||||
function buildProgressBar(progressRatio, width) {
|
||||
const safeWidth = Math.max(10, width);
|
||||
const clampedRatio = Number.isFinite(progressRatio)
|
||||
? Math.min(1, Math.max(0, progressRatio))
|
||||
: 0;
|
||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||
}
|
||||
|
||||
function supportsProgressRedraw() {
|
||||
return Boolean(process.stdout.isTTY && process.env.TERM && process.env.TERM !== 'dumb');
|
||||
}
|
||||
|
||||
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
|
||||
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
|
||||
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||
const bytesText = hasTotal
|
||||
? `${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`
|
||||
: `${formatBytes(writtenBytes)}`;
|
||||
const barWidth = hasTotal ? Math.min(24, Math.max(10, Math.floor(columns * 0.18))) : 0;
|
||||
const barText = hasTotal ? ` [${buildProgressBar(ratio, barWidth)}]` : '';
|
||||
const fixedParts = `${barText} ${bytesText}${percentText}`.trimStart();
|
||||
const availableLabelWidth = Math.max(16, columns - fixedParts.length - 1);
|
||||
const labelText = truncateMiddle(label, availableLabelWidth);
|
||||
|
||||
return `${labelText}${fixedParts ? ` ${fixedParts}` : ''}`;
|
||||
}
|
||||
|
||||
function formatProgressSummary(writtenBytes, totalBytes, hasTotal) {
|
||||
if (hasTotal) {
|
||||
const ratio = writtenBytes / totalBytes;
|
||||
return `Runtime download ${Math.floor(ratio * 100)}% - ${formatBytes(writtenBytes)} / ${formatBytes(totalBytes)}`;
|
||||
}
|
||||
|
||||
return `Runtime download - ${formatBytes(writtenBytes)}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function readBinaryVersion(binaryPath) {
|
||||
return runAndCapture(binaryPath, ['--version']);
|
||||
}
|
||||
|
||||
function isExecutable(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return fs.statSync(filePath).isFile();
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCachedBinaryValid(binaryPath, expectedVersion) {
|
||||
if (!isExecutable(binaryPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return readBinaryVersion(binaryPath).includes(expectedVersion);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadWithProgress(url, destinationPath) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'user-agent': 'claude-team-dev-runtime-bootstrap',
|
||||
},
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to download runtime asset: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const totalBytes = Number.parseInt(response.headers.get('content-length') ?? '', 10);
|
||||
const hasTotal = Number.isFinite(totalBytes) && totalBytes > 0;
|
||||
const writer = fs.createWriteStream(destinationPath);
|
||||
const reader = response.body.getReader();
|
||||
let writtenBytes = 0;
|
||||
let lastPrintedAt = 0;
|
||||
let lastLoggedPercent = -1;
|
||||
let lastLoggedBytes = 0;
|
||||
const label = `Downloading runtime ${path.basename(destinationPath)}`;
|
||||
const canRedraw = supportsProgressRedraw();
|
||||
|
||||
if (canRedraw) {
|
||||
process.stdout.write(formatProgressLine(label, 0, totalBytes, hasTotal));
|
||||
} else {
|
||||
process.stdout.write(`${label}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!writer.write(Buffer.from(value))) {
|
||||
await once(writer, 'drain');
|
||||
}
|
||||
writtenBytes += value.byteLength;
|
||||
|
||||
const now = Date.now();
|
||||
if (canRedraw && (now - lastPrintedAt >= 150 || writtenBytes === totalBytes)) {
|
||||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(formatProgressLine(label, writtenBytes, totalBytes, hasTotal));
|
||||
lastPrintedAt = now;
|
||||
} else if (!canRedraw) {
|
||||
const nextPercent = hasTotal ? Math.floor((writtenBytes / totalBytes) * 100) : null;
|
||||
const shouldLogPercent =
|
||||
nextPercent !== null && (nextPercent === 100 || nextPercent >= lastLoggedPercent + 5);
|
||||
const shouldLogBytes =
|
||||
nextPercent === null && writtenBytes >= lastLoggedBytes + 5 * 1024 * 1024;
|
||||
|
||||
if (shouldLogPercent || shouldLogBytes) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
if (nextPercent !== null) {
|
||||
lastLoggedPercent = nextPercent;
|
||||
} else {
|
||||
lastLoggedBytes = writtenBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
writer.end((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (canRedraw) {
|
||||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, extractDir, archiveKind) {
|
||||
ensureDir(extractDir);
|
||||
|
||||
if (archiveKind === 'tar.gz') {
|
||||
runOrExit('tar', ['-xzf', archivePath, '-C', extractDir]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (archiveKind === 'zip') {
|
||||
if (process.platform === 'win32') {
|
||||
runOrExit('powershell', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Expand-Archive -Path '${archivePath.replace(/'/g, "''")}' -DestinationPath '${extractDir.replace(/'/g, "''")}' -Force`,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
runOrExit('unzip', ['-oq', archivePath, '-d', extractDir]);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported runtime archive kind: ${archiveKind}`);
|
||||
}
|
||||
|
||||
function findExtractedBinary(extractDir, binaryName) {
|
||||
const directCandidate = path.join(extractDir, 'runtime', binaryName);
|
||||
if (fs.existsSync(directCandidate)) {
|
||||
return directCandidate;
|
||||
}
|
||||
|
||||
const fallbackCandidate = path.join(extractDir, binaryName);
|
||||
if (fs.existsSync(fallbackCandidate)) {
|
||||
return fallbackCandidate;
|
||||
}
|
||||
|
||||
throw new Error(`Extracted runtime archive does not contain ${binaryName}`);
|
||||
}
|
||||
|
||||
async function acquireBootstrapLock(lockPath) {
|
||||
const waitDeadline = Date.now() + 120_000;
|
||||
let announcedWait = false;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await fs.promises.open(lockPath, 'wx');
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!announcedWait) {
|
||||
process.stdout.write('Waiting for another runtime bootstrap to finish...\n');
|
||||
announcedWait = true;
|
||||
}
|
||||
|
||||
if (Date.now() >= waitDeadline) {
|
||||
throw new Error(`Timed out waiting for runtime bootstrap lock: ${lockPath}`);
|
||||
}
|
||||
|
||||
await sleep(750);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBootstrappedRuntime() {
|
||||
const runtimeLock = readRuntimeLock();
|
||||
const platformKey = getPlatformAssetKey();
|
||||
const asset = runtimeLock.assets[platformKey];
|
||||
if (!asset) {
|
||||
throw new Error(`No runtime asset configured for ${platformKey}`);
|
||||
}
|
||||
|
||||
const cacheDir = path.join(runtimeCacheRoot, runtimeLock.version, platformKey);
|
||||
const cachedBinaryPath = path.join(cacheDir, asset.binaryName);
|
||||
|
||||
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText: readBinaryVersion(cachedBinaryPath),
|
||||
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
ensureDir(cacheDir);
|
||||
const lockHandle = await acquireBootstrapLock(path.join(cacheDir, '.bootstrap.lock'));
|
||||
|
||||
try {
|
||||
if (isCachedBinaryValid(cachedBinaryPath, runtimeLock.version)) {
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText: readBinaryVersion(cachedBinaryPath),
|
||||
sourceLabel: `cached release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
const workDir = path.join(cacheDir, `.bootstrap-${process.pid}-${Date.now()}`);
|
||||
ensureDir(workDir);
|
||||
|
||||
try {
|
||||
const archivePath = path.join(workDir, asset.file);
|
||||
await downloadWithProgress(getReleaseAssetUrl(runtimeLock, asset), archivePath);
|
||||
|
||||
const extractDir = path.join(workDir, 'extracted');
|
||||
extractArchive(archivePath, extractDir, asset.archiveKind);
|
||||
|
||||
const extractedBinaryPath = findExtractedBinary(extractDir, asset.binaryName);
|
||||
const nextBinaryPath = `${cachedBinaryPath}.tmp`;
|
||||
await fs.promises.copyFile(extractedBinaryPath, nextBinaryPath);
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.promises.chmod(nextBinaryPath, 0o755);
|
||||
}
|
||||
|
||||
await fs.promises.rm(cachedBinaryPath, { force: true });
|
||||
await fs.promises.rename(nextBinaryPath, cachedBinaryPath);
|
||||
|
||||
const versionText = readBinaryVersion(cachedBinaryPath);
|
||||
if (!versionText.includes(runtimeLock.version)) {
|
||||
await fs.promises.rm(cachedBinaryPath, { force: true });
|
||||
throw new Error(
|
||||
`Bootstrapped runtime version mismatch. Expected ${runtimeLock.version}, got: ${versionText}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
binaryPath: cachedBinaryPath,
|
||||
versionText,
|
||||
sourceLabel: `downloaded release ${runtimeLock.sourceRef}`,
|
||||
cacheDir,
|
||||
downloaded: true,
|
||||
};
|
||||
} finally {
|
||||
await fs.promises.rm(nextBinaryPath, { force: true });
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
await lockHandle.close();
|
||||
await fs.promises.rm(path.join(cacheDir, '.bootstrap.lock'), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeRepoRoot(repoRoot) {
|
||||
const runtimePackageJsonPath = path.join(repoRoot, 'package.json');
|
||||
if (!fs.existsSync(runtimePackageJsonPath)) {
|
||||
console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${repoRoot}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRuntimeCli() {
|
||||
if (explicitRuntimeCliPath) {
|
||||
if (!isExecutable(explicitRuntimeCliPath)) {
|
||||
throw new Error(
|
||||
`CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH is not executable: ${explicitRuntimeCliPath}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
binaryPath: explicitRuntimeCliPath,
|
||||
versionText: readBinaryVersion(explicitRuntimeCliPath),
|
||||
sourceLabel: `explicit runtime override ${explicitRuntimeCliPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeRepoRoot) {
|
||||
validateRuntimeRepoRoot(runtimeRepoRoot);
|
||||
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot);
|
||||
|
||||
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
|
||||
|
||||
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
|
||||
return {
|
||||
binaryPath: runtimeCliPath,
|
||||
versionText: readBinaryVersion(runtimeCliPath),
|
||||
sourceLabel: `local runtime repo ${runtimeRepoRoot}`,
|
||||
};
|
||||
}
|
||||
|
||||
return ensureBootstrappedRuntime();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const resolvedRuntime = await resolveRuntimeCli();
|
||||
|
||||
if (shouldPrintRuntimePath) {
|
||||
process.stdout.write(`${resolvedRuntime.binaryPath}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`Using runtime from ${resolvedRuntime.sourceLabel}\n`);
|
||||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
|
||||
}
|
||||
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
|
||||
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: resolvedRuntime.binaryPath,
|
||||
};
|
||||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
|
||||
runOrExit('pnpm', ['exec', 'electron-vite', 'dev'], {
|
||||
cwd: uiRepoRoot,
|
||||
env: uiEnv,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runtimePackageJsonPath = path.join(runtimeRepoRoot, 'package.json');
|
||||
if (!fs.existsSync(runtimePackageJsonPath)) {
|
||||
console.error(`CLAUDE_DEV_RUNTIME_ROOT does not look like a repo root: ${runtimeRepoRoot}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const runtimePackageManager = readPackageManagerCommand(runtimeRepoRoot);
|
||||
|
||||
if (process.argv.includes('--print-runtime-path')) {
|
||||
process.stdout.write(`${runtimeRepoRoot}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Respect the runtime repo's own package manager. The UI repo uses pnpm, but
|
||||
// the runtime may legitimately be a Bun workspace, and forcing pnpm there can
|
||||
// fail before the build even starts.
|
||||
runOrExit(runtimePackageManager, ['run', 'build:dev'], { cwd: runtimeRepoRoot });
|
||||
|
||||
const runtimeCliPath = path.join(runtimeRepoRoot, 'cli-dev');
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
// Dev-only agent_teams_orchestrator runtime override. Keep it separate from
|
||||
// the generic CLAUDE_CLI_PATH override so switching the app into Claude CLI
|
||||
// mode still resolves the real official binary instead of this local
|
||||
// cli-dev shim.
|
||||
CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH: runtimeCliPath,
|
||||
};
|
||||
// If the parent shell exported a stale generic override, do not let it leak
|
||||
// into the Electron main process. Claude mode must resolve the real binary.
|
||||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
|
||||
runOrExit('pnpm', ['run', 'dev:ui'], {
|
||||
cwd: uiRepoRoot,
|
||||
env: uiEnv,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -250,6 +250,10 @@ export function normalizeVersion(raw: string): string {
|
|||
return match ? match[0] : raw.trim();
|
||||
}
|
||||
|
||||
function isSemverVersion(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && /^\d{1,10}\.\d{1,10}\.\d{1,10}$/.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver strings numerically.
|
||||
* Returns true if `installed` is strictly older than `latest`.
|
||||
|
|
@ -579,7 +583,7 @@ export class CliInstallerService {
|
|||
|
||||
private async probeCliVersion(
|
||||
binaryPath: string
|
||||
): Promise<{ ok: true; version: string } | { ok: false; error: string }> {
|
||||
): Promise<{ ok: true; version: string | null } | { ok: false; error: string }> {
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||
timeout: VERSION_TIMEOUT_MS,
|
||||
|
|
@ -589,13 +593,44 @@ export class CliInstallerService {
|
|||
if (!version) {
|
||||
return { ok: false, error: 'CLI returned an empty version string.' };
|
||||
}
|
||||
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
|
||||
return { ok: true, version };
|
||||
|
||||
if (isSemverVersion(version)) {
|
||||
logger.info(`Installed CLI version: "${stdout.trim()}" → normalized: "${version}"`);
|
||||
return { ok: true, version };
|
||||
}
|
||||
|
||||
const inferredVersion = await this.inferInstalledCliVersionFromPath(binaryPath);
|
||||
if (inferredVersion) {
|
||||
logger.info(
|
||||
`Installed CLI version was inferred from installer path: "${stdout.trim()}" → "${inferredVersion}"`
|
||||
);
|
||||
return { ok: true, version: inferredVersion };
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Installed CLI returned a non-semver version string: "${stdout.trim()}". ` +
|
||||
'Treating the binary as healthy, but omitting version details.'
|
||||
);
|
||||
return { ok: true, version: null };
|
||||
} catch (err) {
|
||||
return { ok: false, error: getErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
private async inferInstalledCliVersionFromPath(binaryPath: string): Promise<string | null> {
|
||||
try {
|
||||
const resolvedPath = await fsp.realpath(binaryPath);
|
||||
if (!/[\\/]+versions[\\/]+/.test(resolvedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inferredVersion = normalizeVersion(resolvedPath);
|
||||
return isSemverVersion(inferredVersion) ? inferredVersion : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private markProvidersUnavailable(result: CliInstallationStatus, message: string): void {
|
||||
if (result.flavor !== 'agent_teams_orchestrator') {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,27 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
|||
);
|
||||
}
|
||||
|
||||
function buildLeadRosterContextBlock(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
teammates: Array<{ name: string; role?: string }>
|
||||
): string | null {
|
||||
if (teammates.length === 0) return null;
|
||||
|
||||
const summary = teammates
|
||||
.map((member) => (member.role ? `${member.name} (${member.role})` : member.name))
|
||||
.join(', ');
|
||||
|
||||
return [
|
||||
`Current durable team context:`,
|
||||
`- Team name: ${teamName}`,
|
||||
`- You are the live team lead "${leadName}"`,
|
||||
`- Persistent teammates currently configured: ${summary}`,
|
||||
`- This team is NOT in solo mode`,
|
||||
`- If the user asks who is on the team, answer from this durable roster unless newer durable state explicitly says otherwise.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the durable lead context — constraints, communication protocol, board MCP ops,
|
||||
* and agent block policy — that must survive context compaction.
|
||||
|
|
@ -5860,6 +5881,16 @@ export class TeamProvisioningService {
|
|||
|
||||
const MAX_RELAY = 10;
|
||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
||||
const teammateRoster = (config.members ?? [])
|
||||
.filter((member) => {
|
||||
const name = member.name?.trim();
|
||||
return name && name !== leadName;
|
||||
})
|
||||
.map((member) => ({
|
||||
name: member.name.trim(),
|
||||
...(member.role?.trim() ? { role: member.role.trim() } : {}),
|
||||
}));
|
||||
const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster);
|
||||
run.activeCrossTeamReplyHints = batch.flatMap((m) => {
|
||||
if (m.source !== 'cross_team') return [];
|
||||
const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : '';
|
||||
|
|
@ -5877,9 +5908,11 @@ export class TeamProvisioningService {
|
|||
`If there is no action to take, produce ZERO text output. Do NOT write "No action needed.", status echoes, or any other no-op summary.`,
|
||||
`For pure system notifications, comment notifications, or routine teammate availability updates that require no reply/comment/action, say nothing.`,
|
||||
`Do NOT respond with only an agent-only block.`,
|
||||
...(rosterContextBlock ? [rosterContextBlock] : []),
|
||||
AGENT_BLOCK_OPEN,
|
||||
`Internal note: for task assignments, prefer task_create and rely on the board/runtime notification path instead of sending a separate SendMessage for the same assignment.`,
|
||||
`When creating a task from a user message that has a MessageId field, prefer task_create_from_message with that exact messageId for reliable provenance. Only use task_create_from_message when you have an explicit MessageId — never guess or fabricate one.`,
|
||||
`For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`,
|
||||
`Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`,
|
||||
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`,
|
||||
`Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`,
|
||||
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
|
||||
|
|
@ -5889,6 +5922,10 @@ export class TeamProvisioningService {
|
|||
`Messages:`,
|
||||
...batch.flatMap((m, idx) => {
|
||||
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
||||
const isTaskCreateFromMessageEligible = m.source === 'user_sent';
|
||||
const provenanceLines = isTaskCreateFromMessageEligible
|
||||
? [` Eligible for task_create_from_message: yes`, ` User MessageId: ${m.messageId}`]
|
||||
: [` Eligible for task_create_from_message: no`];
|
||||
const crossTeamMeta =
|
||||
m.source === 'cross_team'
|
||||
? {
|
||||
|
|
@ -5910,11 +5947,11 @@ export class TeamProvisioningService {
|
|||
return [
|
||||
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||
` Timestamp: ${m.timestamp}`,
|
||||
` MessageId: ${m.messageId}`,
|
||||
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||
...(typeof m.source === 'string' && m.source.trim()
|
||||
? [` Source: ${m.source.trim()}`]
|
||||
: []),
|
||||
...provenanceLines,
|
||||
...replyInstructions,
|
||||
` Text:`,
|
||||
...m.text.split('\n').map((line) => ` ${line}`),
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ export interface ProvisioningProgressBlockProps {
|
|||
cliLogsTail?: string;
|
||||
/** Accumulated assistant text output for live preview */
|
||||
assistantOutput?: string;
|
||||
/** Visual surface chrome for the outer block */
|
||||
surface?: 'raised' | 'flat';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +150,7 @@ export const ProvisioningProgressBlock = ({
|
|||
pid,
|
||||
cliLogsTail,
|
||||
assistantOutput,
|
||||
surface = 'raised',
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
|
|
@ -203,7 +206,9 @@ export const ProvisioningProgressBlock = ({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
||||
surface === 'flat'
|
||||
? 'rounded-none border-0 bg-transparent px-0 py-0'
|
||||
: 'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2',
|
||||
isError && 'border-red-500/40 bg-red-500/10',
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
|||
title="Launch failed"
|
||||
message={progress.error ?? null}
|
||||
tone="error"
|
||||
surface="flat"
|
||||
currentStepIndex={lastActiveStepRef.current}
|
||||
errorStepIndex={lastActiveStepRef.current >= 0 ? lastActiveStepRef.current : 0}
|
||||
startedAt={progress.startedAt}
|
||||
|
|
@ -174,6 +175,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
|||
title="Launch details"
|
||||
message={failedSpawnCount > 0 || hasMembersStillJoining ? readyDetailMessage : null}
|
||||
messageSeverity={readyDetailSeverity}
|
||||
surface="flat"
|
||||
currentStepIndex={readyStepIndex}
|
||||
startedAt={progress.startedAt}
|
||||
pid={progress.pid}
|
||||
|
|
@ -199,6 +201,7 @@ export const TeamProvisioningBanner = memo(function TeamProvisioningBanner({
|
|||
title="Launching team"
|
||||
message={progress.message}
|
||||
messageSeverity={progress.messageSeverity}
|
||||
surface="flat"
|
||||
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
|
||||
loading
|
||||
startedAt={progress.startedAt}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export interface CliInstallationStatus {
|
|||
showBinaryPath: boolean;
|
||||
/** Whether the CLI was found and passed the startup health check (`--version`) */
|
||||
installed: boolean;
|
||||
/** Installed version string (e.g. "2.1.59"), null if not installed */
|
||||
/** Installed version string (e.g. "2.1.59"), null if unavailable or not installed */
|
||||
installedVersion: string | null;
|
||||
/** Absolute path to the resolved binary candidate, null if not found */
|
||||
binaryPath: string | null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { realpathMock } = vi.hoisted(() => ({
|
||||
realpathMock: vi.fn(async (value: string) => value),
|
||||
}));
|
||||
|
||||
// Mock dependencies before importing service
|
||||
vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
|
||||
|
|
@ -23,6 +27,7 @@ vi.mock('fs', async (importOriginal) => {
|
|||
promises: {
|
||||
...actual.promises,
|
||||
chmod: vi.fn(),
|
||||
realpath: realpathMock,
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
|
@ -57,6 +62,16 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('@main/services/team/cliFlavor', () => ({
|
||||
getConfiguredCliFlavor: vi.fn(() => 'claude'),
|
||||
getCliFlavorUiOptions: vi.fn(() => ({
|
||||
displayName: 'Claude CLI',
|
||||
supportsSelfUpdate: true,
|
||||
showVersionDetails: true,
|
||||
showBinaryPath: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
CliInstallerService,
|
||||
isVersionOlder,
|
||||
|
|
@ -79,6 +94,8 @@ describe('CliInstallerService', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
realpathMock.mockReset();
|
||||
realpathMock.mockImplementation(async (value: string) => value);
|
||||
service = new CliInstallerService();
|
||||
});
|
||||
|
||||
|
|
@ -95,16 +112,15 @@ describe('CliInstallerService', () => {
|
|||
expect(status.updateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it('returns installed when binary exists', async () => {
|
||||
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.installed).toBe(false);
|
||||
expect(status.binaryPath).toBe('/usr/local/bin/claude');
|
||||
// Version will be null because execFile is mocked to no-op
|
||||
// and latestVersion will be null because fetch is mocked
|
||||
expect(status.installedVersion).toBeNull();
|
||||
});
|
||||
|
||||
it('handles spawn EINVAL when binary path contains non-ASCII by falling back', async () => {
|
||||
|
|
@ -142,6 +158,24 @@ describe('CliInstallerService', () => {
|
|||
expect(status.authLoggedIn).toBe(true);
|
||||
expect(status.authMethod).toBe('oauth_token');
|
||||
});
|
||||
|
||||
it('falls back to the installed launcher path when --version reports unknown', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude');
|
||||
vi.spyOn(service as never, 'inferInstalledCliVersionFromPath').mockResolvedValue('2.1.101');
|
||||
vi.mocked(execCli)
|
||||
.mockResolvedValueOnce({ stdout: 'unknown', stderr: '' })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: '{"loggedIn":true,"authMethod":"oauth_token"}',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.installedVersion).toBe('2.1.101');
|
||||
expect(status.authLoggedIn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install mutex', () => {
|
||||
|
|
|
|||
|
|
@ -854,7 +854,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('includes MessageId in lead inbox relay prompt for provenance', async () => {
|
||||
it('includes user message provenance in lead inbox relay prompt', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
|
|
@ -881,7 +881,8 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
await relayPromise;
|
||||
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('MessageId: msg-provenance-001');
|
||||
expect(payload).toContain('Eligible for task_create_from_message: yes');
|
||||
expect(payload).toContain('User MessageId: msg-provenance-001');
|
||||
expect(payload).toContain('Build the authentication module');
|
||||
});
|
||||
|
||||
|
|
@ -1183,7 +1184,16 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
it('lead inbox relay prompt mentions task_create_from_message for user messages with messageId', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: 'My Team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'user',
|
||||
|
|
@ -1208,7 +1218,41 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
|||
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('task_create_from_message');
|
||||
expect(payload).toContain('MessageId');
|
||||
expect(payload).toContain('Current durable team context:');
|
||||
expect(payload).toContain(`- Team name: ${teamName}`);
|
||||
expect(payload).toContain(`teamName MUST be \\"${teamName}\\"`);
|
||||
expect(payload).toContain('Eligible for task_create_from_message: yes');
|
||||
expect(payload).toContain('User MessageId: msg-task-pref-001');
|
||||
});
|
||||
|
||||
it('does not present teammate inbox message ids as task_create_from_message provenance', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
seedConfig(teamName);
|
||||
seedLeadInbox(teamName, [
|
||||
{
|
||||
from: 'jack',
|
||||
text: 'Могу начать с проверки массовых удалений в docs-site.',
|
||||
timestamp: '2026-02-23T16:05:00.000Z',
|
||||
read: false,
|
||||
summary: 'Нет назначенных задач для jack',
|
||||
messageId: 'inbox-jack-001',
|
||||
},
|
||||
]);
|
||||
|
||||
const { writeSpy } = attachAliveRun(service, teamName);
|
||||
const relayPromise = service.relayLeadInboxMessages(teamName);
|
||||
const run = await waitForCapture(service);
|
||||
(service as any).handleStreamJsonMessage(run, {
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Понял.' }],
|
||||
});
|
||||
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
|
||||
await relayPromise;
|
||||
|
||||
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||
expect(payload).toContain('Eligible for task_create_from_message: no');
|
||||
expect(payload).not.toContain('User MessageId: inbox-jack-001');
|
||||
});
|
||||
|
||||
it('marks pure lead heartbeat idle as read without relaying it', async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue