agent-ecosystem/agent-teams-controller/src/internal/crossTeam.js
iliya 5da9e2372d feat: enhance cross-team messaging and message storage
- Introduced new parameters for cross-team messaging, including CROSS_TEAM_SENT_SOURCE for better tracking of sent messages.
- Updated sendCrossTeamMessage function to append sent messages to the message store, ensuring a complete history of communications.
- Enhanced tests to validate the new message storage functionality and ensure accurate retrieval of sent messages.
- Improved handling of message timestamps and deduplication logic for cross-team communications.
2026-03-11 00:33:17 +02:00

319 lines
10 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { createControllerContext } = require('./context.js');
const { withFileLockSync } = require('./fileLock.js');
const messageStore = require('./messageStore.js');
const cascadeGuard = require('./cascadeGuard.js');
const runtimeHelpers = require('./runtimeHelpers.js');
const {
formatCrossTeamText,
CROSS_TEAM_SOURCE,
CROSS_TEAM_SENT_SOURCE,
} = require('./crossTeamProtocol.js');
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_DEDUPE_WINDOW_MS = 5 * 60 * 1000;
function readJson(filePath, fallbackValue) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
if (error && error.code === 'ENOENT') return fallbackValue;
throw error;
}
}
function writeJson(filePath, value) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2));
fs.renameSync(tempPath, filePath);
}
function normalizeMetaMembers(rawMembers) {
if (!Array.isArray(rawMembers)) return [];
const deduped = new Map();
for (const m of rawMembers) {
if (!m || typeof m !== 'object') continue;
const name = typeof m.name === 'string' ? m.name.trim() : '';
if (!name) continue;
deduped.set(name, {
name,
agentType: typeof m.agentType === 'string' ? m.agentType.trim() || undefined : undefined,
role: typeof m.role === 'string' ? m.role.trim() || undefined : undefined,
});
}
return Array.from(deduped.values());
}
function resolveTargetLead(paths, config) {
// 1. config.members — agentType check
if (config && config.members && config.members.length) {
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
if (lead && lead.name) return String(lead.name).trim();
// 2. config.members — name check
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
if (namedLead && namedLead.name) return String(namedLead.name).trim();
}
// 3. members.meta.json — WITH normalization (trim + dedup)
const metaPath = path.join(paths.teamDir, 'members.meta.json');
try {
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
const members = normalizeMetaMembers(raw && raw.members);
if (members.length > 0) {
const metaLead = members.find(
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
if (metaLead && metaLead.name) return metaLead.name;
return members[0].name;
}
} catch {
/* ENOENT or parse error */
}
// 4. role-based (legacy compat)
if (config && config.members && config.members.length) {
const roleLead = config.members.find(
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
);
if (roleLead && roleLead.name) return String(roleLead.name).trim();
// 5. First member
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
}
return 'team-lead';
}
function createTargetContext(sourceContext, toTeam) {
return createControllerContext({
teamName: toTeam,
claudeDir: sourceContext.claudeDir,
});
}
function normalizeForDedupe(value) {
return String(value || '')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase();
}
function buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary) {
return [
normalizeForDedupe(fromTeam),
normalizeForDedupe(fromMember),
normalizeForDedupe(toTeam),
normalizeForDedupe(summary),
normalizeForDedupe(text),
].join('||');
}
function getCrossTeamMessageDedupeKey(message) {
if (!message || typeof message !== 'object') return '';
return buildCrossTeamDedupeKey(
message.fromTeam,
message.fromMember,
message.toTeam,
message.text,
message.summary
);
}
function findRecentDuplicate(outboxList, dedupeKey) {
if (!Array.isArray(outboxList) || !dedupeKey) return null;
const cutoff = Date.now() - CROSS_TEAM_DEDUPE_WINDOW_MS;
for (let i = outboxList.length - 1; i >= 0; i -= 1) {
const entry = outboxList[i];
const ts = Date.parse(entry && entry.timestamp ? entry.timestamp : '');
if (!Number.isFinite(ts) || ts < cutoff) {
break;
}
if (getCrossTeamMessageDedupeKey(entry) === dedupeKey) {
return entry;
}
}
return null;
}
function sendCrossTeamMessage(context, flags) {
const fromTeam = context.teamName;
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
const replyToConversationId =
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
const conversationId =
typeof flags.conversationId === 'string' && flags.conversationId.trim()
? flags.conversationId.trim()
: replyToConversationId || '';
const text = typeof flags.text === 'string' ? flags.text : '';
const summary = typeof flags.summary === 'string' ? flags.summary.trim() : undefined;
const chainDepth = typeof flags.chainDepth === 'number' ? flags.chainDepth : 0;
// 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 (!text || text.trim().length === 0) {
throw new Error('Message text is required');
}
// Target context + config
const targetContext = createTargetContext(context, toTeam);
const targetConfig = runtimeHelpers.readTeamConfig(targetContext.paths);
if (!targetConfig || targetConfig.deletedAt) {
throw new Error(`Target team not found: ${toTeam}`);
}
// Resolve lead
const leadName = resolveTargetLead(targetContext.paths, targetConfig);
// Format
const from = `${fromTeam}.${fromMember}`;
const resolvedConversationId =
conversationId || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
const formattedText = formatCrossTeamText(from, chainDepth, text, {
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
});
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
const timestamp = new Date().toISOString();
const dedupeKey = buildCrossTeamDedupeKey(fromTeam, fromMember, toTeam, text, summary);
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
let duplicate = null;
withFileLockSync(outboxPath, () => {
const outbox = readJson(outboxPath, []);
const outList = Array.isArray(outbox) ? outbox : [];
duplicate = findRecentDuplicate(outList, dedupeKey);
if (duplicate) {
return;
}
// Cascade check only for real new deliveries.
cascadeGuard.check(fromTeam, toTeam, chainDepth);
cascadeGuard.record(fromTeam, toTeam);
// Cross-process safe inbox write
withFileLockSync(inboxPath, () => {
fs.mkdirSync(path.dirname(inboxPath), { recursive: true });
const current = readJson(inboxPath, []);
const list = Array.isArray(current) ? current : [];
list.push({
from,
to: leadName,
text: formattedText,
timestamp,
read: false,
summary: summary || `Cross-team message from ${fromTeam}`,
messageId,
source: CROSS_TEAM_SOURCE,
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
});
writeJson(inboxPath, list);
});
// Verify while still inside dedupe lock so duplicate callers
// cannot append the same request to outbox concurrently.
const inbox = readJson(inboxPath, []);
if (!inbox.some((m) => m.messageId === messageId)) {
throw new Error('Cross-team inbox write verification failed');
}
messageStore.appendSentMessage(context.paths, {
from: fromMember,
to: `${toTeam}.${leadName}`,
text,
timestamp,
messageId,
summary: summary || `Cross-team message to ${toTeam}`,
source: CROSS_TEAM_SENT_SOURCE,
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
});
outList.push({
messageId,
fromTeam,
fromMember,
toTeam,
conversationId: resolvedConversationId,
replyToConversationId: replyToConversationId || undefined,
text,
summary,
chainDepth,
timestamp,
});
writeJson(outboxPath, outList);
});
if (duplicate) {
return {
messageId: duplicate.messageId,
deliveredToInbox: true,
deduplicated: true,
};
}
return { messageId, deliveredToInbox: true };
}
function listCrossTeamTargets(context, flags) {
const excludeTeam =
typeof flags === 'object' && flags && typeof flags.excludeTeam === 'string'
? flags.excludeTeam
: context.teamName;
const teamsDir = path.dirname(context.paths.teamDir);
let entries;
try {
entries = fs.readdirSync(teamsDir);
} catch {
return [];
}
const targets = [];
for (const entry of entries) {
if (entry === excludeTeam) continue;
if (!TEAM_NAME_PATTERN.test(entry)) continue;
const entryTeamDir = path.join(teamsDir, entry);
const configPath = path.join(entryTeamDir, 'config.json');
let config;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch {
continue;
}
if (!config || config.deletedAt) continue;
targets.push({
teamName: entry,
displayName: config.name || entry,
description: config.description || undefined,
});
}
return targets;
}
function getCrossTeamOutbox(context) {
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
return readJson(outboxPath, []);
}
module.exports = {
sendCrossTeamMessage,
listCrossTeamTargets,
getCrossTeamOutbox,
};