feat: improve team management and logging functionality
- Added background polling timer stop during service shutdown to prevent hanging. - Enhanced IPC handlers by importing and utilizing renderer log handlers for better logging. - Updated team-related services to handle member provisioning more robustly, including validation for empty member arrays. - Implemented timeout handling for file system operations to improve reliability. - Improved UI components to reflect solo team status and provide clearer feedback on member counts. Made-with: Cursor
This commit is contained in:
parent
a30727d3b0
commit
fa244052e8
20 changed files with 858 additions and 611 deletions
|
|
@ -765,6 +765,11 @@ function shutdownServices(): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Stop background polling timers (prevents hanging shutdown).
|
||||
if (teamDataService) {
|
||||
teamDataService.stopProcessHealthPolling();
|
||||
}
|
||||
|
||||
// Kill all PTY processes
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.killAll();
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
registerProjectHandlers,
|
||||
removeProjectHandlers,
|
||||
} from './projects';
|
||||
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||
import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review';
|
||||
import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search';
|
||||
import {
|
||||
|
|
@ -69,7 +70,6 @@ import {
|
|||
import { registerUtilityHandlers, removeUtilityHandlers } from './utility';
|
||||
import { registerValidationHandlers, removeValidationHandlers } from './validation';
|
||||
import { registerWindowHandlers, removeWindowHandlers } from './window';
|
||||
import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs';
|
||||
|
||||
import type {
|
||||
ChangeExtractorService,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const lastHeartbeatByWebContentsId = new Map<number, number>();
|
|||
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
||||
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
||||
let heartbeatMonitorStarted = false;
|
||||
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function startHeartbeatMonitor(): void {
|
||||
if (heartbeatMonitorStarted) return;
|
||||
|
|
@ -32,7 +33,7 @@ function startHeartbeatMonitor(): void {
|
|||
const STALE_AFTER_MS = 5000;
|
||||
const WARN_THROTTLE_MS = 10_000;
|
||||
|
||||
setInterval(() => {
|
||||
heartbeatMonitorInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, last] of lastHeartbeatByWebContentsId.entries()) {
|
||||
if (!hasReceivedHeartbeatByWebContentsId.has(id)) {
|
||||
|
|
@ -48,6 +49,9 @@ function startHeartbeatMonitor(): void {
|
|||
logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`);
|
||||
}
|
||||
}, CHECK_EVERY_MS);
|
||||
|
||||
// Diagnostics-only: should not keep the app alive.
|
||||
heartbeatMonitorInterval.unref();
|
||||
}
|
||||
|
||||
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
||||
|
|
@ -91,4 +95,13 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeAllListeners(RENDERER_LOG);
|
||||
ipcMain.removeAllListeners(RENDERER_BOOT);
|
||||
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
||||
|
||||
if (heartbeatMonitorInterval) {
|
||||
clearInterval(heartbeatMonitorInterval);
|
||||
heartbeatMonitorInterval = null;
|
||||
}
|
||||
heartbeatMonitorStarted = false;
|
||||
lastHeartbeatByWebContentsId.clear();
|
||||
lastHeartbeatWarnedAtByWebContentsId.clear();
|
||||
hasReceivedHeartbeatByWebContentsId.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -531,8 +531,8 @@ async function validateProvisioningRequest(
|
|||
return { valid: false, error: 'description must be string' };
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
||||
return { valid: false, error: 'members must contain at least one member' };
|
||||
if (!Array.isArray(payload.members)) {
|
||||
return { valid: false, error: 'members must be an array' };
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
|
|
@ -1317,8 +1317,8 @@ async function handleCreateConfig(
|
|||
return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
||||
return { success: false, error: 'members must contain at least one member' };
|
||||
if (!Array.isArray(payload.members)) {
|
||||
return { success: false, error: 'members must be an array' };
|
||||
}
|
||||
|
||||
if (payload.displayName !== undefined && typeof payload.displayName !== 'string') {
|
||||
|
|
@ -1590,8 +1590,8 @@ async function handleReplaceMembers(
|
|||
return { success: false, error: 'request must be an object' };
|
||||
}
|
||||
const payload = request as { members?: unknown };
|
||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
||||
return { success: false, error: 'members must contain at least one member' };
|
||||
if (!Array.isArray(payload.members)) {
|
||||
return { success: false, error: 'members must be an array' };
|
||||
}
|
||||
const seenNames = new Set<string>();
|
||||
const members: { name: string; role?: string; workflow?: string }[] = [];
|
||||
|
|
|
|||
|
|
@ -60,9 +60,9 @@ const logger = createLogger('Discovery:ProjectScanner');
|
|||
|
||||
// IPC payload safety: session ID arrays can be extremely large for long-lived projects.
|
||||
// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive.
|
||||
// We no longer need session IDs in project/repository listings (session lists are fetched separately).
|
||||
// Keeping this at 0 avoids huge IPC payloads that can stall the renderer thread.
|
||||
const MAX_SESSION_IDS_EXPORTED = 0;
|
||||
// Keep this non-zero because parts of the renderer still rely on a (partial) sessionId list
|
||||
// for lookups and navigation; a small cap preserves that behavior without huge payloads.
|
||||
const MAX_SESSION_IDS_EXPORTED = 200;
|
||||
|
||||
export class ProjectScanner {
|
||||
private readonly projectsDir: string;
|
||||
|
|
|
|||
|
|
@ -253,17 +253,24 @@ export class CliInstallerService {
|
|||
// Run the actual status gathering with an overall timeout.
|
||||
// On timeout, return whatever partial result was collected so far.
|
||||
const ref = { current: result };
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
logger.warn(
|
||||
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
|
||||
);
|
||||
resolve();
|
||||
}, GET_STATUS_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
`getStatus() timed out after ${GET_STATUS_TIMEOUT_MS}ms, returning partial result`
|
||||
);
|
||||
resolve();
|
||||
}, GET_STATUS_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -347,15 +354,22 @@ export class CliInstallerService {
|
|||
};
|
||||
|
||||
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
||||
await Promise.race([
|
||||
doCheck(),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
||||
resolve();
|
||||
}, AUTH_TOTAL_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
doCheck(),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
||||
resolve();
|
||||
}, AUTH_TOTAL_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -359,9 +359,12 @@ export class DataCache {
|
|||
*/
|
||||
startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout {
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
return setInterval(() => {
|
||||
const timer = setInterval(() => {
|
||||
this.cleanExpired();
|
||||
}, intervalMs);
|
||||
// Background maintenance should not keep the process alive.
|
||||
timer.unref();
|
||||
return timer;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* This is the default provider used when operating in local mode.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
import type {
|
||||
|
|
@ -21,6 +23,18 @@ const STAT_TIMEOUT_MS = 2000;
|
|||
// let callers stat only the files they actually need.
|
||||
const STAT_PREFETCH_LIMIT = 1500;
|
||||
|
||||
async function statWithTimeout(filePath: string, timeoutMs: number): Promise<fs.Stats> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('stat timeout')), timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fs.promises.stat(filePath), timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
|
|
@ -57,12 +71,7 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
}
|
||||
|
||||
async stat(filePath: string): Promise<FsStatResult> {
|
||||
const stats = await Promise.race([
|
||||
fs.promises.stat(filePath),
|
||||
new Promise<fs.Stats>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
const stats = await statWithTimeout(filePath, STAT_TIMEOUT_MS);
|
||||
return {
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
|
|
@ -90,13 +99,8 @@ export class LocalFileSystemProvider implements FileSystemProvider {
|
|||
let birthtimeMs: number | undefined;
|
||||
let size: number | undefined;
|
||||
try {
|
||||
const fullPath = `${dirPath}/${entry.name}`;
|
||||
const stat = await Promise.race([
|
||||
fs.promises.stat(fullPath),
|
||||
new Promise<fs.Stats>((_resolve, reject) =>
|
||||
setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS)
|
||||
),
|
||||
]);
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
const stat = await statWithTimeout(fullPath, STAT_TIMEOUT_MS);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
birthtimeMs = stat.birthtimeMs;
|
||||
size = stat.size;
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@ export class TeamConfigReader {
|
|||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
if (!name) return;
|
||||
// Summary/memberCount should represent teammates (exclude the lead process).
|
||||
if (name === 'team-lead' || m.agentType === 'team-lead') return;
|
||||
const key = name.toLowerCase();
|
||||
const existing = memberMap.get(key);
|
||||
memberMap.set(key, {
|
||||
|
|
|
|||
|
|
@ -657,15 +657,15 @@ export class TeamDataService {
|
|||
teamName: string,
|
||||
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||
): Promise<void> {
|
||||
if (!request.members.length) {
|
||||
throw new Error('At least one member is required');
|
||||
}
|
||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||
const joinedAt = Date.now();
|
||||
const newMembers: TeamMember[] = request.members.map((member, index) => {
|
||||
const nextByName = new Set<string>();
|
||||
|
||||
const nextActive: TeamMember[] = request.members.map((member, index) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
nextByName.add(name.toLowerCase());
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
return {
|
||||
name,
|
||||
|
|
@ -674,9 +674,24 @@ export class TeamDataService {
|
|||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
color: prev?.color ?? getMemberColor(index),
|
||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||
removedAt: undefined,
|
||||
};
|
||||
});
|
||||
await this.membersMetaStore.writeMembers(teamName, newMembers);
|
||||
|
||||
// Preserve/mark removed members so stale inbox files don't resurrect them in the UI.
|
||||
const nextRemoved: TeamMember[] = [];
|
||||
for (const prev of existing) {
|
||||
const prevName = prev.name.trim();
|
||||
if (!prevName) continue;
|
||||
const key = prevName.toLowerCase();
|
||||
if (nextByName.has(key)) continue;
|
||||
nextRemoved.push({
|
||||
...prev,
|
||||
removedAt: prev.removedAt ?? joinedAt,
|
||||
});
|
||||
}
|
||||
|
||||
await this.membersMetaStore.writeMembers(teamName, [...nextActive, ...nextRemoved]);
|
||||
}
|
||||
|
||||
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -492,6 +492,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
[data?.members]
|
||||
);
|
||||
|
||||
const activeTeammateCount = useMemo(
|
||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||
[activeMembers]
|
||||
);
|
||||
|
||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||
|
||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||
|
|
@ -928,7 +933,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sectionId="team"
|
||||
title="Team"
|
||||
icon={<Users size={14} />}
|
||||
badge={activeMembers.length}
|
||||
badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
|
||||
defaultOpen
|
||||
action={
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -729,6 +729,10 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
renderMemberChips(team.members)
|
||||
) : team.memberCount === 0 ? (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Solo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Members: {team.memberCount}
|
||||
|
|
|
|||
|
|
@ -156,14 +156,6 @@ function validateRequest(
|
|||
},
|
||||
};
|
||||
}
|
||||
if (request.members.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: {
|
||||
members: 'At least one member is required',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (request.members.some((member) => !member.name.trim())) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
|
|||
|
|
@ -98,10 +98,6 @@ export const EditTeamDialog = ({
|
|||
return;
|
||||
}
|
||||
const builtMembers = buildMembersFromDrafts(members);
|
||||
if (builtMembers.length === 0) {
|
||||
setError('At least one member is required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export const MemberList = ({
|
|||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
No members found
|
||||
Solo team — lead only
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,6 @@ declare global {
|
|||
// module-level side effect guarded by a global flag.
|
||||
if (!window.__claudeTeamsUiDidInit) {
|
||||
window.__claudeTeamsUiDidInit = true;
|
||||
if (import.meta.env.DEV) {
|
||||
// Intentionally console.warn so it shows up in main terminal via preload forwarding.
|
||||
console.warn('[Perf:Renderer] boot renderer/main.tsx');
|
||||
}
|
||||
initializeNotificationListeners();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,43 +81,17 @@ export function initializeNotificationListeners(): () => void {
|
|||
// Components also fire these from useEffect — loading guards in each action
|
||||
// prevent duplicate IPC calls (whichever caller starts first wins).
|
||||
void (async () => {
|
||||
const isDev = import.meta.env.DEV;
|
||||
const log = (msg: string): void => {
|
||||
if (!isDev) return;
|
||||
console.warn(`[Perf:Renderer] init ${msg}`);
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
|
||||
// Config: fast (in-memory read) — needed for theme before first paint.
|
||||
log('fetchConfig:start');
|
||||
const configStartedAt = Date.now();
|
||||
await useStore.getState().fetchConfig();
|
||||
log(`fetchConfig:done ms=${Date.now() - configStartedAt}`);
|
||||
|
||||
// Remaining fetches have no data dependency on each other — run in parallel
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
const run = async (label: string, fn: () => Promise<void>): Promise<void> => {
|
||||
log(`${label}:start`);
|
||||
const s = Date.now();
|
||||
try {
|
||||
await fn();
|
||||
log(`${label}:done ms=${Date.now() - s}`);
|
||||
} catch (e) {
|
||||
log(
|
||||
`${label}:error ms=${Date.now() - s} msg=${e instanceof Error ? e.message : String(e)}`
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
run('fetchRepositoryGroups', () => useStore.getState().fetchRepositoryGroups()),
|
||||
run('fetchAllTasks', () => useStore.getState().fetchAllTasks()),
|
||||
run('fetchTeams', () => useStore.getState().fetchTeams()),
|
||||
run('fetchNotifications', () => useStore.getState().fetchNotifications()),
|
||||
useStore.getState().fetchRepositoryGroups(),
|
||||
useStore.getState().fetchAllTasks(),
|
||||
useStore.getState().fetchTeams(),
|
||||
useStore.getState().fetchNotifications(),
|
||||
]);
|
||||
|
||||
log(`init:done ms=${Date.now() - startedAt}`);
|
||||
})();
|
||||
|
||||
// CLI status check is non-critical for initial render (spawns child processes
|
||||
|
|
|
|||
|
|
@ -361,9 +361,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
fetchAllTasks: async () => {
|
||||
// Guard: prevent concurrent fetches (component mount + centralized init chain)
|
||||
if (get().globalTasksLoading) return;
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:enter');
|
||||
}
|
||||
// Show skeleton only on the very first fetch — not on subsequent refreshes
|
||||
// even when the task list is empty (avoids flickering skeleton on every watcher event).
|
||||
const isInitialLoad = !get().globalTasksInitialized;
|
||||
|
|
@ -374,18 +371,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
const wasFirst = isFirstFetchAllTasks;
|
||||
isFirstFetchAllTasks = false;
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:invoke');
|
||||
}
|
||||
const tasks = await withTimeout(
|
||||
unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()),
|
||||
TEAM_FETCH_TIMEOUT_MS,
|
||||
'fetchAllTasks'
|
||||
);
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`[Perf:Renderer] fetchAllTasks:received count=${tasks.length}`);
|
||||
}
|
||||
|
||||
if (!wasFirst) {
|
||||
const notifyOnClarifications =
|
||||
get().appConfig?.notifications?.notifyOnClarifications ?? true;
|
||||
|
|
@ -405,9 +395,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
globalTasksInitialized: true,
|
||||
globalTasksError: null,
|
||||
});
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[Perf:Renderer] fetchAllTasks:setState:done');
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
globalTasksLoading: false,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import {
|
|||
TEAM_UPDATE_MEMBER_ROLE,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
} from '../../../src/preload/constants/ipcChannels';
|
||||
import {
|
||||
initializeTeamHandlers,
|
||||
|
|
@ -149,6 +150,8 @@ describe('ipc teams handlers', () => {
|
|||
setTaskNeedsClarification: vi.fn(async () => undefined),
|
||||
addTaskRelationship: vi.fn(async () => undefined),
|
||||
removeTaskRelationship: vi.fn(async () => undefined),
|
||||
replaceMembers: vi.fn(async () => undefined),
|
||||
createTeamConfig: vi.fn(async () => undefined),
|
||||
};
|
||||
const provisioningService = {
|
||||
prepareForProvisioning: vi.fn(async () => ({
|
||||
|
|
@ -617,4 +620,68 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('solo team (zero members)', () => {
|
||||
it('createTeam accepts members: [] (provisioning validation)', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'solo-team',
|
||||
members: [],
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.createTeam).toHaveBeenCalledTimes(1);
|
||||
const callArg = provisioningService.createTeam.mock.calls[0][0];
|
||||
expect(callArg.members).toEqual([]);
|
||||
});
|
||||
|
||||
it('handleCreateConfig accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'solo-team',
|
||||
members: [],
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handleReplaceMembers accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: [],
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.replaceMembers).toHaveBeenCalledWith('my-team', { members: [] });
|
||||
});
|
||||
|
||||
it('still rejects members as non-array in createTeam', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'solo-team',
|
||||
members: 'not-array',
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean; error: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('members must be an array');
|
||||
});
|
||||
|
||||
it('still rejects members as non-array in handleCreateConfig', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'solo-team',
|
||||
members: 'not-array',
|
||||
})) as { success: boolean; error: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('members must be an array');
|
||||
});
|
||||
|
||||
it('still rejects members as non-array in handleReplaceMembers', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
members: 'not-array',
|
||||
})) as { success: boolean; error: string };
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('members must be an array');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue