agent-ecosystem/src/main/services/team/memberUpdateNotifications.ts
2026-05-21 01:10:48 +03:00

220 lines
6.4 KiB
TypeScript

import type { EffortLevel, TeamMemberMcpPolicy, TeamProviderId } from '@shared/types';
export interface MemberDiffInput {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
removedAt?: number | string | null;
}
export interface ReplaceMembersDiff {
added: {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}[];
removed: string[];
updated: {
name: string;
changes: string[];
}[];
}
function normalizeOptionalText(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
function describeRoleChange(
previousRole: string | undefined,
nextRole: string | undefined
): string | null {
if (previousRole === nextRole) {
return null;
}
if (previousRole && nextRole) {
return `role changed from "${previousRole}" to "${nextRole}"`;
}
if (nextRole) {
return `role set to "${nextRole}"`;
}
return 'role cleared';
}
function describeWorkflowChange(
previousWorkflow: string | undefined,
nextWorkflow: string | undefined
): string | null {
if (previousWorkflow === nextWorkflow) {
return null;
}
if (previousWorkflow && nextWorkflow) {
return 'workflow instructions were updated';
}
if (nextWorkflow) {
return 'workflow instructions were added';
}
return 'workflow instructions were cleared';
}
function describeProviderChange(
previousProviderId: TeamProviderId | undefined,
nextProviderId: TeamProviderId | undefined
): string | null {
if (previousProviderId === nextProviderId) {
return null;
}
return 'provider changed - restart required';
}
function describeModelChange(
previousModel: string | undefined,
nextModel: string | undefined
): string | null {
if (previousModel === nextModel) {
return null;
}
return 'model changed - restart required';
}
function describeEffortChange(
previousEffort: EffortLevel | undefined,
nextEffort: EffortLevel | undefined
): string | null {
if (previousEffort === nextEffort) {
return null;
}
return 'reasoning effort changed - restart required';
}
function describeMcpPolicyChange(
previousMcpPolicy: TeamMemberMcpPolicy | undefined,
nextMcpPolicy: TeamMemberMcpPolicy | undefined
): string | null {
if (JSON.stringify(previousMcpPolicy) === JSON.stringify(nextMcpPolicy)) {
return null;
}
return 'MCP access policy changed - restart required';
}
export function buildReplaceMembersDiff(
previousMembers: MemberDiffInput[],
nextMembers: {
name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
providerId?: TeamProviderId;
model?: string;
effort?: EffortLevel;
mcpPolicy?: TeamMemberMcpPolicy;
}[]
): ReplaceMembersDiff {
const previousByName = new Map(
previousMembers
.filter((member) => !member.removedAt && member.name.trim().toLowerCase() !== 'team-lead')
.map((member) => [
member.name.trim().toLowerCase(),
{
name: member.name.trim(),
role: normalizeOptionalText(member.role),
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
mcpPolicy: member.mcpPolicy,
},
])
);
const nextByName = new Map(
nextMembers
.filter((member) => member.name.trim().toLowerCase() !== 'team-lead')
.map((member) => [
member.name.trim().toLowerCase(),
{
name: member.name.trim(),
role: normalizeOptionalText(member.role),
workflow: normalizeOptionalText(member.workflow),
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
providerId: member.providerId,
model: normalizeOptionalText(member.model),
effort: member.effort,
mcpPolicy: member.mcpPolicy,
},
])
);
const added = Array.from(nextByName.entries())
.filter(([name]) => !previousByName.has(name))
.map(([, member]) => member);
const removed = Array.from(previousByName.entries())
.filter(([name]) => !nextByName.has(name))
.map(([, member]) => member.name)
.sort((a, b) => a.localeCompare(b));
const updated = Array.from(nextByName.entries())
.flatMap(([name, nextMember]) => {
const previousMember = previousByName.get(name);
if (!previousMember) {
return [];
}
const changes = [
describeRoleChange(previousMember.role, nextMember.role),
describeWorkflowChange(previousMember.workflow, nextMember.workflow),
previousMember.isolation !== nextMember.isolation
? nextMember.isolation === 'worktree'
? 'worktree isolation enabled'
: 'worktree isolation disabled'
: null,
describeProviderChange(previousMember.providerId, nextMember.providerId),
describeModelChange(previousMember.model, nextMember.model),
describeEffortChange(previousMember.effort, nextMember.effort),
describeMcpPolicyChange(previousMember.mcpPolicy, nextMember.mcpPolicy),
].filter((value): value is string => value !== null);
if (changes.length === 0) {
return [];
}
return [{ name: nextMember.name, changes }];
})
.sort((a, b) => a.name.localeCompare(b.name));
return { added, removed, updated };
}
export function buildReplaceMembersSummaryMessage(diff: ReplaceMembersDiff): string | null {
const lines: string[] = [];
for (const name of diff.removed) {
lines.push(
`- Teammate "${name}" was removed from the team. Stop assigning them new work and reassign any active tasks if needed.`
);
}
for (const update of diff.updated) {
lines.push(
`- Teammate "${update.name}" was updated: ${update.changes.join('; ')}. Please send them refreshed instructions so their live behavior matches the new config.`
);
}
if (lines.length === 0) {
return null;
}
return (
'The user updated the live team roster.\n' +
'Apply these changes to the running team now:\n' +
lines.join('\n')
);
}