feat: add cross-team communication orchestrator
Autonomous message routing between Agent Teams via MCP with cascade protection. Inbox-first canonical delivery with cross-process file lock (O_CREAT|O_EXCL) and best-effort relay for online teams. - CascadeGuard: rate limit (10/min), chain depth (max 5), pair cooldown (3s) - FileLock: cross-process safe via kernel-level atomic lock files - CrossTeamService: validate → cascade → file-lock → inbox write → relay → outbox - Unified lead resolver with members.meta.json normalization (trim+dedup) - 3 MCP tools: cross_team_send, cross_team_list_targets, cross_team_get_outbox - Controller module with sync file lock, same protocol as main - IPC adapter with 3 Electron handlers - 64 new tests across 8 test files
This commit is contained in:
parent
afb6e2794f
commit
c3eea4d6eb
31 changed files with 1992 additions and 46 deletions
|
|
@ -5,6 +5,7 @@ const review = require('./internal/review.js');
|
|||
const messages = require('./internal/messages.js');
|
||||
const processes = require('./internal/processes.js');
|
||||
const maintenance = require('./internal/maintenance.js');
|
||||
const crossTeam = require('./internal/crossTeam.js');
|
||||
|
||||
function bindModule(context, moduleApi) {
|
||||
return Object.fromEntries(
|
||||
|
|
@ -26,6 +27,7 @@ function createController(options) {
|
|||
messages: bindModule(context, messages),
|
||||
processes: bindModule(context, processes),
|
||||
maintenance: bindModule(context, maintenance),
|
||||
crossTeam: bindModule(context, crossTeam),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -38,4 +40,5 @@ module.exports = {
|
|||
messages,
|
||||
processes,
|
||||
maintenance,
|
||||
crossTeam,
|
||||
};
|
||||
|
|
|
|||
59
agent-teams-controller/src/internal/cascadeGuard.js
Normal file
59
agent-teams-controller/src/internal/cascadeGuard.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const MAX_PER_MINUTE = 10;
|
||||
const PAIR_COOLDOWN_MS = 3000;
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const WINDOW_MS = 60000;
|
||||
|
||||
const teamCounters = new Map();
|
||||
const pairTimestamps = new Map();
|
||||
|
||||
function cleanup(now) {
|
||||
for (const [team, timestamps] of teamCounters) {
|
||||
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
|
||||
if (fresh.length === 0) {
|
||||
teamCounters.delete(team);
|
||||
} else {
|
||||
teamCounters.set(team, fresh);
|
||||
}
|
||||
}
|
||||
for (const [key, ts] of pairTimestamps) {
|
||||
if (now - ts > WINDOW_MS) {
|
||||
pairTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function check(fromTeam, toTeam, chainDepth) {
|
||||
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
||||
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
cleanup(now);
|
||||
|
||||
const counts = teamCounters.get(fromTeam) || [];
|
||||
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
|
||||
if (recentCount >= MAX_PER_MINUTE) {
|
||||
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
|
||||
}
|
||||
|
||||
const pairKey = `${fromTeam}\u2192${toTeam}`;
|
||||
const lastPairTs = pairTimestamps.get(pairKey);
|
||||
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
|
||||
throw new Error(`Cross-team pair cooldown active: ${fromTeam} \u2192 ${toTeam}`);
|
||||
}
|
||||
}
|
||||
|
||||
function record(fromTeam, toTeam) {
|
||||
const now = Date.now();
|
||||
const counts = teamCounters.get(fromTeam) || [];
|
||||
counts.push(now);
|
||||
teamCounters.set(fromTeam, counts);
|
||||
pairTimestamps.set(`${fromTeam}\u2192${toTeam}`, now);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
teamCounters.clear();
|
||||
pairTimestamps.clear();
|
||||
}
|
||||
|
||||
module.exports = { check, record, reset };
|
||||
225
agent-teams-controller/src/internal/crossTeam.js
Normal file
225
agent-teams-controller/src/internal/crossTeam.js
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { createControllerContext } = require('./context.js');
|
||||
const { withFileLockSync } = require('./fileLock.js');
|
||||
const cascadeGuard = require('./cascadeGuard.js');
|
||||
const runtimeHelpers = require('./runtimeHelpers.js');
|
||||
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
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 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 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);
|
||||
|
||||
// Cascade check
|
||||
cascadeGuard.check(fromTeam, toTeam, chainDepth);
|
||||
cascadeGuard.record(fromTeam, toTeam);
|
||||
|
||||
// Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`;
|
||||
const messageId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
||||
|
||||
// Cross-process safe inbox write
|
||||
const inboxPath = path.join(targetContext.paths.teamDir, 'inboxes', `${leadName}.json`);
|
||||
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: new Date().toISOString(),
|
||||
read: false,
|
||||
summary: summary || `Cross-team message from ${fromTeam}`,
|
||||
messageId,
|
||||
source: 'cross_team',
|
||||
});
|
||||
writeJson(inboxPath, list);
|
||||
});
|
||||
|
||||
// Verify
|
||||
const inbox = readJson(inboxPath, []);
|
||||
if (!inbox.some((m) => m.messageId === messageId)) {
|
||||
throw new Error('Cross-team inbox write verification failed');
|
||||
}
|
||||
|
||||
// Record outbox (with file lock)
|
||||
const outboxPath = path.join(context.paths.teamDir, 'sent-cross-team.json');
|
||||
withFileLockSync(outboxPath, () => {
|
||||
const outbox = readJson(outboxPath, []);
|
||||
const outList = Array.isArray(outbox) ? outbox : [];
|
||||
outList.push({
|
||||
messageId,
|
||||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
writeJson(outboxPath, outList);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
81
agent-teams-controller/src/internal/fileLock.js
Normal file
81
agent-teams-controller/src/internal/fileLock.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const STALE_TIMEOUT_MS = 30000;
|
||||
const ACQUIRE_TIMEOUT_MS = 5000;
|
||||
const SPIN_INTERVAL_MS = 20;
|
||||
|
||||
function readLockAge(lockPath) {
|
||||
try {
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const ts = parseInt(content.split('\n')[1] || '', 10);
|
||||
if (Number.isFinite(ts)) return Date.now() - ts;
|
||||
} catch {
|
||||
/* lock may have been released concurrently */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryAcquire(lockPath) {
|
||||
try {
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
||||
fs.closeSync(fd);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err && err.code === 'EEXIST') {
|
||||
const age = readLockAge(lockPath);
|
||||
if (age !== null && age > STALE_TIMEOUT_MS) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* another process may have cleaned it */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLock(lockPath) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already released or cleaned up */
|
||||
}
|
||||
}
|
||||
|
||||
function spinWait(deadlineMs) {
|
||||
while (Date.now() < deadlineMs) {
|
||||
const waitUntil = Date.now() + SPIN_INTERVAL_MS;
|
||||
while (Date.now() < waitUntil) {
|
||||
/* busy-wait — intentionally synchronous */
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function withFileLockSync(filePath, fn) {
|
||||
const lockPath = `${filePath}.lock`;
|
||||
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
|
||||
|
||||
while (!tryAcquire(lockPath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`File lock timeout: ${filePath}`);
|
||||
}
|
||||
spinWait(Math.min(Date.now() + SPIN_INTERVAL_MS, deadline));
|
||||
}
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { withFileLockSync };
|
||||
58
agent-teams-controller/test/cascadeGuard.test.js
Normal file
58
agent-teams-controller/test/cascadeGuard.test.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
|
||||
describe('cascadeGuard', () => {
|
||||
beforeEach(() => {
|
||||
cascadeGuard.reset();
|
||||
});
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('allows up to 10 messages per minute', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
cascadeGuard.check('team-a', `team-${i}`, 0);
|
||||
cascadeGuard.record('team-a', `team-${i}`);
|
||||
}
|
||||
expect(() => cascadeGuard.check('team-a', 'team-x', 0)).toThrow('rate limit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chain depth', () => {
|
||||
it('allows depth 0 through 4', () => {
|
||||
for (let d = 0; d < 5; d++) {
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', d)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects depth >= 5', () => {
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', 5)).toThrow('chain depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pair cooldown', () => {
|
||||
it('rejects same pair within 3s', () => {
|
||||
cascadeGuard.check('team-a', 'team-b', 0);
|
||||
cascadeGuard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-b', 0)).toThrow('cooldown');
|
||||
});
|
||||
|
||||
it('allows different pairs simultaneously', () => {
|
||||
cascadeGuard.check('team-a', 'team-b', 0);
|
||||
cascadeGuard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-c', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears all state', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
cascadeGuard.check('team-a', `team-${i}`, 0);
|
||||
cascadeGuard.record('team-a', `team-${i}`);
|
||||
}
|
||||
|
||||
cascadeGuard.reset();
|
||||
|
||||
expect(() => cascadeGuard.check('team-a', 'team-0', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
277
agent-teams-controller/test/crossTeam.test.js
Normal file
277
agent-teams-controller/test/crossTeam.test.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { createController } = require('../src/index.js');
|
||||
|
||||
describe('crossTeam module', () => {
|
||||
function makeClaudeDir(teams = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
||||
|
||||
for (const [teamName, config] of Object.entries(teams)) {
|
||||
const teamDir = path.join(dir, 'teams', teamName);
|
||||
const taskDir = path.join(dir, 'tasks', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(teamDir, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Reset cascade guard between tests
|
||||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
cascadeGuard.reset();
|
||||
});
|
||||
|
||||
describe('sendCrossTeamMessage', () => {
|
||||
it('delivers message to target team inbox', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const result = controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'lead',
|
||||
text: 'Hello from team-a',
|
||||
summary: 'Test message',
|
||||
});
|
||||
|
||||
expect(result.deliveredToInbox).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
|
||||
// Verify inbox was written
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe('cross_team');
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain('[Cross-team from team-a.lead | depth:0]');
|
||||
});
|
||||
|
||||
it('records outbox entry', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toHaveLength(1);
|
||||
expect(outbox[0].toTeam).toBe('team-b');
|
||||
});
|
||||
|
||||
it('rejects self-send', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-a',
|
||||
text: 'Self',
|
||||
})
|
||||
).toThrow('same team');
|
||||
});
|
||||
|
||||
it('rejects when target not found', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-nonexistent',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects when target is deleted', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
deletedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects excessive chain depth', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
chainDepth: 5,
|
||||
})
|
||||
).toThrow('chain depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTargetLead', () => {
|
||||
it('resolves lead by agentType', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'alpha-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'beta-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'beta-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves lead by name fallback', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves lead from members.meta.json with normalization', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Write meta with dirty data (leading spaces, duplicates)
|
||||
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
members: [
|
||||
{ name: ' meta-lead ', agentType: 'team-lead' },
|
||||
{ name: ' meta-lead ', agentType: 'team-lead' },
|
||||
{ name: 'worker', agentType: 'worker' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'meta-lead.json');
|
||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listCrossTeamTargets', () => {
|
||||
it('lists valid teams excluding current', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': { name: 'Team A' },
|
||||
'team-b': { name: 'Team B', description: 'B desc' },
|
||||
'team-c': { name: 'Team C', deletedAt: '2024-01-01' },
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const targets = controller.crossTeam.listCrossTeamTargets();
|
||||
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].teamName).toBe('team-b');
|
||||
expect(targets[0].displayName).toBe('Team B');
|
||||
expect(targets[0].description).toBe('B desc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCrossTeamOutbox', () => {
|
||||
it('returns empty for non-existent outbox', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': { name: 'Team A' },
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
const outbox = controller.crossTeam.getCrossTeamOutbox();
|
||||
expect(outbox).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
mcp-server/src/agent-teams-controller.d.ts
vendored
7
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -60,6 +60,12 @@ declare module 'agent-teams-controller' {
|
|||
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerCrossTeamApi {
|
||||
sendCrossTeamMessage(flags: Record<string, unknown>): unknown;
|
||||
listCrossTeamTargets(flags?: Record<string, unknown>): unknown;
|
||||
getCrossTeamOutbox(): unknown;
|
||||
}
|
||||
|
||||
export interface AgentTeamsController {
|
||||
tasks: ControllerTaskApi;
|
||||
kanban: ControllerKanbanApi;
|
||||
|
|
@ -67,6 +73,7 @@ declare module 'agent-teams-controller' {
|
|||
messages: ControllerMessageApi;
|
||||
processes: ControllerProcessApi;
|
||||
maintenance: ControllerMaintenanceApi;
|
||||
crossTeam: ControllerCrossTeamApi;
|
||||
}
|
||||
|
||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||
|
|
|
|||
67
mcp-server/src/tools/crossTeamTools.ts
Normal file
67
mcp-server/src/tools/crossTeamTools.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'cross_team_send',
|
||||
description:
|
||||
'Send a message to another team. The message is delivered to the target team lead inbox.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
toTeam: z.string().min(1),
|
||||
text: z.string().min(1),
|
||||
fromMember: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
chainDepth: z.number().int().nonnegative().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, toTeam, text, fromMember, summary, chainDepth }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
toTeam,
|
||||
text,
|
||||
...(fromMember ? { fromMember } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'cross_team_list_targets',
|
||||
description: 'List available teams that can receive cross-team messages.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
excludeTeam: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||
...(excludeTeam ? { excludeTeam } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'cross_team_get_outbox',
|
||||
description: 'Get sent cross-team messages for the current team.',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||
),
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
|
||||
import { registerCrossTeamTools } from './crossTeamTools';
|
||||
import { registerKanbanTools } from './kanbanTools';
|
||||
import { registerMessageTools } from './messageTools';
|
||||
import { registerProcessTools } from './processTools';
|
||||
|
|
@ -12,4 +13,5 @@ export function registerTools(server: FastMCP) {
|
|||
registerReviewTools(server);
|
||||
registerMessageTools(server);
|
||||
registerProcessTools(server);
|
||||
registerCrossTeamTools(server);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ function parseJsonToolResult(result: unknown) {
|
|||
describe('agent-teams-mcp tools', () => {
|
||||
const tools = collectTools();
|
||||
const expectedToolNames = [
|
||||
'cross_team_get_outbox',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_send',
|
||||
'kanban_add_reviewer',
|
||||
'kanban_clear',
|
||||
'kanban_get',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@
|
|||
// On Windows this saturates all threads, blocking the event loop.
|
||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
|
||||
import { FileContentResolver } from '@main/services/team/FileContentResolver';
|
||||
import { GitDiffFallback } from '@main/services/team/GitDiffFallback';
|
||||
|
|
@ -663,6 +666,17 @@ function initializeServices(): void {
|
|||
ptyTerminalService = new PtyTerminalService();
|
||||
teamDataService = new TeamDataService();
|
||||
teamProvisioningService = new TeamProvisioningService();
|
||||
|
||||
// Cross-team communication service
|
||||
const crossTeamConfigReader = new TeamConfigReader();
|
||||
const crossTeamInboxWriter = new TeamInboxWriter();
|
||||
const crossTeamService = new CrossTeamService(
|
||||
crossTeamConfigReader,
|
||||
teamDataService,
|
||||
crossTeamInboxWriter,
|
||||
teamProvisioningService
|
||||
);
|
||||
|
||||
const teamMemberLogsFinder = new TeamMemberLogsFinder();
|
||||
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
|
||||
const taskBoundaryParser = new TaskBoundaryParser();
|
||||
|
|
@ -760,7 +774,8 @@ function initializeServices(): void {
|
|||
schedulerService,
|
||||
extensionFacadeService,
|
||||
pluginInstallService,
|
||||
mcpInstallService
|
||||
mcpInstallService,
|
||||
crossTeamService
|
||||
);
|
||||
|
||||
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||
|
|
|
|||
93
src/main/ipc/crossTeam.ts
Normal file
93
src/main/ipc/crossTeam.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import {
|
||||
CROSS_TEAM_GET_OUTBOX,
|
||||
CROSS_TEAM_LIST_TARGETS,
|
||||
CROSS_TEAM_SEND,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import type { IpcResult } from '@shared/types';
|
||||
|
||||
const logger = createLogger('IPC:crossTeam');
|
||||
|
||||
let crossTeamService: CrossTeamService | null = null;
|
||||
|
||||
export function initializeCrossTeamHandlers(service: CrossTeamService): void {
|
||||
crossTeamService = service;
|
||||
}
|
||||
|
||||
function getService(): CrossTeamService {
|
||||
if (!crossTeamService) {
|
||||
throw new Error('CrossTeamService not initialized');
|
||||
}
|
||||
return crossTeamService;
|
||||
}
|
||||
|
||||
async function wrapCrossTeamHandler<T>(
|
||||
operation: string,
|
||||
handler: () => Promise<T>
|
||||
): Promise<IpcResult<T>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[crossTeam:${operation}] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
_event: IpcMainInvokeEvent,
|
||||
request: unknown
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('send', () => {
|
||||
if (!request || typeof request !== 'object') {
|
||||
throw new Error('Invalid request');
|
||||
}
|
||||
const req = request as Record<string, unknown>;
|
||||
return getService().send({
|
||||
fromTeam: String(req.fromTeam ?? ''),
|
||||
fromMember: String(req.fromMember ?? ''),
|
||||
toTeam: String(req.toTeam ?? ''),
|
||||
text: String(req.text ?? ''),
|
||||
summary: typeof req.summary === 'string' ? req.summary : undefined,
|
||||
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleListTargets(
|
||||
_event: IpcMainInvokeEvent,
|
||||
excludeTeam?: string
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('listTargets', () =>
|
||||
getService().listAvailableTargets(typeof excludeTeam === 'string' ? excludeTeam : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGetOutbox(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: string
|
||||
): Promise<IpcResult<unknown>> {
|
||||
return wrapCrossTeamHandler('getOutbox', () => {
|
||||
if (typeof teamName !== 'string' || !teamName.trim()) {
|
||||
throw new Error('teamName is required');
|
||||
}
|
||||
return getService().getOutbox(teamName);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerCrossTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.handle(CROSS_TEAM_SEND, handleSend);
|
||||
ipcMain.handle(CROSS_TEAM_LIST_TARGETS, handleListTargets);
|
||||
ipcMain.handle(CROSS_TEAM_GET_OUTBOX, handleGetOutbox);
|
||||
}
|
||||
|
||||
export function removeCrossTeamHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(CROSS_TEAM_SEND);
|
||||
ipcMain.removeHandler(CROSS_TEAM_LIST_TARGETS);
|
||||
ipcMain.removeHandler(CROSS_TEAM_GET_OUTBOX);
|
||||
}
|
||||
|
|
@ -28,6 +28,11 @@ import {
|
|||
registerContextHandlers,
|
||||
removeContextHandlers,
|
||||
} from './context';
|
||||
import {
|
||||
initializeCrossTeamHandlers,
|
||||
registerCrossTeamHandlers,
|
||||
removeCrossTeamHandlers,
|
||||
} from './crossTeam';
|
||||
import { initializeEditorHandlers, registerEditorHandlers, removeEditorHandlers } from './editor';
|
||||
import {
|
||||
initializeExtensionHandlers,
|
||||
|
|
@ -98,6 +103,7 @@ import type {
|
|||
UpdaterService,
|
||||
} from '../services';
|
||||
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
|
|
@ -132,7 +138,8 @@ export function initializeIpcHandlers(
|
|||
schedulerService?: SchedulerService,
|
||||
extensionFacade?: ExtensionFacadeService,
|
||||
pluginInstaller?: PluginInstallService,
|
||||
mcpInstaller?: McpInstallService
|
||||
mcpInstaller?: McpInstallService,
|
||||
crossTeamService?: CrossTeamService
|
||||
): void {
|
||||
// Initialize domain handlers with registry
|
||||
initializeProjectHandlers(registry);
|
||||
|
|
@ -170,6 +177,9 @@ export function initializeIpcHandlers(
|
|||
if (extensionFacade) {
|
||||
initializeExtensionHandlers(extensionFacade, pluginInstaller, mcpInstaller);
|
||||
}
|
||||
if (crossTeamService) {
|
||||
initializeCrossTeamHandlers(crossTeamService);
|
||||
}
|
||||
|
||||
if (changeExtractor) {
|
||||
initializeReviewHandlers({
|
||||
|
|
@ -210,6 +220,9 @@ export function initializeIpcHandlers(
|
|||
if (extensionFacade) {
|
||||
registerExtensionHandlers(ipcMain);
|
||||
}
|
||||
if (crossTeamService) {
|
||||
registerCrossTeamHandlers(ipcMain);
|
||||
}
|
||||
|
||||
logger.info('All handlers registered');
|
||||
}
|
||||
|
|
@ -240,6 +253,7 @@ export function removeIpcHandlers(): void {
|
|||
removeTerminalHandlers(ipcMain);
|
||||
removeHttpServerHandlers(ipcMain);
|
||||
removeExtensionHandlers(ipcMain);
|
||||
removeCrossTeamHandlers(ipcMain);
|
||||
|
||||
logger.info('All handlers removed');
|
||||
}
|
||||
|
|
|
|||
59
src/main/services/team/CascadeGuard.ts
Normal file
59
src/main/services/team/CascadeGuard.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
const MAX_PER_MINUTE = 10;
|
||||
const PAIR_COOLDOWN_MS = 3_000;
|
||||
const MAX_CHAIN_DEPTH = 5;
|
||||
const WINDOW_MS = 60_000;
|
||||
|
||||
export class CascadeGuard {
|
||||
private teamCounters = new Map<string, number[]>();
|
||||
private pairTimestamps = new Map<string, number>();
|
||||
|
||||
check(fromTeam: string, toTeam: string, chainDepth: number): void {
|
||||
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
||||
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.cleanup(now);
|
||||
|
||||
const counts = this.teamCounters.get(fromTeam) ?? [];
|
||||
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
|
||||
if (recentCount >= MAX_PER_MINUTE) {
|
||||
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
|
||||
}
|
||||
|
||||
const pairKey = `${fromTeam}→${toTeam}`;
|
||||
const lastPairTs = this.pairTimestamps.get(pairKey);
|
||||
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
|
||||
throw new Error(`Cross-team pair cooldown active: ${fromTeam} → ${toTeam}`);
|
||||
}
|
||||
}
|
||||
|
||||
record(fromTeam: string, toTeam: string): void {
|
||||
const now = Date.now();
|
||||
const counts = this.teamCounters.get(fromTeam) ?? [];
|
||||
counts.push(now);
|
||||
this.teamCounters.set(fromTeam, counts);
|
||||
this.pairTimestamps.set(`${fromTeam}→${toTeam}`, now);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.teamCounters.clear();
|
||||
this.pairTimestamps.clear();
|
||||
}
|
||||
|
||||
private cleanup(now: number): void {
|
||||
for (const [team, timestamps] of this.teamCounters) {
|
||||
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
|
||||
if (fresh.length === 0) {
|
||||
this.teamCounters.delete(team);
|
||||
} else {
|
||||
this.teamCounters.set(team, fresh);
|
||||
}
|
||||
}
|
||||
for (const [key, ts] of this.pairTimestamps) {
|
||||
if (now - ts > WINDOW_MS) {
|
||||
this.pairTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/main/services/team/CrossTeamOutbox.ts
Normal file
45
src/main/services/team/CrossTeamOutbox.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { withFileLock } from './fileLock';
|
||||
|
||||
import type { CrossTeamMessage } from '@shared/types';
|
||||
|
||||
export class CrossTeamOutbox {
|
||||
private getOutboxPath(teamName: string): string {
|
||||
return path.join(getTeamsBasePath(), teamName, 'sent-cross-team.json');
|
||||
}
|
||||
|
||||
async append(teamName: string, message: CrossTeamMessage): Promise<void> {
|
||||
const outboxPath = this.getOutboxPath(teamName);
|
||||
await withFileLock(outboxPath, async () => {
|
||||
let list: CrossTeamMessage[] = [];
|
||||
try {
|
||||
const raw = await fs.promises.readFile(outboxPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
list = parsed as CrossTeamMessage[];
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
list.push(message);
|
||||
const dir = path.dirname(outboxPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(outboxPath, JSON.stringify(list, null, 2), 'utf8');
|
||||
});
|
||||
}
|
||||
|
||||
async read(teamName: string): Promise<CrossTeamMessage[]> {
|
||||
const outboxPath = this.getOutboxPath(teamName);
|
||||
try {
|
||||
const raw = await fs.promises.readFile(outboxPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as CrossTeamMessage[]) : [];
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
147
src/main/services/team/CrossTeamService.ts
Normal file
147
src/main/services/team/CrossTeamService.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
||||
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 TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
|
||||
export interface CrossTeamTarget {
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
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, summary } = request;
|
||||
const chainDepth = request.chainDepth ?? 0;
|
||||
|
||||
// 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 (!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. Cascade check
|
||||
this.cascadeGuard.check(fromTeam, toTeam, chainDepth);
|
||||
this.cascadeGuard.record(fromTeam, toTeam);
|
||||
|
||||
// 4. Format
|
||||
const from = `${fromTeam}.${fromMember}`;
|
||||
const formattedText = `[Cross-team from ${from} | depth:${chainDepth}]\n${text}`;
|
||||
const messageId = randomUUID();
|
||||
|
||||
// 5. Inbox write (TeamInboxWriter handles file lock + in-process lock internally)
|
||||
await this.inboxWriter.sendMessage(toTeam, {
|
||||
member: leadName,
|
||||
text: formattedText,
|
||||
from,
|
||||
summary: summary ?? `Cross-team message from ${fromTeam}`,
|
||||
source: 'cross_team',
|
||||
});
|
||||
|
||||
// 6. 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)}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Record outbox
|
||||
const outboxMessage: CrossTeamMessage = {
|
||||
messageId,
|
||||
fromTeam,
|
||||
fromMember,
|
||||
toTeam,
|
||||
text,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
void this.outbox.append(fromTeam, outboxMessage).catch((e: unknown) => {
|
||||
logger.warn(
|
||||
`Failed to write outbox for ${fromTeam}: ${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;
|
||||
|
||||
targets.push({
|
||||
teamName: entry,
|
||||
displayName: config.name || entry,
|
||||
description: config.description,
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
async getOutbox(teamName: string): Promise<CrossTeamMessage[]> {
|
||||
return this.outbox.read(teamName);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { withFileLock } from './fileLock';
|
||||
import { withInboxLock } from './inboxLock';
|
||||
|
||||
import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types';
|
||||
|
|
@ -33,18 +34,20 @@ export class TeamInboxWriter {
|
|||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||
};
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const list = await this.readInbox(inboxPath);
|
||||
list.push(payload);
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
||||
const written = await this.readInbox(inboxPath);
|
||||
if (written.some((msg) => msg.messageId === messageId)) {
|
||||
return;
|
||||
await withFileLock(inboxPath, async () => {
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const list = await this.readInbox(inboxPath);
|
||||
list.push(payload);
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
||||
const written = await this.readInbox(inboxPath);
|
||||
if (written.some((msg) => msg.messageId === messageId)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt));
|
||||
}
|
||||
throw new Error('Failed to verify inbox write');
|
||||
throw new Error('Failed to verify inbox write');
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
import { withFileLock } from './fileLock';
|
||||
import { withInboxLock } from './inboxLock';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
|
|
@ -2858,40 +2859,42 @@ export class TeamProvisioningService {
|
|||
): Promise<void> {
|
||||
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
const raw = await tryReadRegularFileUtf8(inboxPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_INBOX_MAX_BYTES,
|
||||
});
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return;
|
||||
|
||||
const ids = new Set(messages.map((m) => m.messageId).filter((id) => id.trim().length > 0));
|
||||
|
||||
let changed = false;
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
|
||||
if (!msgId || !ids.has(msgId)) continue;
|
||||
|
||||
if (row.read !== true) {
|
||||
row.read = true;
|
||||
changed = true;
|
||||
await withFileLock(inboxPath, async () => {
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
const raw = await tryReadRegularFileUtf8(inboxPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_INBOX_MAX_BYTES,
|
||||
});
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) return;
|
||||
|
||||
const ids = new Set(messages.map((m) => m.messageId).filter((id) => id.trim().length > 0));
|
||||
|
||||
let changed = false;
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
|
||||
if (!msgId || !ids.has(msgId)) continue;
|
||||
|
||||
if (row.read !== true) {
|
||||
row.read = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
69
src/main/services/team/fileLock.ts
Normal file
69
src/main/services/team/fileLock.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const STALE_TIMEOUT_MS = 30_000;
|
||||
const ACQUIRE_TIMEOUT_MS = 5_000;
|
||||
const RETRY_INTERVAL_MS = 20;
|
||||
|
||||
function readLockAge(lockPath: string): number | null {
|
||||
try {
|
||||
const content = fs.readFileSync(lockPath, 'utf8');
|
||||
const ts = parseInt(content.split('\n')[1] ?? '', 10);
|
||||
if (Number.isFinite(ts)) return Date.now() - ts;
|
||||
} catch {
|
||||
/* lock may have been released concurrently */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryAcquire(lockPath: string): boolean {
|
||||
try {
|
||||
const dir = path.dirname(lockPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const fd = fs.openSync(lockPath, 'wx');
|
||||
fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
|
||||
fs.closeSync(fd);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
const age = readLockAge(lockPath);
|
||||
if (age !== null && age > STALE_TIMEOUT_MS) {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* another process may have cleaned it */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseLock(lockPath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(lockPath);
|
||||
} catch {
|
||||
/* already released or cleaned up */
|
||||
}
|
||||
}
|
||||
|
||||
export async function withFileLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
const lockPath = `${filePath}.lock`;
|
||||
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
|
||||
|
||||
while (!tryAcquire(lockPath)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`File lock timeout: ${filePath}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
export { CascadeGuard } from './CascadeGuard';
|
||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
export { CrossTeamOutbox } from './CrossTeamOutbox';
|
||||
export { CrossTeamService } from './CrossTeamService';
|
||||
export { FileContentResolver } from './FileContentResolver';
|
||||
export { GitDiffFallback } from './GitDiffFallback';
|
||||
export { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
||||
|
|
|
|||
|
|
@ -370,6 +370,19 @@ export const TEAM_VALIDATE_CLI_ARGS = 'team:validateCliArgs';
|
|||
/** Invoke: update tool approval settings (renderer → main) */
|
||||
export const TEAM_TOOL_APPROVAL_SETTINGS = 'team:toolApprovalSettings';
|
||||
|
||||
// =============================================================================
|
||||
// Cross-Team Communication Channels
|
||||
// =============================================================================
|
||||
|
||||
/** Send cross-team message */
|
||||
export const CROSS_TEAM_SEND = 'crossTeam:send';
|
||||
|
||||
/** List available cross-team targets */
|
||||
export const CROSS_TEAM_LIST_TARGETS = 'crossTeam:listTargets';
|
||||
|
||||
/** Get cross-team outbox for a team */
|
||||
export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
|
||||
|
||||
// =============================================================================
|
||||
// CLI Installer API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import {
|
|||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
CONTEXT_SWITCH,
|
||||
CROSS_TEAM_GET_OUTBOX,
|
||||
CROSS_TEAM_LIST_TARGETS,
|
||||
CROSS_TEAM_SEND,
|
||||
EDITOR_CHANGE,
|
||||
EDITOR_CLOSE,
|
||||
EDITOR_CREATE_DIR,
|
||||
|
|
@ -198,6 +201,9 @@ import type {
|
|||
ContextInfo,
|
||||
CreateScheduleInput,
|
||||
CreateTaskRequest,
|
||||
CrossTeamMessage,
|
||||
CrossTeamSendRequest,
|
||||
CrossTeamSendResult,
|
||||
ElectronAPI,
|
||||
FileChangeWithContent,
|
||||
GlobalTask,
|
||||
|
|
@ -1054,6 +1060,20 @@ const electronAPI: ElectronAPI = {
|
|||
return invokeIpcWithResult<void>(TEAM_TOOL_APPROVAL_SETTINGS, settings);
|
||||
},
|
||||
},
|
||||
crossTeam: {
|
||||
send: async (request: CrossTeamSendRequest) => {
|
||||
return invokeIpcWithResult<CrossTeamSendResult>(CROSS_TEAM_SEND, request);
|
||||
},
|
||||
listTargets: async (excludeTeam?: string) => {
|
||||
return invokeIpcWithResult<{ teamName: string; displayName: string; description?: string }[]>(
|
||||
CROSS_TEAM_LIST_TARGETS,
|
||||
excludeTeam
|
||||
);
|
||||
},
|
||||
getOutbox: async (teamName: string) => {
|
||||
return invokeIpcWithResult<CrossTeamMessage[]>(CROSS_TEAM_GET_OUTBOX, teamName);
|
||||
},
|
||||
},
|
||||
review: {
|
||||
getAgentChanges: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<AgentChangeSet>(REVIEW_GET_AGENT_CHANGES, teamName, memberName);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
ContextInfo,
|
||||
ConversationGroup,
|
||||
CreateTaskRequest,
|
||||
CrossTeamAPI,
|
||||
ElectronAPI,
|
||||
FileChangeEvent,
|
||||
GlobalTask,
|
||||
|
|
@ -910,6 +911,21 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
},
|
||||
};
|
||||
|
||||
// Cross-team communication API stubs
|
||||
crossTeam: CrossTeamAPI = {
|
||||
send: async () => {
|
||||
throw new Error('Cross-team communication is not available in browser mode');
|
||||
},
|
||||
listTargets: async () => {
|
||||
console.warn('[HttpAPIClient] crossTeam.listTargets is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
getOutbox: async () => {
|
||||
console.warn('[HttpAPIClient] crossTeam.getOutbox is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
// Review API stubs
|
||||
review = {
|
||||
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,9 @@ import type {
|
|||
AttachmentFileData,
|
||||
CommentAttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
CrossTeamMessage,
|
||||
CrossTeamSendRequest,
|
||||
CrossTeamSendResult,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
|
|
@ -533,6 +536,18 @@ export interface TeamsAPI {
|
|||
updateToolApprovalSettings: (settings: ToolApprovalSettings) => Promise<void>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cross-Team Communication API
|
||||
// =============================================================================
|
||||
|
||||
export interface CrossTeamAPI {
|
||||
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
||||
listTargets: (
|
||||
excludeTeam?: string
|
||||
) => Promise<{ teamName: string; displayName: string; description?: string }[]>;
|
||||
getOutbox: (teamName: string) => Promise<CrossTeamMessage[]>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Schedule API
|
||||
// =============================================================================
|
||||
|
|
@ -761,6 +776,9 @@ export interface ElectronAPI {
|
|||
// Team management API
|
||||
teams: TeamsAPI;
|
||||
|
||||
// Cross-Team Communication API
|
||||
crossTeam: CrossTeamAPI;
|
||||
|
||||
// Review API
|
||||
review: ReviewAPI;
|
||||
|
||||
|
|
|
|||
|
|
@ -245,7 +245,13 @@ export interface InboxMessage {
|
|||
summary?: string;
|
||||
color?: string;
|
||||
messageId?: string;
|
||||
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent' | 'system_notification';
|
||||
source?:
|
||||
| 'inbox'
|
||||
| 'lead_session'
|
||||
| 'lead_process'
|
||||
| 'user_sent'
|
||||
| 'system_notification'
|
||||
| 'cross_team';
|
||||
attachments?: AttachmentMeta[];
|
||||
/** Lead session ID that produced this message (for session boundary detection). */
|
||||
leadSessionId?: string;
|
||||
|
|
@ -590,6 +596,35 @@ export interface TeamMessageNotificationData {
|
|||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cross-Team Communication
|
||||
// =============================================================================
|
||||
|
||||
export interface CrossTeamMessage {
|
||||
messageId: string;
|
||||
fromTeam: string;
|
||||
fromMember: string;
|
||||
toTeam: string;
|
||||
text: string;
|
||||
summary?: string;
|
||||
chainDepth: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface CrossTeamSendRequest {
|
||||
fromTeam: string;
|
||||
fromMember: string;
|
||||
toTeam: string;
|
||||
text: string;
|
||||
summary?: string;
|
||||
chainDepth?: number;
|
||||
}
|
||||
|
||||
export interface CrossTeamSendResult {
|
||||
messageId: string;
|
||||
deliveredToInbox: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Approval (control_request / control_response protocol)
|
||||
// =============================================================================
|
||||
|
|
|
|||
7
src/types/agent-teams-controller.d.ts
vendored
7
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -60,6 +60,12 @@ declare module 'agent-teams-controller' {
|
|||
reconcileArtifacts(flags?: Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
export interface ControllerCrossTeamApi {
|
||||
sendCrossTeamMessage(flags: Record<string, unknown>): unknown;
|
||||
listCrossTeamTargets(flags?: Record<string, unknown>): unknown;
|
||||
getCrossTeamOutbox(): unknown;
|
||||
}
|
||||
|
||||
export interface AgentTeamsController {
|
||||
tasks: ControllerTaskApi;
|
||||
kanban: ControllerKanbanApi;
|
||||
|
|
@ -67,6 +73,7 @@ declare module 'agent-teams-controller' {
|
|||
messages: ControllerMessageApi;
|
||||
processes: ControllerProcessApi;
|
||||
maintenance: ControllerMaintenanceApi;
|
||||
crossTeam: ControllerCrossTeamApi;
|
||||
}
|
||||
|
||||
export function createController(options: ControllerContextOptions): AgentTeamsController;
|
||||
|
|
|
|||
163
test/main/ipc/crossTeam.test.ts
Normal file
163
test/main/ipc/crossTeam.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@preload/constants/ipcChannels', () => ({
|
||||
CROSS_TEAM_SEND: 'cross-team:send',
|
||||
CROSS_TEAM_LIST_TARGETS: 'cross-team:listTargets',
|
||||
CROSS_TEAM_GET_OUTBOX: 'cross-team:getOutbox',
|
||||
}));
|
||||
|
||||
import {
|
||||
initializeCrossTeamHandlers,
|
||||
registerCrossTeamHandlers,
|
||||
removeCrossTeamHandlers,
|
||||
} from '@main/ipc/crossTeam';
|
||||
|
||||
function createMockIpcMain() {
|
||||
return {
|
||||
handle: vi.fn(),
|
||||
removeHandler: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockService() {
|
||||
return {
|
||||
send: vi.fn(),
|
||||
listAvailableTargets: vi.fn(),
|
||||
getOutbox: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('crossTeam IPC handlers', () => {
|
||||
let mockIpc: ReturnType<typeof createMockIpcMain>;
|
||||
let mockService: ReturnType<typeof createMockService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIpc = createMockIpcMain();
|
||||
mockService = createMockService();
|
||||
// Re-initialize with fresh service for each test
|
||||
initializeCrossTeamHandlers(mockService as never);
|
||||
});
|
||||
|
||||
it('registers 3 IPC handlers', () => {
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(3);
|
||||
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:send', expect.any(Function));
|
||||
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:listTargets', expect.any(Function));
|
||||
expect(mockIpc.handle).toHaveBeenCalledWith('cross-team:getOutbox', expect.any(Function));
|
||||
});
|
||||
|
||||
it('removes all 3 handlers', () => {
|
||||
removeCrossTeamHandlers(mockIpc as never);
|
||||
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(3);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:send');
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:listTargets');
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledWith('cross-team:getOutbox');
|
||||
});
|
||||
|
||||
it('send handler returns success on valid request', async () => {
|
||||
mockService.send.mockResolvedValue({ messageId: 'msg-1', deliveredToInbox: true });
|
||||
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
|
||||
|
||||
const result = await handler({} as never, {
|
||||
fromTeam: 'team-a',
|
||||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: { messageId: 'msg-1', deliveredToInbox: true },
|
||||
});
|
||||
expect(mockService.send).toHaveBeenCalledWith({
|
||||
fromTeam: 'team-a',
|
||||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
summary: undefined,
|
||||
chainDepth: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('send handler returns error on service throw', async () => {
|
||||
mockService.send.mockRejectedValue(new Error('Target team not found'));
|
||||
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
|
||||
|
||||
const result = await handler({} as never, {
|
||||
fromTeam: 'team-a',
|
||||
fromMember: 'lead',
|
||||
toTeam: 'nonexistent',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Target team not found' });
|
||||
});
|
||||
|
||||
it('send handler rejects invalid request', async () => {
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find((c) => c[0] === 'cross-team:send')![1];
|
||||
|
||||
const result = await handler({} as never, null);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid request' });
|
||||
});
|
||||
|
||||
it('listTargets handler calls service', async () => {
|
||||
mockService.listAvailableTargets.mockResolvedValue([
|
||||
{ teamName: 'team-b', displayName: 'Team B' },
|
||||
]);
|
||||
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find(
|
||||
(c) => c[0] === 'cross-team:listTargets'
|
||||
)![1];
|
||||
|
||||
const result = await handler({} as never, 'team-a');
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: [{ teamName: 'team-b', displayName: 'Team B' }],
|
||||
});
|
||||
expect(mockService.listAvailableTargets).toHaveBeenCalledWith('team-a');
|
||||
});
|
||||
|
||||
it('getOutbox handler calls service', async () => {
|
||||
mockService.getOutbox.mockResolvedValue([]);
|
||||
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find(
|
||||
(c) => c[0] === 'cross-team:getOutbox'
|
||||
)![1];
|
||||
|
||||
const result = await handler({} as never, 'team-a');
|
||||
|
||||
expect(result).toEqual({ success: true, data: [] });
|
||||
expect(mockService.getOutbox).toHaveBeenCalledWith('team-a');
|
||||
});
|
||||
|
||||
it('getOutbox handler rejects empty teamName', async () => {
|
||||
registerCrossTeamHandlers(mockIpc as never);
|
||||
const handler = mockIpc.handle.mock.calls.find(
|
||||
(c) => c[0] === 'cross-team:getOutbox'
|
||||
)![1];
|
||||
|
||||
const result = await handler({} as never, '');
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'teamName is required' });
|
||||
});
|
||||
});
|
||||
94
test/main/services/team/CascadeGuard.test.ts
Normal file
94
test/main/services/team/CascadeGuard.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CascadeGuard } from '@main/services/team/CascadeGuard';
|
||||
|
||||
describe('CascadeGuard', () => {
|
||||
let guard: CascadeGuard;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new CascadeGuard();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('rate limit', () => {
|
||||
it('allows up to 10 messages per minute', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
guard.check('team-a', `team-${i}`, 0);
|
||||
guard.record('team-a', `team-${i}`);
|
||||
}
|
||||
// 11th should fail
|
||||
expect(() => guard.check('team-a', 'team-x', 0)).toThrow('rate limit');
|
||||
});
|
||||
|
||||
it('resets after window expires', () => {
|
||||
vi.useFakeTimers();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
guard.check('team-a', `team-${i}`, 0);
|
||||
guard.record('team-a', `team-${i}`);
|
||||
}
|
||||
|
||||
// Advance 61 seconds
|
||||
vi.advanceTimersByTime(61_000);
|
||||
|
||||
// Should succeed now
|
||||
expect(() => guard.check('team-a', 'team-new', 0)).not.toThrow();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('chain depth', () => {
|
||||
it('allows depth 0 through 4', () => {
|
||||
for (let d = 0; d < 5; d++) {
|
||||
expect(() => guard.check('team-a', 'team-b', d)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects depth >= 5', () => {
|
||||
expect(() => guard.check('team-a', 'team-b', 5)).toThrow('chain depth');
|
||||
expect(() => guard.check('team-a', 'team-b', 10)).toThrow('chain depth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pair cooldown', () => {
|
||||
it('rejects same pair within 3s', () => {
|
||||
guard.check('team-a', 'team-b', 0);
|
||||
guard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => guard.check('team-a', 'team-b', 0)).toThrow('cooldown');
|
||||
});
|
||||
|
||||
it('allows same pair after 3s', () => {
|
||||
vi.useFakeTimers();
|
||||
guard.check('team-a', 'team-b', 0);
|
||||
guard.record('team-a', 'team-b');
|
||||
|
||||
vi.advanceTimersByTime(3_001);
|
||||
|
||||
expect(() => guard.check('team-a', 'team-b', 0)).not.toThrow();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('allows different pairs simultaneously', () => {
|
||||
guard.check('team-a', 'team-b', 0);
|
||||
guard.record('team-a', 'team-b');
|
||||
|
||||
expect(() => guard.check('team-a', 'team-c', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears all state', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
guard.check('team-a', `team-${i}`, 0);
|
||||
guard.record('team-a', `team-${i}`);
|
||||
}
|
||||
|
||||
guard.reset();
|
||||
|
||||
expect(() => guard.check('team-a', 'team-0', 0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
67
test/main/services/team/CrossTeamOutbox.test.ts
Normal file
67
test/main/services/team/CrossTeamOutbox.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CrossTeamOutbox } from '@main/services/team/CrossTeamOutbox';
|
||||
|
||||
import type { CrossTeamMessage } from '@shared/types';
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => tmpDir,
|
||||
}));
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outbox-test-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'test-team'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeMessage(overrides: Partial<CrossTeamMessage> = {}): CrossTeamMessage {
|
||||
return {
|
||||
messageId: 'msg-1',
|
||||
fromTeam: 'team-a',
|
||||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'hello',
|
||||
chainDepth: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CrossTeamOutbox', () => {
|
||||
let outbox: CrossTeamOutbox;
|
||||
|
||||
beforeEach(() => {
|
||||
outbox = new CrossTeamOutbox();
|
||||
});
|
||||
|
||||
it('returns empty array when no outbox file exists', async () => {
|
||||
const result = await outbox.read('test-team');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('appends a message and reads it back', async () => {
|
||||
const msg = makeMessage();
|
||||
await outbox.append('test-team', msg);
|
||||
|
||||
const result = await outbox.read('test-team');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].messageId).toBe('msg-1');
|
||||
expect(result[0].fromTeam).toBe('team-a');
|
||||
});
|
||||
|
||||
it('appends multiple messages', async () => {
|
||||
await outbox.append('test-team', makeMessage({ messageId: 'msg-1' }));
|
||||
await outbox.append('test-team', makeMessage({ messageId: 'msg-2' }));
|
||||
|
||||
const result = await outbox.read('test-team');
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
203
test/main/services/team/CrossTeamService.test.ts
Normal file
203
test/main/services/team/CrossTeamService.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CrossTeamService } from '@main/services/team/CrossTeamService';
|
||||
|
||||
import type { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import type { TeamDataService } from '@main/services/team/TeamDataService';
|
||||
import type { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import type { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||
import type { CrossTeamSendRequest, TeamConfig } from '@shared/types';
|
||||
|
||||
vi.mock('@main/utils/pathDecoder', () => ({
|
||||
getTeamsBasePath: () => '/tmp/cross-team-test-nonexistent-dir-' + process.pid,
|
||||
}));
|
||||
|
||||
vi.mock('@shared/utils/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeRequest(overrides: Partial<CrossTeamSendRequest> = {}): CrossTeamSendRequest {
|
||||
return {
|
||||
fromTeam: 'team-a',
|
||||
fromMember: 'lead',
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello from team-a',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
|
||||
return {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CrossTeamService', () => {
|
||||
let service: CrossTeamService;
|
||||
let configReader: { getConfig: ReturnType<typeof vi.fn> };
|
||||
let dataService: { getLeadMemberName: ReturnType<typeof vi.fn> };
|
||||
let inboxWriter: { sendMessage: ReturnType<typeof vi.fn> };
|
||||
let provisioning: {
|
||||
isTeamAlive: ReturnType<typeof vi.fn>;
|
||||
relayLeadInboxMessages: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
configReader = {
|
||||
getConfig: vi.fn().mockResolvedValue(makeConfig()),
|
||||
};
|
||||
dataService = {
|
||||
getLeadMemberName: vi.fn().mockResolvedValue('team-lead'),
|
||||
};
|
||||
inboxWriter = {
|
||||
sendMessage: vi.fn().mockResolvedValue({ deliveredToInbox: true, messageId: 'mock-id' }),
|
||||
};
|
||||
provisioning = {
|
||||
isTeamAlive: vi.fn().mockReturnValue(false),
|
||||
relayLeadInboxMessages: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
service = new CrossTeamService(
|
||||
configReader as unknown as TeamConfigReader,
|
||||
dataService as unknown as TeamDataService,
|
||||
inboxWriter as unknown as TeamInboxWriter,
|
||||
provisioning as unknown as TeamProvisioningService
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('delivers message to inbox via inboxWriter', async () => {
|
||||
const result = await service.send(makeRequest());
|
||||
|
||||
expect(result.deliveredToInbox).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
expect(inboxWriter.sendMessage).toHaveBeenCalledOnce();
|
||||
|
||||
const [teamName, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(teamName).toBe('team-b');
|
||||
expect(req.member).toBe('team-lead');
|
||||
expect(req.source).toBe('cross_team');
|
||||
expect(req.from).toBe('team-a.lead');
|
||||
expect(req.text).toContain('[Cross-team from team-a.lead | depth:0]');
|
||||
});
|
||||
|
||||
it('calls relayLeadInboxMessages when team is alive', async () => {
|
||||
provisioning.isTeamAlive.mockReturnValue(true);
|
||||
|
||||
await service.send(makeRequest());
|
||||
|
||||
expect(provisioning.relayLeadInboxMessages).toHaveBeenCalledWith('team-b');
|
||||
});
|
||||
|
||||
it('does not relay when team is offline', async () => {
|
||||
provisioning.isTeamAlive.mockReturnValue(false);
|
||||
|
||||
await service.send(makeRequest());
|
||||
|
||||
expect(provisioning.relayLeadInboxMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gracefully handles relay failure', async () => {
|
||||
provisioning.isTeamAlive.mockReturnValue(true);
|
||||
provisioning.relayLeadInboxMessages.mockRejectedValue(new Error('relay fail'));
|
||||
|
||||
const result = await service.send(makeRequest());
|
||||
expect(result.deliveredToInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects self-send', async () => {
|
||||
await expect(service.send(makeRequest({ fromTeam: 'team-a', toTeam: 'team-a' }))).rejects.toThrow(
|
||||
'same team'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid team names', async () => {
|
||||
await expect(service.send(makeRequest({ fromTeam: '../evil' }))).rejects.toThrow('Invalid fromTeam');
|
||||
await expect(service.send(makeRequest({ toTeam: 'UPPER' }))).rejects.toThrow('Invalid toTeam');
|
||||
});
|
||||
|
||||
it('rejects empty text', async () => {
|
||||
await expect(service.send(makeRequest({ text: '' }))).rejects.toThrow('text is required');
|
||||
await expect(service.send(makeRequest({ text: ' ' }))).rejects.toThrow('text is required');
|
||||
});
|
||||
|
||||
it('rejects when target not found', async () => {
|
||||
configReader.getConfig.mockResolvedValue(null);
|
||||
await expect(service.send(makeRequest())).rejects.toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects when target is deleted', async () => {
|
||||
configReader.getConfig.mockResolvedValue(makeConfig({ deletedAt: '2024-01-01T00:00:00Z' }));
|
||||
await expect(service.send(makeRequest())).rejects.toThrow('Target team not found');
|
||||
});
|
||||
|
||||
it('rejects excessive chain depth', async () => {
|
||||
await expect(service.send(makeRequest({ chainDepth: 5 }))).rejects.toThrow('chain depth');
|
||||
});
|
||||
|
||||
it('rejects rate limit exceeded', async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await service.send(makeRequest({ toTeam: `team-${String.fromCharCode(98 + i)}` }));
|
||||
configReader.getConfig.mockResolvedValue(
|
||||
makeConfig({ name: `team-${String.fromCharCode(99 + i)}` })
|
||||
);
|
||||
}
|
||||
configReader.getConfig.mockResolvedValue(makeConfig({ name: 'team-z' }));
|
||||
await expect(service.send(makeRequest({ toTeam: 'team-z' }))).rejects.toThrow('rate limit');
|
||||
});
|
||||
|
||||
it('uses "team-lead" as fallback when getLeadMemberName returns null', async () => {
|
||||
dataService.getLeadMemberName.mockResolvedValue(null);
|
||||
|
||||
await service.send(makeRequest());
|
||||
|
||||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(req.member).toBe('team-lead');
|
||||
});
|
||||
|
||||
it('uses from format "team.member"', async () => {
|
||||
await service.send(makeRequest({ fromTeam: 'alpha', fromMember: 'researcher' }));
|
||||
|
||||
const [, req] = inboxWriter.sendMessage.mock.calls[0];
|
||||
expect(req.from).toBe('alpha.researcher');
|
||||
});
|
||||
|
||||
it('works with null provisioning', async () => {
|
||||
const svc = new CrossTeamService(
|
||||
configReader as unknown as TeamConfigReader,
|
||||
dataService as unknown as TeamDataService,
|
||||
inboxWriter as unknown as TeamInboxWriter,
|
||||
null
|
||||
);
|
||||
|
||||
const result = await svc.send(makeRequest());
|
||||
expect(result.deliveredToInbox).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAvailableTargets', () => {
|
||||
it('returns empty when teams dir read fails', async () => {
|
||||
configReader.getConfig.mockRejectedValue(new Error('ENOENT'));
|
||||
const result = await service.listAvailableTargets();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOutbox', () => {
|
||||
it('returns empty for non-existent outbox', async () => {
|
||||
const result = await service.getOutbox('team-a');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
test/main/services/team/fileLock.test.ts
Normal file
77
test/main/services/team/fileLock.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { withFileLock } from '@main/services/team/fileLock';
|
||||
|
||||
describe('withFileLock', () => {
|
||||
let tmpDir: string;
|
||||
let testFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filelock-test-'));
|
||||
testFile = path.join(tmpDir, 'test.json');
|
||||
fs.writeFileSync(testFile, '[]', 'utf8');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('acquires and releases lock around fn()', async () => {
|
||||
const lockPath = `${testFile}.lock`;
|
||||
|
||||
const result = await withFileLock(testFile, async () => {
|
||||
expect(fs.existsSync(lockPath)).toBe(true);
|
||||
return 42;
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('releases lock even on error', async () => {
|
||||
const lockPath = `${testFile}.lock`;
|
||||
|
||||
await expect(
|
||||
withFileLock(testFile, async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes concurrent access', async () => {
|
||||
const order: number[] = [];
|
||||
|
||||
const task = (id: number, delayMs: number) =>
|
||||
withFileLock(testFile, async () => {
|
||||
order.push(id);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
});
|
||||
|
||||
await Promise.all([task(1, 50), task(2, 10), task(3, 10)]);
|
||||
|
||||
expect(order).toHaveLength(3);
|
||||
expect(new Set(order).size).toBe(3);
|
||||
});
|
||||
|
||||
it('removes stale lock and acquires', async () => {
|
||||
const lockPath = `${testFile}.lock`;
|
||||
// Create a stale lock (timestamp 60s ago)
|
||||
fs.writeFileSync(lockPath, `99999\n${Date.now() - 60_000}\n`, 'utf8');
|
||||
|
||||
const result = await withFileLock(testFile, async () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('creates parent directories for lock file', async () => {
|
||||
const nested = path.join(tmpDir, 'a', 'b', 'deep.json');
|
||||
|
||||
const result = await withFileLock(nested, async () => 'created');
|
||||
expect(result).toBe('created');
|
||||
expect(fs.existsSync(`${nested}.lock`)).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue