- 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.
220 lines
7.3 KiB
TypeScript
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);
|
|
}
|
|
}
|