feat: enhance member handling and validation in team services

- Updated handleAddMember function to include optional 'workflow' parameter with validation.
- Introduced ensureMemberInMeta method in TeamDataService to migrate members from config.json to meta if necessary.
- Refactored addMember and removeMember methods to utilize ensureMemberInMeta for improved member management.
- Enhanced TeamProvisioningService to ensure message handling includes workflow context in notifications.
This commit is contained in:
iliya 2026-03-10 17:20:49 +02:00
parent bfcdff5955
commit 1949084bd8
3 changed files with 74 additions and 12 deletions

View file

@ -1850,12 +1850,19 @@ async function handleAddMember(
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
const { name, role } = payload as { name?: unknown; role?: unknown };
const { name, role, workflow } = payload as {
name?: unknown;
role?: unknown;
workflow?: unknown;
};
const vName = validateTeammateName(name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
if (role !== undefined && typeof role !== 'string') {
return { success: false, error: 'role must be a string' };
}
if (workflow !== undefined && typeof workflow !== 'string') {
return { success: false, error: 'workflow must be a string' };
}
return wrapTeamHandler('addMember', async () => {
const tn = vTeam.value!;
@ -1863,15 +1870,20 @@ async function handleAddMember(
await getTeamDataService().addMember(tn, {
name: memberName,
role: role,
workflow: typeof workflow === 'string' ? workflow.trim() || undefined : undefined,
});
// If team is alive, notify the lead to spawn the new teammate
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
const roleHint = typeof role === 'string' && role.trim() ? ` with role "${role.trim()}"` : '';
const workflowHint =
typeof workflow === 'string' && workflow.trim()
? ` Their workflow: ${workflow.trim()}`
: '';
const spawnMessage =
`A new teammate "${memberName}"${roleHint} has been added to the team. ` +
`Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".`;
`Please spawn them immediately using the Task tool with team_name="${tn}" and name="${memberName}".${workflowHint}`;
try {
await provisioning.sendMessageToTeam(tn, spawnMessage);
} catch {

View file

@ -599,6 +599,62 @@ export class TeamDataService {
}
}
/**
* Ensures a member exists in members.meta.json.
* Members can appear in the UI from three sources (see TeamMemberResolver):
* 1. members.meta.json
* 2. config.json members array (CLI-created)
* 3. inbox file presence (CLI-spawned teammates)
* If the member exists in source 2 or 3 but not in meta, migrates it so
* that edit/delete operations work.
*/
private async ensureMemberInMeta(
teamName: string,
memberName: string
): Promise<{ members: TeamMember[]; member: TeamMember }> {
const members = await this.membersMetaStore.getMembers(teamName);
let member = members.find((m) => m.name === memberName);
if (!member) {
// Try config.json first — it may have role/workflow info.
const config = await this.configReader.getConfig(teamName);
const configMember = config?.members?.find(
(m) => typeof m?.name === 'string' && m.name.trim() === memberName
);
if (configMember) {
member = {
name: configMember.name.trim(),
role: configMember.role,
workflow: configMember.workflow,
agentType: configMember.agentType ?? 'general-purpose',
color: configMember.color ?? getMemberColorByName(configMember.name.trim()),
joinedAt: configMember.joinedAt ?? Date.now(),
cwd: configMember.cwd,
};
} else {
// Member may exist only via inbox file (CLI-spawned teammate).
// Check if an inbox file exists for this name.
const inboxNames = await this.inboxReader.listInboxNames(teamName);
if (!inboxNames.includes(memberName)) {
throw new Error(`Member "${memberName}" not found`);
}
member = {
name: memberName,
agentType: 'general-purpose',
color: getMemberColorByName(memberName),
joinedAt: Date.now(),
};
}
members.push(member);
await this.membersMetaStore.writeMembers(teamName, members);
}
return { members, member };
}
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
const name = request.name.trim();
if (!name) {
@ -639,9 +695,7 @@ export class TeamDataService {
memberName: string,
newRole: string | undefined
): Promise<{ oldRole: string | undefined; changed: boolean }> {
const members = await this.membersMetaStore.getMembers(teamName);
const member = members.find((m) => m.name === memberName);
if (!member) throw new Error(`Member "${memberName}" not found`);
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
if (member.removedAt) throw new Error(`Member "${memberName}" is removed`);
if (member.agentType === 'team-lead') throw new Error('Cannot change team lead role');
@ -716,12 +770,8 @@ export class TeamDataService {
}
async removeMember(teamName: string, memberName: string): Promise<void> {
const members = await this.membersMetaStore.getMembers(teamName);
const member = members.find((m) => m.name === memberName);
const { members, member } = await this.ensureMemberInMeta(teamName, memberName);
if (!member) {
throw new Error(`Member "${memberName}" not found`);
}
if (member.removedAt) {
throw new Error(`Member "${memberName}" is already removed`);
}

View file

@ -1426,7 +1426,7 @@ export class TeamProvisioningService {
return;
}
const toMark: InboxMessage[] = [];
const toMark: (InboxMessage & { messageId: string })[] = [];
for (const block of deliveredBlocks) {
const matchesBlock = (message: InboxMessage, requireExactText: boolean): boolean => {
if (message.read || message.source !== CROSS_TEAM_SOURCE) return false;
@ -1442,7 +1442,7 @@ export class TeamProvisioningService {
const matched =
leadInboxMessages.find((message) => matchesBlock(message, true)) ??
leadInboxMessages.find((message) => matchesBlock(message, false));
if (!matched) continue;
if (!matched || !this.hasStableMessageId(matched)) continue;
matched.read = true;
toMark.push(matched);
}