diff --git a/agent-teams-controller/src/controller.js b/agent-teams-controller/src/controller.js index 85d617d1..262c33e0 100644 --- a/agent-teams-controller/src/controller.js +++ b/agent-teams-controller/src/controller.js @@ -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, }; diff --git a/agent-teams-controller/src/internal/cascadeGuard.js b/agent-teams-controller/src/internal/cascadeGuard.js new file mode 100644 index 00000000..4ed0af56 --- /dev/null +++ b/agent-teams-controller/src/internal/cascadeGuard.js @@ -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 }; diff --git a/agent-teams-controller/src/internal/crossTeam.js b/agent-teams-controller/src/internal/crossTeam.js new file mode 100644 index 00000000..b331252f --- /dev/null +++ b/agent-teams-controller/src/internal/crossTeam.js @@ -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, +}; diff --git a/agent-teams-controller/src/internal/fileLock.js b/agent-teams-controller/src/internal/fileLock.js new file mode 100644 index 00000000..11ec4e7f --- /dev/null +++ b/agent-teams-controller/src/internal/fileLock.js @@ -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 }; diff --git a/agent-teams-controller/test/cascadeGuard.test.js b/agent-teams-controller/test/cascadeGuard.test.js new file mode 100644 index 00000000..7573a60e --- /dev/null +++ b/agent-teams-controller/test/cascadeGuard.test.js @@ -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(); + }); + }); +}); diff --git a/agent-teams-controller/test/crossTeam.test.js b/agent-teams-controller/test/crossTeam.test.js new file mode 100644 index 00000000..325f6b63 --- /dev/null +++ b/agent-teams-controller/test/crossTeam.test.js @@ -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([]); + }); + }); +}); diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 8b861a0f..04043d6f 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -60,6 +60,12 @@ declare module 'agent-teams-controller' { reconcileArtifacts(flags?: Record): unknown; } + export interface ControllerCrossTeamApi { + sendCrossTeamMessage(flags: Record): unknown; + listCrossTeamTargets(flags?: Record): 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; diff --git a/mcp-server/src/tools/crossTeamTools.ts b/mcp-server/src/tools/crossTeamTools.ts new file mode 100644 index 00000000..5586f7c7 --- /dev/null +++ b/mcp-server/src/tools/crossTeamTools.ts @@ -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) { + 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()) + ), + }); +} diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts index 02d73884..755b24f7 100644 --- a/mcp-server/src/tools/index.ts +++ b/mcp-server/src/tools/index.ts @@ -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); } diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 0499ee0d..e419ff7d 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -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', diff --git a/src/main/index.ts b/src/main/index.ts index c4864801..7735c2dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts new file mode 100644 index 00000000..2f109358 --- /dev/null +++ b/src/main/ipc/crossTeam.ts @@ -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( + operation: string, + handler: () => Promise +): Promise> { + 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> { + return wrapCrossTeamHandler('send', () => { + if (!request || typeof request !== 'object') { + throw new Error('Invalid request'); + } + const req = request as Record; + 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> { + return wrapCrossTeamHandler('listTargets', () => + getService().listAvailableTargets(typeof excludeTeam === 'string' ? excludeTeam : undefined) + ); +} + +async function handleGetOutbox( + _event: IpcMainInvokeEvent, + teamName: string +): Promise> { + 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); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 639814ac..6d05559f 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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'); } diff --git a/src/main/services/team/CascadeGuard.ts b/src/main/services/team/CascadeGuard.ts new file mode 100644 index 00000000..c7569aba --- /dev/null +++ b/src/main/services/team/CascadeGuard.ts @@ -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(); + private pairTimestamps = new Map(); + + 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); + } + } + } +} diff --git a/src/main/services/team/CrossTeamOutbox.ts b/src/main/services/team/CrossTeamOutbox.ts new file mode 100644 index 00000000..2e23a664 --- /dev/null +++ b/src/main/services/team/CrossTeamOutbox.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts new file mode 100644 index 00000000..846fa431 --- /dev/null +++ b/src/main/services/team/CrossTeamService.ts @@ -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 { + 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 { + 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 { + return this.outbox.read(teamName); + } +} diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 8ac414ec..e267b220 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -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 { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3a15f284..aee25050 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { 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; - 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; + 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)); + }); }); } diff --git a/src/main/services/team/fileLock.ts b/src/main/services/team/fileLock.ts new file mode 100644 index 00000000..9f95fdb1 --- /dev/null +++ b/src/main/services/team/fileLock.ts @@ -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(filePath: string, fn: () => Promise): Promise { + 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); + } +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index f11cc731..9c3c5c2c 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -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'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index e088565f..3a33b295 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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 // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index eaf6ac25..3b49bc3a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_TOOL_APPROVAL_SETTINGS, settings); }, }, + crossTeam: { + send: async (request: CrossTeamSendRequest) => { + return invokeIpcWithResult(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(CROSS_TEAM_GET_OUTBOX, teamName); + }, + }, review: { getAgentChanges: async (teamName: string, memberName: string) => { return invokeIpcWithResult(REVIEW_GET_AGENT_CHANGES, teamName, memberName); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index f7abefa1..9bb6be6a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 0c0f6433..53d58fa3 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; } +// ============================================================================= +// Cross-Team Communication API +// ============================================================================= + +export interface CrossTeamAPI { + send: (request: CrossTeamSendRequest) => Promise; + listTargets: ( + excludeTeam?: string + ) => Promise<{ teamName: string; displayName: string; description?: string }[]>; + getOutbox: (teamName: string) => Promise; +} + // ============================================================================= // Schedule API // ============================================================================= @@ -761,6 +776,9 @@ export interface ElectronAPI { // Team management API teams: TeamsAPI; + // Cross-Team Communication API + crossTeam: CrossTeamAPI; + // Review API review: ReviewAPI; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index bd7fa2c8..5101c58e 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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) // ============================================================================= diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 8b861a0f..04043d6f 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -60,6 +60,12 @@ declare module 'agent-teams-controller' { reconcileArtifacts(flags?: Record): unknown; } + export interface ControllerCrossTeamApi { + sendCrossTeamMessage(flags: Record): unknown; + listCrossTeamTargets(flags?: Record): 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; diff --git a/test/main/ipc/crossTeam.test.ts b/test/main/ipc/crossTeam.test.ts new file mode 100644 index 00000000..a8b6de01 --- /dev/null +++ b/test/main/ipc/crossTeam.test.ts @@ -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; + let mockService: ReturnType; + + 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' }); + }); +}); diff --git a/test/main/services/team/CascadeGuard.test.ts b/test/main/services/team/CascadeGuard.test.ts new file mode 100644 index 00000000..ad209961 --- /dev/null +++ b/test/main/services/team/CascadeGuard.test.ts @@ -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(); + }); + }); +}); diff --git a/test/main/services/team/CrossTeamOutbox.test.ts b/test/main/services/team/CrossTeamOutbox.test.ts new file mode 100644 index 00000000..7a776304 --- /dev/null +++ b/test/main/services/team/CrossTeamOutbox.test.ts @@ -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 { + 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); + }); +}); diff --git a/test/main/services/team/CrossTeamService.test.ts b/test/main/services/team/CrossTeamService.test.ts new file mode 100644 index 00000000..43b16ad8 --- /dev/null +++ b/test/main/services/team/CrossTeamService.test.ts @@ -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 { + return { + fromTeam: 'team-a', + fromMember: 'lead', + toTeam: 'team-b', + text: 'Hello from team-a', + ...overrides, + }; +} + +function makeConfig(overrides: Partial = {}): TeamConfig { + return { + name: 'team-b', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + ...overrides, + }; +} + +describe('CrossTeamService', () => { + let service: CrossTeamService; + let configReader: { getConfig: ReturnType }; + let dataService: { getLeadMemberName: ReturnType }; + let inboxWriter: { sendMessage: ReturnType }; + let provisioning: { + isTeamAlive: ReturnType; + relayLeadInboxMessages: ReturnType; + }; + + 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([]); + }); + }); +}); diff --git a/test/main/services/team/fileLock.test.ts b/test/main/services/team/fileLock.test.ts new file mode 100644 index 00000000..9896e0b3 --- /dev/null +++ b/test/main/services/team/fileLock.test.ts @@ -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); + }); +});