agent-ecosystem/src/main/services/team/CrossTeamService.ts
iliya 19f2fa76d0 feat: enhance cross-team functionality and UI components
- Added isOnline property to CrossTeamTarget and updated CrossTeamService to sort targets based on online status.
- Enhanced MessageComposer to fetch and display online status of teams, improving user experience in cross-team messaging.
- Updated various UI components to reflect changes in team online status, including visual indicators in the MessagesPanel and MessageComposer.
- Improved error handling and validation messages in CreateTeamDialog and other forms for better user feedback.
- Refactored CSS for field-level validation to enhance visual consistency across forms.
- Updated utility functions to support new online status features in team management.
2026-03-12 16:33:52 +02:00

220 lines
7.3 KiB
TypeScript

import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, formatCrossTeamText } from '@shared/constants';
import { getClaudeBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import { buildActionModeAgentBlock } from './actionModeInstructions';
import { CascadeGuard } from './CascadeGuard';
import { CrossTeamOutbox } from './CrossTeamOutbox';
import type { TeamConfigReader } from './TeamConfigReader';
import type { TeamDataService } from './TeamDataService';
import type { TeamInboxWriter } from './TeamInboxWriter';
import type { TeamProvisioningService } from './TeamProvisioningService';
import type {
CrossTeamMessage,
CrossTeamSendRequest,
CrossTeamSendResult,
TeamConfig,
} from '@shared/types';
const logger = createLogger('CrossTeamService');
const { createController } = agentTeamsControllerModule;
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
export interface CrossTeamTarget {
teamName: string;
displayName: string;
description?: string;
color?: string;
leadName?: string;
leadColor?: string;
isOnline?: boolean;
}
export class CrossTeamService {
private cascadeGuard = new CascadeGuard();
private outbox = new CrossTeamOutbox();
constructor(
private configReader: TeamConfigReader,
private dataService: TeamDataService,
private inboxWriter: TeamInboxWriter,
private provisioning: TeamProvisioningService | null
) {}
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
const { fromTeam, fromMember, toTeam, text, taskRefs, summary, actionMode } = request;
const chainDepth = request.chainDepth ?? 0;
const messageId = request.messageId?.trim() || randomUUID();
const timestamp = request.timestamp ?? new Date().toISOString();
const inferredReplyMeta =
!request.conversationId && !request.replyToConversationId
? (this.provisioning?.resolveCrossTeamReplyMetadata(fromTeam, toTeam) ?? null)
: null;
const replyToConversationId =
request.replyToConversationId?.trim() ||
inferredReplyMeta?.replyToConversationId ||
undefined;
const conversationId =
request.conversationId?.trim() ||
inferredReplyMeta?.conversationId ||
replyToConversationId ||
randomUUID();
// 1. Validate
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
throw new Error(`Invalid fromTeam: ${fromTeam}`);
}
if (!TEAM_NAME_PATTERN.test(toTeam)) {
throw new Error(`Invalid toTeam: ${toTeam}`);
}
if (fromTeam === toTeam) {
throw new Error('Cannot send cross-team message to the same team');
}
if (!fromMember || typeof fromMember !== 'string' || fromMember.trim().length === 0) {
throw new Error('fromMember is required');
}
if (!text || typeof text !== 'string' || text.trim().length === 0) {
throw new Error('Message text is required');
}
const targetConfig = await this.configReader.getConfig(toTeam);
if (!targetConfig || targetConfig.deletedAt) {
throw new Error(`Target team not found: ${toTeam}`);
}
// 2. Resolve lead
const leadName = (await this.dataService.getLeadMemberName(toTeam)) ?? 'team-lead';
// 3. Format
const from = `${fromTeam}.${fromMember}`;
const actionModeBlock = buildActionModeAgentBlock(actionMode);
const deliveryText = actionModeBlock ? `${actionModeBlock}\n\n${text}` : text;
const formattedText = formatCrossTeamText(from, chainDepth, deliveryText, {
conversationId,
replyToConversationId,
});
const outboxMessage: CrossTeamMessage = {
messageId,
fromTeam,
fromMember,
toTeam,
conversationId,
replyToConversationId,
text,
taskRefs,
summary,
chainDepth,
timestamp,
};
const { duplicate } = await this.outbox.appendIfNotRecent(fromTeam, outboxMessage, async () => {
// 4. Cascade check only for real new deliveries
this.cascadeGuard.check(fromTeam, toTeam, chainDepth);
this.cascadeGuard.record(fromTeam, toTeam);
this.provisioning?.registerPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
// 5. Inbox write to TARGET team (TeamInboxWriter handles file lock + in-process lock internally)
await this.inboxWriter.sendMessage(toTeam, {
member: leadName,
text: formattedText,
from,
timestamp,
messageId,
summary: summary ?? `Cross-team message from ${fromTeam}`,
source: CROSS_TEAM_SOURCE,
conversationId,
replyToConversationId,
taskRefs,
});
});
if (duplicate) {
return { messageId: duplicate.messageId, deliveredToInbox: true, deduplicated: true };
}
// 6. Write a non-actionable sender copy so the message appears in activity without
// waking the local lead through their inbox controller.
try {
createController({
teamName: fromTeam,
claudeDir: getClaudeBasePath(),
}).messages.appendSentMessage({
from: fromMember,
to: `${toTeam}.${leadName}`,
text,
taskRefs,
timestamp,
messageId,
summary: summary ?? `Cross-team message to ${toTeam}`,
source: CROSS_TEAM_SENT_SOURCE,
conversationId,
replyToConversationId,
});
this.provisioning?.clearPendingCrossTeamReplyExpectation(fromTeam, toTeam, conversationId);
} catch (e: unknown) {
logger.warn(
`Failed to write sender copy for ${fromTeam}: ${e instanceof Error ? e.message : String(e)}`
);
}
// 7. Best-effort relay (if online)
if (this.provisioning?.isTeamAlive(toTeam)) {
void this.provisioning.relayLeadInboxMessages(toTeam).catch((e: unknown) => {
logger.warn(`Cross-team relay to ${toTeam}: ${e instanceof Error ? e.message : String(e)}`);
});
}
return { messageId, deliveredToInbox: true };
}
async listAvailableTargets(excludeTeam?: string): Promise<CrossTeamTarget[]> {
const teamsDir = getTeamsBasePath();
let entries: string[];
try {
entries = await fs.promises.readdir(teamsDir);
} catch {
return [];
}
const targets: CrossTeamTarget[] = [];
for (const entry of entries) {
if (excludeTeam && entry === excludeTeam) continue;
if (!TEAM_NAME_PATTERN.test(entry)) continue;
let config: TeamConfig | null;
try {
config = await this.configReader.getConfig(entry);
} catch {
continue;
}
if (!config || config.deletedAt) continue;
const lead = config.members?.find((m) => m.role === 'lead' || m.name === 'team-lead');
targets.push({
teamName: entry,
displayName: config.name || entry,
description: config.description,
color: config.color,
leadName: lead?.name,
leadColor: lead?.color,
isOnline: this.provisioning?.isTeamAlive(entry) ?? false,
});
}
return targets.sort((a, b) => {
if (a.isOnline && !b.isOnline) return -1;
if (!a.isOnline && b.isOnline) return 1;
return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' });
});
}
async getOutbox(teamName: string): Promise<CrossTeamMessage[]> {
return this.outbox.read(teamName);
}
}