384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
import { buildPlannedMemberLaneIdentity } from '@features/team-runtime-lanes';
|
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
|
import { normalizeTeamMemberMcpPolicy } from '@shared/utils/teamMemberMcpPolicy';
|
|
import {
|
|
createCliAutoSuffixNameGuard,
|
|
createCliProvisionerNameGuard,
|
|
} from '@shared/utils/teamMemberName';
|
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
|
|
|
import { selectCurrentActiveTeamTask } from './teamTaskActiveState';
|
|
|
|
import type {
|
|
PersistedTeamLaunchSnapshot,
|
|
TeamConfig,
|
|
TeamMember,
|
|
TeamMemberSnapshot,
|
|
TeamProviderBackendId,
|
|
TeamProviderId,
|
|
TeamTaskWithKanban,
|
|
} from '@shared/types';
|
|
|
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
|
'cross_team_send',
|
|
'cross_team_list_targets',
|
|
'cross_team_get_outbox',
|
|
]);
|
|
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
|
|
|
|
function looksLikeQualifiedExternalRecipient(name: string): boolean {
|
|
const trimmed = name.trim();
|
|
const dot = trimmed.indexOf('.');
|
|
if (dot <= 0 || dot === trimmed.length - 1) return false;
|
|
const teamName = trimmed.slice(0, dot).trim();
|
|
const memberName = trimmed.slice(dot + 1).trim();
|
|
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
|
|
}
|
|
|
|
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
|
|
const trimmed = name.trim();
|
|
const prefixes = [
|
|
'cross_team::',
|
|
'cross_team--',
|
|
'cross-team:',
|
|
'cross-team-',
|
|
'cross_team:',
|
|
'cross_team-',
|
|
];
|
|
for (const prefix of prefixes) {
|
|
if (!trimmed.startsWith(prefix)) continue;
|
|
const teamName = trimmed.slice(prefix.length).trim();
|
|
if (TEAM_NAME_PATTERN.test(teamName)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function looksLikeCrossTeamToolRecipient(name: string): boolean {
|
|
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
|
|
}
|
|
|
|
function looksLikeGeneratedAgentId(name: string): boolean {
|
|
return GENERATED_AGENT_ID_PATTERN.test(name.trim());
|
|
}
|
|
|
|
export class TeamMemberResolver {
|
|
resolveMembers(
|
|
config: TeamConfig,
|
|
metaMembers: TeamConfig['members'],
|
|
inboxNames: string[],
|
|
tasks: TeamTaskWithKanban[],
|
|
options?: {
|
|
launchSnapshot?: PersistedTeamLaunchSnapshot | null;
|
|
leadProviderId?: TeamProviderId;
|
|
leadProviderBackendId?: TeamProviderBackendId | null;
|
|
leadFastMode?: TeamMember['fastMode'];
|
|
leadResolvedFastMode?: boolean | null;
|
|
}
|
|
): TeamMemberSnapshot[] {
|
|
const names = new Set<string>();
|
|
const explicitNames = new Set<string>();
|
|
const seenNames = new Set<string>();
|
|
const addName = (name: string): void => {
|
|
const normalized = name.toLowerCase();
|
|
if (seenNames.has(normalized)) {
|
|
return;
|
|
}
|
|
seenNames.add(normalized);
|
|
names.add(name);
|
|
};
|
|
|
|
if (Array.isArray(config.members)) {
|
|
for (const member of config.members) {
|
|
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
|
const trimmed = member.name.trim();
|
|
addName(trimmed);
|
|
explicitNames.add(trimmed.toLowerCase());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(metaMembers)) {
|
|
for (const member of metaMembers) {
|
|
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
|
const trimmed = member.name.trim();
|
|
addName(trimmed);
|
|
explicitNames.add(trimmed.toLowerCase());
|
|
}
|
|
}
|
|
}
|
|
|
|
const launchSnapshot = options?.launchSnapshot;
|
|
if (launchSnapshot) {
|
|
for (const name of launchSnapshot.expectedMembers) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) continue;
|
|
addName(trimmed);
|
|
explicitNames.add(trimmed.toLowerCase());
|
|
}
|
|
for (const name of Object.keys(launchSnapshot.members)) {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) continue;
|
|
addName(trimmed);
|
|
explicitNames.add(trimmed.toLowerCase());
|
|
}
|
|
}
|
|
|
|
for (const inboxName of inboxNames) {
|
|
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
|
|
const trimmed = inboxName.trim();
|
|
if (
|
|
looksLikeCrossTeamPseudoRecipient(trimmed) ||
|
|
looksLikeCrossTeamToolRecipient(trimmed)
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
!explicitNames.has(trimmed.toLowerCase()) &&
|
|
looksLikeQualifiedExternalRecipient(trimmed)
|
|
) {
|
|
continue;
|
|
}
|
|
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
|
|
continue;
|
|
}
|
|
addName(trimmed);
|
|
}
|
|
}
|
|
|
|
const configMemberMap = new Map<
|
|
string,
|
|
{
|
|
agentId?: string;
|
|
agentType?: string;
|
|
role?: string;
|
|
workflow?: string;
|
|
isolation?: 'worktree';
|
|
providerId?: TeamProviderId;
|
|
providerBackendId?: TeamProviderBackendId;
|
|
model?: string;
|
|
effort?: TeamMember['effort'];
|
|
fastMode?: TeamMember['fastMode'];
|
|
mcpPolicy?: TeamMember['mcpPolicy'];
|
|
color?: string;
|
|
cwd?: string;
|
|
}
|
|
>();
|
|
if (Array.isArray(config.members)) {
|
|
for (const m of config.members) {
|
|
if (typeof m?.name === 'string' && m.name.trim() !== '') {
|
|
const configMember = m as TeamMember & { provider?: TeamProviderId };
|
|
const providerId =
|
|
normalizeOptionalTeamProviderId(configMember.providerId) ??
|
|
normalizeOptionalTeamProviderId(configMember.provider);
|
|
configMemberMap.set(m.name.trim(), {
|
|
agentId: configMember.agentId,
|
|
agentType: configMember.agentType,
|
|
role: configMember.role,
|
|
workflow: configMember.workflow,
|
|
isolation: configMember.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
|
providerId,
|
|
providerBackendId: migrateProviderBackendId(providerId, configMember.providerBackendId),
|
|
model: configMember.model,
|
|
effort: configMember.effort,
|
|
fastMode:
|
|
configMember.fastMode === 'inherit' ||
|
|
configMember.fastMode === 'on' ||
|
|
configMember.fastMode === 'off'
|
|
? configMember.fastMode
|
|
: undefined,
|
|
mcpPolicy: normalizeTeamMemberMcpPolicy(configMember.mcpPolicy),
|
|
color: configMember.color,
|
|
cwd: configMember.cwd,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const metaMemberMap = new Map<
|
|
string,
|
|
{
|
|
agentId?: string;
|
|
agentType?: string;
|
|
role?: string;
|
|
workflow?: string;
|
|
isolation?: 'worktree';
|
|
providerId?: TeamProviderId;
|
|
providerBackendId?: TeamProviderBackendId;
|
|
model?: string;
|
|
effort?: TeamMember['effort'];
|
|
fastMode?: TeamMember['fastMode'];
|
|
mcpPolicy?: TeamMember['mcpPolicy'];
|
|
color?: string;
|
|
cwd?: string;
|
|
removedAt?: number;
|
|
}
|
|
>();
|
|
if (Array.isArray(metaMembers)) {
|
|
for (const member of metaMembers) {
|
|
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
|
metaMemberMap.set(member.name.trim(), {
|
|
agentId: member.agentId,
|
|
agentType: member.agentType,
|
|
role: member.role,
|
|
workflow: member.workflow,
|
|
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
|
providerId: member.providerId,
|
|
providerBackendId: migrateProviderBackendId(
|
|
member.providerId,
|
|
member.providerBackendId
|
|
),
|
|
model: member.model,
|
|
effort: member.effort,
|
|
fastMode:
|
|
member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off'
|
|
? member.fastMode
|
|
: undefined,
|
|
mcpPolicy: normalizeTeamMemberMcpPolicy(member.mcpPolicy),
|
|
color: member.color,
|
|
cwd: member.cwd,
|
|
removedAt: member.removedAt,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const launchMemberMap = new Map<
|
|
string,
|
|
NonNullable<NonNullable<typeof launchSnapshot>['members'][string]>
|
|
>();
|
|
if (launchSnapshot) {
|
|
for (const [memberName, member] of Object.entries(launchSnapshot.members)) {
|
|
if (typeof memberName === 'string' && memberName.trim().length > 0 && member) {
|
|
launchMemberMap.set(memberName.trim(), member);
|
|
}
|
|
}
|
|
}
|
|
|
|
// "user" is a built-in pseudo-member in Claude Code's team framework
|
|
// (recipient of SendMessage to "user"). It's not a real AI teammate.
|
|
names.delete('user');
|
|
|
|
// Defense: merge inbox-derived "lead" alias into canonical "team-lead".
|
|
// Teammates sometimes address messages to "lead" instead of "team-lead",
|
|
// creating a separate inbox file that the resolver picks up as a phantom member.
|
|
if (names.has('lead') && names.has('team-lead')) {
|
|
names.delete('lead');
|
|
}
|
|
|
|
// Defense: hide CLI auto-suffixed duplicates (alice-2) only when the base
|
|
// name still exists as an active member. Removed base members must not hide
|
|
// active suffixed teammates after live mutation / rollback flows.
|
|
const activeNamesForAutoSuffix = Array.from(names).filter((name) => {
|
|
return !metaMemberMap.get(name)?.removedAt;
|
|
});
|
|
const keepName = createCliAutoSuffixNameGuard(activeNamesForAutoSuffix);
|
|
// Defense: hide CLI provisioner artifacts (alice-provisioner) when base name (alice) exists.
|
|
const keepProvisioner = createCliProvisionerNameGuard(names);
|
|
for (const name of Array.from(names)) {
|
|
if (!keepName(name) || !keepProvisioner(name)) {
|
|
names.delete(name);
|
|
}
|
|
}
|
|
|
|
const members: TeamMemberSnapshot[] = [];
|
|
for (const name of names) {
|
|
const ownedTasks = tasks.filter((task) => task.owner === name);
|
|
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
|
const configMember = configMemberMap.get(name);
|
|
const metaMember = metaMemberMap.get(name);
|
|
const launchMember = launchMemberMap.get(name);
|
|
const effectiveProviderId =
|
|
launchMember?.providerId ??
|
|
configMember?.providerId ??
|
|
metaMember?.providerId ??
|
|
options?.leadProviderId;
|
|
const plannedLane = buildPlannedMemberLaneIdentity({
|
|
leadProviderId: options?.leadProviderId,
|
|
member: {
|
|
name,
|
|
providerId: effectiveProviderId,
|
|
},
|
|
});
|
|
const providerBackendId = migrateProviderBackendId(
|
|
effectiveProviderId,
|
|
launchMember?.providerBackendId ??
|
|
configMember?.providerBackendId ??
|
|
metaMember?.providerBackendId ??
|
|
(effectiveProviderId === options?.leadProviderId
|
|
? (options?.leadProviderBackendId ?? undefined)
|
|
: undefined)
|
|
);
|
|
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
|
members.push({
|
|
name,
|
|
agentId,
|
|
currentTaskId: currentTask?.id ?? null,
|
|
taskCount: ownedTasks.length,
|
|
color: configMember?.color ?? metaMember?.color ?? getMemberColorByName(name),
|
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
|
role: configMember?.role ?? metaMember?.role,
|
|
workflow: configMember?.workflow ?? metaMember?.workflow,
|
|
isolation: configMember?.isolation ?? metaMember?.isolation,
|
|
providerId: effectiveProviderId,
|
|
providerBackendId,
|
|
model: launchMember?.model ?? configMember?.model ?? metaMember?.model,
|
|
effort: launchMember?.effort ?? configMember?.effort ?? metaMember?.effort,
|
|
mcpPolicy: configMember?.mcpPolicy ?? metaMember?.mcpPolicy,
|
|
selectedFastMode:
|
|
launchMember?.selectedFastMode ??
|
|
configMember?.fastMode ??
|
|
metaMember?.fastMode ??
|
|
(effectiveProviderId === options?.leadProviderId
|
|
? (options?.leadFastMode ?? undefined)
|
|
: undefined),
|
|
resolvedFastMode:
|
|
typeof launchMember?.resolvedFastMode === 'boolean'
|
|
? launchMember.resolvedFastMode
|
|
: effectiveProviderId === options?.leadProviderId
|
|
? (options?.leadResolvedFastMode ?? undefined)
|
|
: undefined,
|
|
laneId: launchMember?.laneId ?? plannedLane.laneId,
|
|
laneKind: launchMember?.laneKind ?? plannedLane.laneKind,
|
|
laneOwnerProviderId: launchMember?.laneOwnerProviderId ?? plannedLane.laneOwnerProviderId,
|
|
cwd: configMember?.cwd ?? metaMember?.cwd,
|
|
removedAt: metaMember?.removedAt,
|
|
});
|
|
}
|
|
|
|
const explicitConfigOrder = new Map<string, number>();
|
|
for (const [index, member] of config.members?.entries() ?? []) {
|
|
const stableOwnerId = getStableTeamOwnerId(member);
|
|
explicitConfigOrder.set(stableOwnerId, index);
|
|
explicitConfigOrder.set(member.name, index);
|
|
}
|
|
|
|
members.sort((a, b) => {
|
|
const aStableId = getStableTeamOwnerId(a);
|
|
const bStableId = getStableTeamOwnerId(b);
|
|
const aConfigIndex =
|
|
explicitConfigOrder.get(aStableId) ??
|
|
explicitConfigOrder.get(a.name) ??
|
|
Number.POSITIVE_INFINITY;
|
|
const bConfigIndex =
|
|
explicitConfigOrder.get(bStableId) ??
|
|
explicitConfigOrder.get(b.name) ??
|
|
Number.POSITIVE_INFINITY;
|
|
if (aConfigIndex !== bConfigIndex) {
|
|
return aConfigIndex - bConfigIndex;
|
|
}
|
|
return aStableId.localeCompare(bStableId);
|
|
});
|
|
|
|
const colorMap = buildTeamMemberColorMap(members, { preferProvidedColors: false });
|
|
return members.map((member) => ({
|
|
...member,
|
|
color: colorMap.get(member.name) ?? member.color ?? getMemberColorByName(member.name),
|
|
}));
|
|
}
|
|
}
|